事务子回滚

Odoo savepoint 为什么不是 try/except 的平替:事务子回滚、flush 与缓存一致性边界

结合 Odoo 19 的 SQL cursor 与 ORM 源码,讲清 savepoint 不只是“局部回滚”,它还牵涉 flush、脏字段、缓存失效和重算时机。懂这个边界,批处理和容错代码才不会越修越脏。

Odoo 开发 框架
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 4 阅读

先把误区打掉

很多人把 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。

它们做的是:

  1. 先处理待重算字段
  2. 再把 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() 会做两件关键事:

  1. 在需要时失效缓存
  2. 为依赖该字段的 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 之后结果怪异,按这个顺序查:

  1. 失败前后有没有混用 ORM 和原生 SQL
  2. savepoint 是否默认 flush,是否被你改成 flush=False
  3. 失败后相关 recordset cache 是否已失效
  4. 依赖字段是否触发了 modified() / recompute
  5. 你看到的问题是数据库没写进去,还是缓存没更新

后两者非常容易被混为一谈。


结论

在 Odoo 里,savepoint 从来不是“局部 try/except”那么简单。

它真正关心的是:

局部失败之后,数据库状态、待写队列和 ORM 缓存还能不能继续保持一致。

明白这一点,你写批处理时才知道什么时候该用它,什么时候该配合 flush、invalidate 和 recompute 一起用。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。