先把误区打掉
很多人把 with self.env.cr.savepoint(): 理解成:
- 里面出错就回滚这一小段
- 外面继续跑
- 跟 ORM 其他状态基本无关
这理解只对了一半。
在 Odoo 里,savepoint 从来不是单纯的 SQL 技巧,它还和:
- flush
- 脏字段
- ORM cache
- 重算队列
绑在一起。
所以它真正解决的是:
如何在同一大事务里允许局部失败,同时尽量不把环境状态搞乱。
第一层:默认 savepoint 是带 flush 语义的
在 odoo/sql_db.py 里,savepoint(flush=True) 是默认行为。
源码注释写得很明确:
- 默认会自动运行相关 hooks
- 也会在需要时做清理
这说明 Odoo 不想让 savepoint 只停留在数据库游标层。
为什么?
因为 ORM 里很多值此刻还在 cache / dirty queue 里,根本没落库。
如果不处理这些状态,数据库层回滚了,Python 层却还记着“我刚刚改过”,环境就脏了。
第二层:flush 的职责不是“马上提交”,而是把待写状态对齐到数据库
flush_model() / flush_recordset() 干的事不是 commit。
它们做的是:
- 先处理待重算字段
- 再把 dirty field 对应的更新真正写到数据库
所以 flush 的真实含义更像:
把 ORM 里“已决定但尚未落库”的内容,先同步到当前事务中的数据库状态。
这和 commit 差很远:
- flush 后仍在当前事务里
- rollback / savepoint rollback 依然能回退
第三层:为什么 savepoint 会和缓存一致性绑死
Odoo 的 ORM cache 默认相信:
- 我已经读到的值是可信的
- 我准备写的值在 dirty 队列里也是可信的
但如果 savepoint 内部 SQL 或 ORM 操作失败,数据库局部回滚后:
- 数据库状态回到了 earlier point
- Python 层 cache 却可能还留着失败前的脏印象
所以 invalidate_recordset() / _invalidate_cache() 这类机制才重要。
它们的存在就是为了在“数据库事实”和“缓存记忆”分叉时,把缓存打回去。
第四层:modified() 不是装饰动作,而是在挂重算触发器
models.py 里的 modified() 会做两件关键事:
- 在需要时失效缓存
- 为依赖该字段的 stored compute 字段准备重算
所以你如果在 savepoint 里做了底层 SQL 更新,甚至跳过 ORM 自己写表,光是“SQL 成功了”并不够。
如果没有正确通知 ORM:
- 依赖字段可能不重算
- 旧缓存可能继续被读到
这就是为什么很多“局部 SQL 修复脚本”跑完后,界面结果还不对,直到下一次事务或重新打开才正常。
第五层:为什么批处理喜欢 savepoint,但又最容易把环境写脏
典型批处理会这样写:
for rec in records:
with self.env.cr.savepoint():
rec._do_one_thing()
目的很好:
- 某一条失败,不拖垮整个批次
但真正的风险在于:
风险 1:savepoint 里混原生 SQL,外面继续信任旧 cache
你以为已经“局部回滚 clean 了”,其实 cache 还是旧的。
风险 2:异常被吃掉了,但脏状态没彻底清
结果后面几条记录读到的是半旧半新的环境。
风险 3:为了省事把 flush=False 滥用
这并不是性能开关,而是在告诉 Odoo:
- 我自己来负责一致性
这件事通常比想象中难得多。
一个好理解的心智模型
你可以把 Odoo savepoint 想成两层:
数据库层
- 建一个子事务锚点
- 失败时能只回滚到这里
ORM 层
- 尽量把待写状态先落到当前事务
- 避免 Python cache 和数据库状态脱钩
所以 savepoint 的重点不是“局部回滚很酷”,而是:
局部回滚以后,系统还能不能继续可信地跑下去。
实战建议:什么时候该用,什么时候别乱用
适合用
- 批量导入 / 清洗,一条失败不想拖垮整批
- 调用外部约束较多的流程,希望局部容错
- 长事务里对单个对象做独立尝试
不适合乱用
- 想拿它代替完整的错误建模
- savepoint 里大量原生 SQL,但不处理缓存失效
- 不理解 flush / modified / invalidate 还盲目设
flush=False
一个简单排错顺序
如果你发现 savepoint 之后结果怪异,按这个顺序查:
- 失败前后有没有混用 ORM 和原生 SQL
- savepoint 是否默认 flush,是否被你改成
flush=False - 失败后相关 recordset cache 是否已失效
- 依赖字段是否触发了
modified()/ recompute - 你看到的问题是数据库没写进去,还是缓存没更新
后两者非常容易被混为一谈。
结论
在 Odoo 里,savepoint 从来不是“局部 try/except”那么简单。
它真正关心的是:
局部失败之后,数据库状态、待写队列和 ORM 缓存还能不能继续保持一致。
明白这一点,你写批处理时才知道什么时候该用它,什么时候该配合 flush、invalidate 和 recompute 一起用。
DISCUSSION
评论区