1. 一个普通的周二下午,我的数据库炸了
那天下午,我正悠哉游哉地喝着第三杯美式,突然监控报警像疯了一样弹出来。
数据库连接数飙到了上限,系统卡得跟幻灯片似的。运维大哥直接杀到我工位:"你昨晚上的那个发版,到底干了什么?"
我心里咯噔一下。昨晚我只是给一个批量处理订单的功能加了个重试逻辑,测试环境跑得好好的,怎么上了生产就翻车了?
赶紧翻代码。问题很快定位到了——我写了个数据库事务处理,手动控制 begin 和 commit,中间出了异常,我确实在 except 里写了 rollback。逻辑上看没问题啊,但运行日志告诉我:异常发生后,事务根本没有回滚,而且连接也没释放。
更气人的是,我明明写了 finally 来关连接,但那段代码在某个极端路径下跳过去了。不是我粗心,是这种"手动挡"的操作,在复杂的业务逻辑里,总有你照顾不到的角落。
旁边那哥们探头看了一眼,轻描淡写地说了一句:"你为啥不用 with?"
我嘴硬:"我用啊,我关文件一直用 with。这是数据库,又不是文件。"
他笑了笑:"with 管的不是文件,管的是'有头有尾'这件事。"
这句话,让我重新认识了 with。
2. 我眼中 VS 真实世界的 with
在很长一段时间里,我对 with 的认知就是这样的:
with open("data.txt", "r") as f:
content = f.read()
我觉得 with 就是文件操作的"专属语法糖",作用就是帮我们自动调用 close(),省得忘关文件导致句柄泄露。
这个理解说对也对,但格局小了,小得离谱。
真实世界的 with 是这样的:
with是一套通用的"头尾管理"机制。任何"先做点啥,最后必须做点啥"的场景,都能用它来管。
你的脑回路得从"用 with 关文件"升级成"用 with 保证善后"。
文件操作只不过是个最典型的例子。数据库事务、锁的获取与释放、临时环境变量的修改与还原、计时统计、甚至会话管理,所有"必须有收尾动作"的操作,with 都能包圆。
3. 扒开 with 的马甲:上下文管理器协议
要搞清楚 with 为什么能管这么多事儿,得先看它到底是怎么工作的。
with 语句背后站着的是一套协议,叫上下文管理器协议。这套协议就两个方法:
__enter__():进入with代码块时自动调用__exit__():退出with代码块时自动调用(不管是因为执行完了,还是因为抛了异常)
我们平时写的 open("data.txt", "r") 返回的文件对象,内部就实现了这两个方法。__enter__ 返回文件句柄,__exit__ 负责关闭文件。
流程是这样的:
with 表达式 as 变量:
代码块
Python 解释器看到这个结构,会按下面的步骤执行:
第一步: 执行"表达式",拿到一个对象(这个对象必须实现了 __enter__ 和 __exit__)。
第二步: 调用这个对象的 __enter__() 方法,返回值赋给 as 后面的变量(如果有的话)。
第三步: 执行 代码块。
第四步: 不管 代码块 是正常跑完了,还是中间出了异常,最后都会调用这个对象的 __exit__() 方法。如果出了异常,异常信息会被当作参数传给 __exit__。
看到没?异常也能触发 __exit__。 这就是事务回滚和连接释放能靠它实现的原因——无论如何都能保证收尾代码被执行。
4. 实战:用 with 管住我的手忙脚乱
先看一个经典的"手动挡"翻车现场,这代码你肯定见过类似的:
def transfer_money(from_account, to_account, amount):
db = get_connection()
cursor = db.cursor()
try:
cursor.execute("BEGIN")
cursor.execute("UPDATE account SET balance = balance - %s WHERE id = %s", (amount, from_account))
# 万一这里抛异常了呢?比如说余额不足?
cursor.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (amount, to_account))
cursor.execute("COMMIT")
except Exception as e:
cursor.execute("ROLLBACK")
raise e
finally:
cursor.close()
db.close()
这代码有什么毛病?
- 万一
COMMIT本身失败了,回滚处理了吗? - 万一
ROLLBACK也抛异常了呢? - 每个数据库操作的地方都得复制粘贴这一大坨
try-except-finally,快吐了。
用 with 重构一下,先不管连接池的实现细节,看看调用方的体验:
def transfer_money(from_account, to_account, amount):
with Transaction() as tx:
tx.execute("UPDATE account SET balance = balance - %s WHERE id = %s", (amount, from_account))
tx.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (amount, to_account))
tx.commit() # 如果这段代码正常结束,自动提交
# 如果抛异常,自动回滚,连接自动释放
你没看错,就这些。没有手动的 begin、commit、rollback,没有 finally 里小心翼翼的清理。一切都交给 with 背后的 __enter__ 和 __exit__ 去操心。
你可能会问,这个 Transaction 类长什么样?其实很简单:
class Transaction:
def __init__(self):
self.conn = get_connection()
self.cursor = self.conn.cursor()
def __enter__(self):
self.cursor.execute("BEGIN")
return self # 返回自己,让 with 里面的代码可以用 tx.xxx
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
# 没异常,提交事务
self.conn.commit()
else:
# 有异常,回滚
self.conn.rollback()
# 无论如何,关连接
self.cursor.close()
self.conn.close()
# 返回 False 表示异常继续向上抛,True 表示吃掉异常
return False
def execute(self, sql, params):
return self.cursor.execute(sql, params)
核心思想是:好话不说两遍,善后只写一次。 所有跟"怎么善后"相关的逻辑,封在 __exit__ 里。业务代码只关心"怎么干活"。
5. 生活里到处都是"有头有尾"的场景
一旦你理解了 with 管的是"善后"而不是"文件",你会发现 Python 生态里到处是它的身影。我给你扒拉几个极其常见但你未必注意过的场景。
场景一:线程锁,不用再怕忘了释放
不用 with 的时候:
import threading
lock = threading.Lock()
lock.acquire()
# 如果这里抛异常了,锁永远释放不了
try:
# 干活
pass
finally:
lock.release()
用 with 之后:
with lock:
# 干活,完事自动释放
pass
锁对象本来就实现了上下文管理器协议,__enter__ 调 acquire(),__exit__ 调 release()。干净利落。
场景二:临时修改环境变量,完事自动还原
有时候测试代码需要临时改个环境变量,改完得恢复,不然影响其他测试用例。
import os
from contextlib import contextmanager
@contextmanager
def set_env_var(key, value):
old_value = os.environ.get(key)
os.environ[key] = value
try:
yield
finally:
if old_value is None:
del os.environ[key]
else:
os.environ[key] = old_value
# 用起来
with set_env_var("DEBUG", "true"):
# 在这段代码里,DEBUG 是 true
run_tests()
# 出来了,DEBUG 自动恢复成原来的值
这里用了 contextlib.contextmanager,一个超级好用的装饰器。你把逻辑分成三段:yield 之前是"开头",yield 本身是"正事",finally 里是"收尾"。装饰器帮你自动包装成上下文管理器。
场景三:计时器,统计代码块耗时
想统计某段代码跑了多久,又不想到处贴 time.time()?
import time
from contextlib import contextmanager
@contextmanager
def timer(name):
start = time.time()
try:
yield
finally:
elapsed = time.time() - start
print(f"[{name}] 耗时: {elapsed:.3f}s")
# 用起来
with timer("查数据库"):
result = query_database()
# 自动打印耗时
一行装饰器搞定,清爽得不像话。
场景四:临时切换工作目录
有时候需要临时切到某个目录干活,干完再切回来。
import os
from contextlib import contextmanager
@contextmanager
def chdir(path):
cwd = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(cwd)
with chdir("/tmp"):
# 在 /tmp 里干活
os.listdir(".")
# 回到原来的目录
6. 别光顾着用,给你个进阶小抄
contextlib 模块里除了 contextmanager 装饰器,还有几个狠角色,能让你少写很多代码。
contextlib.closing:专治那些"只有 close 方法"的对象
有些老旧的库,对象没有实现上下文管理器协议,但有个 close() 方法。用 closing 包一下就支持 with 了。
from contextlib import closing
from urllib.request import urlopen
with closing(urlopen("https://api.example.com")) as response:
data = response.read()
# 自动 close
contextlib.ExitStack:同时管理多个上下文
当你需要同时打开一堆资源,或者动态决定要进几个上下文时,ExitStack 是你的救星。
from contextlib import ExitStack
with ExitStack() as stack:
files = []
for filename in ["a.txt", "b.txt", "c.txt"]:
f = stack.enter_context(open(filename, "w"))
files.append(f)
# 三个文件同时开着,with 结束时一起关
# 而且如果其中一个打开失败了,前面已经打开的也会被关掉,不泄露
不用再写嵌套的 with open as f1, open as f2... 了,动态批量管理,优雅得很。
7. 你问我答:with 的几个灵魂拷问
Q:with 能处理异常吗?A:能。__exit__ 会收到异常信息。你可以选择返回 True 来"吃掉"异常(表示这个异常已经被处理了,不会继续往上抛),或者返回 False(默认行为)让异常继续向外传播。
Q:如果我主动在 with 里面 return 了,__exit__ 还会被执行吗?A:会。return 也算"退出代码块",__exit__ 会被触发。所以放心大胆地在 with 里 return,善后工作依然会执行。
Q:数据库连接池场景下,with 里关的是连接还是归还连接?A:看你 __exit__ 怎么写。你可以写 conn.close() 真正关闭,也可以写 pool.release(conn) 归还给连接池。with 不关心你具体做什么,只保证你"一定会做"。
Q:用 @contextmanager 装饰器和自己写类实现上下文协议,怎么选?A:简单场景(就是开头做点事,结尾做点事)用 @contextmanager,代码更短。复杂场景(需要携带状态、需要精细控制异常传播、需要复用的)自己写类,可读性更好。
8. 把思维从"关文件"升级到"管善后"
回过头来看,我当年犯的错本质上不是语法错误,而是思维模型的错误。
我把 with 当成"文件专属工具"来记,所以在遇到数据库事务时,脑子里压根没闪过 with 的影子。我下意识地拿 try-except-finally 去硬扛,扛得手忙脚乱,还扛出了生产事故。
但如果我把 with 的理解升级为"保证善后的工具",那么不管面对文件、事务、锁、临时变量还是其他任何"有头有尾"的操作,我的第一反应都会是:这事儿能不能交给 with 去管?
而且 with 还有个隐藏好处:它让你的代码意图更清晰。看到 with lock:,读者一眼就知道"这地方上锁了,完事自动解"。看到 with Transaction():,读者立刻明白"这是个事务,要么全成,要么全回滚"。这种"声明式"的写法,远比满屏的 try-finally 更容易理解、更难出错。
最后送你一句我后来贴在工位上的话:
凡是需要"善后"的事,都应该交给
with去办。你只管想清楚"开头做什么、结尾做什么",剩下的,Python 替你兜底。
以后再写事务、加锁、改环境变量,甚至自己造个需要收尾的工具,记得先问自己一句:"我能不能给它写个上下文管理器?" 相信我,这比你写一百遍 finally 都要舒服得多。