事务与回滚

Odoo 事务为什么不是“报错就全没了”:commit、rollback、savepoint 的真实边界

很多人知道 Odoo 有事务,却常把 commit、rollback、savepoint 混成一团。本文结合 sql_db.py 和官方常见用法,讲清“整单回滚”和“局部回滚”到底差在哪。

Odoo 开发
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

先说结论

在 Odoo 里,事务不是一句“出错就回滚”就能讲完的。

更准确地说:

  • 整个请求 / 操作通常跑在一个数据库事务里
  • rollback() 是把整个当前事务回退
  • savepoint() 是在事务内部切一个“局部可撤回的检查点”
  • commit() 才是把当前事务真正落地

所以如果要用一句话概括:

rollback 管整笔交易,savepoint 管交易里的一个可撤销小片段。


官方源码里 savepoint 是怎么定义的

/home/ubuntu/odoo-temp/odoo/sql_db.py 里,Cursor 的接口非常直接:

  • commit():提交当前事务
  • rollback():回滚当前事务
  • savepoint(flush=True):进入一个新的 savepoint 上下文

源码注释还专门强调了一点:

  • flush=True 是默认值
  • 会自动处理相关 flush / hook

这其实已经在提醒你:

savepoint 不只是 SQL 层的“打个点”,它还要和 Odoo ORM 的缓存、flush、hook 节奏协调。

这也是为什么很多内部实现会在非常明确的场景下才用 flush=False


为什么 Odoo 需要 savepoint,而不是只靠整单回滚

如果系统里只有两种状态:

  • 全成功
  • 全失败

那当然只要事务 + rollback 就够了。

但真实业务开发经常不是这样。

比如:

  • 一批数据里,某一条校验失败,不想把前面准备动作也全部抹掉
  • 某个“尝试性动作”可能失败,但失败本身是允许的
  • 你想先尝试一段可能触发约束的逻辑,失败了就局部撤销,再走备用分支

这时候如果没有 savepoint,你只能:

  • 要么把整笔事务全回滚
  • 要么提前 commit(这通常更危险)

而 savepoint 的价值,就是提供第三种选择:

在同一个大事务里,允许你对局部步骤做“失败即撤回”。


官方源码里哪些地方在用 savepoint

你在 /home/ubuntu/odoo-temp 里会看到非常多官方场景都在用它,例如:

  • odoo/modules/loading.py
  • odoo/orm/registry.py
  • addons/base_import/models/base_import.py
  • addons/website/controllers/form.py
  • addons/stock/models/stock_quant.py
  • addons/account/models/sequence_mixin.py

这几个场景本身就很有代表性:

1. 模块加载 / 注册阶段

模块安装升级不是“做一步就永远落库一步”,中间很多步骤都需要可控地失败、恢复、继续。

2. 导入场景

导入很容易遇到坏行、约束失败、格式异常。

如果没有局部回退,整批导入的容错体验会非常差。

3. 网站表单 / 外部入口

外部输入天然不稳定。

有时你需要先尝试创建或匹配对象,失败后再转备用逻辑,而不是一失败就把整次请求的所有准备动作都炸掉。


最容易搞错的一点:savepoint 不是 commit

很多人潜意识会把 savepoint 想成“先临时保存一下”。

这很危险。

因为 savepoint 不是提交

它只是说:

  • 到这里先做一个检查点
  • 后面这一小段如果失败,可以回退到这里

但只要外层事务最后整体失败,这些内容仍然可能全部不算数。

也就是说:

  • savepoint 解决的是局部可撤销
  • commit 解决的是全局正式落地

这两个层级完全不同。


再容易搞错的一点:局部失败被吞掉,不代表系统“没有回滚”

看下面这种典型结构:

with self.env.cr.savepoint():
    do_something_risky()

如果 do_something_risky() 抛错:

  • savepoint 内的数据库变更会被撤销
  • 外层事务可以继续活着

如果你又在外层把异常捕获掉,很多人就会误以为:

“报错了但系统没回滚。”

其实不是没回滚,而是:

  • 局部已经回滚
  • 外层事务没有被整体打断

所以排查这类问题时,你一定要分清:

  • 是哪一层失败
  • 是哪一层在兜底
  • 最终有没有整体 commit

为什么默认是 flush=True

这点非常关键,但平时最容易被忽略。

Odoo ORM 并不是每次字段赋值都立刻打到数据库。

很多时候它会:

  • 延后 flush
  • 维护缓存
  • 延后某些计算与钩子

如果 savepoint 和这些状态脱节,你会得到很难理解的结果:

  • SQL 以为状态 A
  • ORM 缓存以为状态 B
  • 回滚时又只回了数据库层的一部分认知

所以 savepoint(flush=True) 的默认设计,其实是在保护:

savepoint 前后的数据库状态与 ORM 认知尽量同步。

这也是为什么官方只有在非常明确知道自己在做什么时,才会传 flush=False


实战里什么时候该用 savepoint

适合用的场景

  • 尝试性逻辑,失败允许降级
  • 批量处理中,单步失败不想拖垮整批
  • 需要探测某个约束 / 唯一性 / 可行性
  • 外部输入不稳定,但请求主流程还要继续

不适合用的场景

  • 想用它替代正常的业务校验
  • 想用它掩盖经常发生的结构性错误
  • 想用“局部回滚”掩饰事务边界设计混乱
  • 明明应该在外层统一失败,却硬拆成很多 savepoint

换句话说,savepoint 是精细化控制工具,不是创可贴。


一个很常见的误区:为了保住前面结果,随手 commit

有些开发看到“后面可能失败”,第一反应不是 savepoint,而是:

  • 先 commit 前面已经做好的
  • 后面再继续做

这通常比你想的危险得多。

因为这样会把原本一笔业务事务,拆成多个已经落地的碎片:

  • 前面成功已提交
  • 后面失败却回不去了
  • 最后系统可能留下半成品状态

而 savepoint 恰恰提供了更安全的中间层:

  • 不提前正式提交
  • 只在大事务内做局部试错

这才是更符合 Odoo 事务风格的思路。


和现有文章怎么区分

这篇不是在重复讲 flush / invalidate / modified 的 ORM 缓存机制。

那篇更偏缓存与重算协作

而本文的切入点是:

  • 事务是什么层级
  • savepoint 为什么不是 commit
  • 局部回滚和整单回滚的边界
  • 官方为什么在导入、模块加载、外部表单等场景广泛使用它

也就是从事务控制语义去讲,而不是从字段缓存语义去讲。


最后一句话

在 Odoo 开发里,真正成熟的事务观不是“报错了就回滚”这么粗。

而是要分清三层:

  • savepoint:局部撤回
  • rollback:整笔撤回
  • commit:正式生效

一旦这三层想清楚,很多“为什么这里失败了却没全炸”“为什么这里看着保存了最后又没了”的问题,就都顺了。

DISCUSSION

评论区

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