一份耗时数月、所有利益相关者都点了头的迁移spec,落地第一个PR时就撞了墙。程序员在梳理代码时,发现调用栈两层以上的地方,悄悄藏着一句db.commit()——外层明明包着with transaction(),看起来是一个原子操作,实际上内层这行提交一旦执行成功,木已成舟,外层再出错也回滚不回去了。这个坑,他填了数月。

事务的“原子性外套”是怎么被撕破的

with transaction()给人的错觉是:里面的代码要么全成功,要么全回滚。但如果被调用的某个helper函数内部自己调了一次commit(),这次提交跟外层事务没有任何关系——数据已经落盘,外层就算后面出异常回滚,也只能回滚“还没提交”的那部分。原子性从这一刻起就是假的

更麻烦的是,这类手动commit往往埋得很深,调用它的人根本不知道自己踩了雷。文章里管这个叫“隐藏的敌人”:表面上只是调了一个业务方法,背地里已经悄悄结束了一次事务。

事务边界是怎么碎的 开启事务 with transaction() create_main 内部悄悄commit()! create_details 照常执行 事务“结束” 原子性已破

原文还给了另外两个坑:一个是给ORM对象随手设个属性,看起来只是赋值,底层却在悄悄写库(“沉默的伪朋友”);另一个是有人把transaction()注释掉却忘了删调用代码,请求结束数据直接消失(“老爹买牛奶再也没回来”)。三个坑的共同点是:代码看起来正常,行为却完全不受控

为什么“设个属性”也能偷偷写库

这不是玄学。SQLAlchemy的官方文档写得很清楚:Session默认采用autobegin机制,事务会在第一次需要时自动开启;而commit()会无条件flush掉所有挂起的变更,提交当前事务,然后让托管对象过期。也就是说,ORM对象的属性天生带变更追踪,你改一个字段,它就记在账上,只等下一次flush把账结清。

想拖延也没用:文档同样说明,即便用session.no_autoflush临时关掉自动flush,只要调用了commit(),它照样会强制flush。换句话说,只要代码里有一处commit,前面所有“看起来只是设置了个值”的操作,都会被一次性交代出去。这解释了“沉默的伪朋友”为什么防不胜防——问题不在你写没写commit,而在于你根本不知道链条上哪里有一个。


框架早给了退路,只是没人真的用

SQLAlchemy自己也不主张到处手动commit。官方推荐的写法是用session.begin()做上下文管理器:正常退出自动flush加commit,抛异常自动rollback,事务边界完全交给一个地方管理。

谁该拥有commit() 分散手动commit helper内部commit 属性赋值静默写库 注释掉transaction DAL统一session.begin() 上层不碰commit 退出自动flush+commit 异常自动rollback

框架已经把防线修好了,问题是团队没把它当成规矩去执行。这才是这次事故最容易被忽略的一层:不是工具没给出路,是没人逼着大家走那条路

锅该谁背

原文的态度很硬:不要怪框架,不要怪业务需求,程序员是唯一该负责的人。这话说得痛快,但也留了一个缺口——这个项目的spec被审了数月,所有人都点了头,为什么事前梳理阶段没有静态扫描出这些散落的commit?作者自己笔记里写着“investigate random-ass commits”,却直到踩坑那天才想起来。

千里之堤,溃于蚁穴,这句话用在这里恰好,也恰好不完全对。真正让堤坝决口的,往往不是蚁穴本身,而是没人巡堤——代码评审和CI里压根没有一道检查跨层commit的关口。把责任全压在写代码的人身上,情绪上解气,但漏掉了流程本该拦住这一关的事实。

  • 风险.如果代码评审和CI里没有专门检查跨层commit/session访问的规则,同样的“隐藏敌人”下一次还会从另一个helper里冒出来。

怎么把规矩钉死

原文给的方案很朴素:用AST遍历代码,禁止手动调commit()、禁止DAL之外访问session、禁止DAL之外操作transaction,再用同样的逻辑封装成flake8插件塞进CI。这类规则能覆盖大部分静态可查的违规。

真正棘手的是“DAL层是否偷偷把ORM对象当domain模型返回”——这个mypy和AST都抓不住,因为返回类型注解可以撒谎,cast()能把类型强行洗白。原文给的补丁是让LLM在CI里扫一遍DAL层的公开方法,回答一个问题:有没有函数返回的是DB模型而不是domain模型,命中就转人工review。

  • 结论.AST/flake8能挡住“显性违规”,能不能挡住“伪装成正常返回值的ORM对象”,才是这套防线真正的分水岭。

顺带一提,这篇文章在不同索引来源里出现过另一个标题,内容本身没变,只是标注有出入,读者按原文实际标题查证即可,不影响事情本身的判断。两层调用之外的一行commit(),最后拖垮的不是代码,是几个月的工时——防线其实早就摆在文档里,没人巡逻才是真正的漏洞。