ORM 预取

Odoo ORM 为什么一个 for 循环就能把查询打爆:prefetch、fetch 与缓存协作机制

很多 Odoo 性能问题不是复杂 SQL,而是开发者把 recordset 当成一堆彼此独立的小对象。结合 models.py 里的 _prefetch_ids、search_fetch、fetch、with_prefetch、grouped 与 sorted,可以看出 ORM 真正追求的是“整批记录共享预取上下文”,而不是“每条记录随取随查”。

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

很多 Odoo 后端性能事故,表面上看像数据库慢,实际上是代码把 ORM 的好意全打散了。

典型场景是这样的:

  • search() 出一批记录;
  • 然后在 Python 里 for rec in records
  • 循环里每次再去读几个关系字段;
  • 最后查询数一路飙升。

很多人会把问题归咎于“ORM 不够聪明”,但真正看 /home/ubuntu/odoo-temp/odoo/orm/models.py,你会发现 Odoo 其实已经为这件事准备了相当明确的优化模型:

  • recordset 持有 _prefetch_ids
  • search_fetch() 用更少的 SQL 做“先搜再预取”
  • fetch() 会按字段依赖和 prefetch 组批量灌缓存
  • with_prefetch()grouped()sorted() 尽量保留共享的预取上下文

也就是说,ORM 想让你做的是:

以 recordset 为单位协作取数,而不是把每条记录当成互不相干的小对象。

一、recordset 从底层结构上就不是“一个对象 = 一条记录”

BaseModel__slots__ 里有三样东西:

  • env
  • _ids
  • _prefetch_ids

这其实已经把 Odoo recordset 的设计思路写在脸上了:

  • _ids 表示当前这组记录到底是哪几条;
  • _prefetch_ids 表示它共享的是哪一个预取集合。

也就是说,就算你当前手里是单条记录 rec,它也不一定在孤军奋战。

很多时候它背后仍然挂着一整个批次的 _prefetch_ids,这正是后续字段访问能被批量优化的基础。

二、search() 自己就建立在 search_fetch() 之上

源码里 search() 最后直接调:

  • return self.search_fetch(domain, [], ...)

这很有意思。

它说明 search() 从设计上就不是和 search_fetch() 完全割裂的两套世界,而是共享同一层查询框架。

search_fetch() 的文档写得也很清楚:

它相当于 search + fetch 的组合,但尽量用更少 SQL 完成。

所以当你已经知道后面一定会访问一批字段时,search_fetch() 往往比:

  • search()
  • 再在循环里逐渐触发字段读取

更符合 Odoo 想要的取数模式。

三、fetch() 的目标不是“把你点名字段单独查一下”,而是“按预取组协同灌缓存”

fetch() 的注释里直接写了:

  • 它确保给定字段在内存里可用;
  • 对 non-stored 字段大多忽略,但会跟随其存储依赖;
  • 这是一个优化用方法。

更关键的是,上面还有一段逻辑:

  • 如果字段属于某个 prefetch 组,
  • 那就把同组且当前用户可读的字段一起纳入 fnames
  • 再统一 self.fetch(fnames)

也就是说,Odoo 的思路根本不是:

你访问 name,我就只查 name

而是:

既然你已经进来了,我把同组字段顺手一批带上,后面少来回折腾数据库。

这也是为什么一些看似“多取了一点字段”的行为,反而比极度吝啬的逐字段访问更快。

四、__iter__() 已经在尽力保住预取上下文,但你仍然可能把它打碎

__iter__() 里有个常被忽略的点:

  • 当 recordset 被迭代成单条记录时,Odoo 生成的新 record 仍会尽量带着原来的 prefetch_ids

也就是说,源码已经在努力让:

  • 大 recordset 拆成一个个 rec
  • 但每个 rec 依然记得自己来自哪个批次

这样循环里第一次读字段时,仍有机会把整批缓存热起来。

但如果你在循环里继续做这些事,优化就会被打折:

  • 每次都重新 browse(id)
  • 每次都切环境、切上下文,丢掉原 recordset;
  • 在循环里频繁构造新的零散 recordset;
  • 把同批操作拆成多个互不相关的方法调用。

简单说:

ORM 能替你保住一部分批量语义,但保不住“被你主动打散”的语义。

五、with_prefetch() 是显式告诉 ORM:这些记录应该共享一个预取集合

with_prefetch(prefetch_ids=None) 的返回值本质是:

  • 复用当前 env
  • 保留当前 _ids
  • 但指定新的 _prefetch_ids

这说明 _prefetch_ids 不是隐藏黑魔法,而是 ORM 明确暴露出来的性能语义:

  • 这些记录应该一起被看待

大多数业务代码不一定要手写 with_prefetch(),但你至少应该知道:

  • 预取上下文是可以保留和传递的;
  • 如果你总在循环里生成互不相关的新 recordset,本质上就是在破坏这个上下文。

六、grouped()sorted() 为什么也值得注意

grouped() 返回新 recordset 时,用的是:

  • browse(..., prefetch_ids=self._prefetch_ids)

sorted() 返回新 recordset 时,同样把原来的 _prefetch_ids 传了下去。

这说明 Odoo 在很多 recordset 变换 API 里都在坚持一件事:

变换记录顺序或分组,不应该天然丢失批量预取语义。

因此你在优化代码时,一个很实用的判断标准是:

  • 我现在是在 recordset 世界里继续变换它;
  • 还是已经掉回 Python list / 单 id / 零散 browse 的世界了?

前者通常更能吃到 ORM 缓存红利。

七、为什么一个 innocent loop 还是会把查询打爆

因为 ORM 的优化前提不是“你在用 Python for”,而是:

  • 你有没有保住共享的 recordset 语义;
  • 你访问字段时,缓存里有没有整批可用的数据;
  • 你是不是不断制造新的、互不相关的 recordset 边界。

所以真正危险的不是 for 这个语法本身,而是下面这种写法:

  1. 大批量 search() 出结果;
  2. 循环里每次重新 browse(rec.id)
  3. 再访问关系字段;
  4. 又在内层循环里做更多零散查找。

这时你相当于反复告诉 ORM:

  • 忘了刚才那一大批记录;
  • 现在只看我眼前这一条;
  • 下一次又重新来。

数据库当然会被你来回敲。

八、实战里更稳的写法思路

我更建议遵守几条简单规则:

  1. 能保留原 recordset,就别在循环里反复 browse(id)
  2. 已知后面要读哪些字段时,优先考虑 search_fetch() 或显式 fetch()
  3. 尽量在 recordset 上做 grouped() / sorted() / filtered(),少掉回纯 Python 零散对象;
  4. 少在循环里频繁切 context / env,除非你真的要改变权限或公司边界;
  5. 排查性能时,别只看 SQL 慢不慢,也看是不是你的代码把 _prefetch_ids 语义打没了。

总结

Odoo ORM 的性能关键,不只是“有没有索引”或“SQL 快不快”,还在于:

  • recordset 是否共享 _prefetch_ids
  • search_fetch() / fetch() 是否把缓存一次性热起来;
  • 你的代码有没有保住 recordset 的批量语义。

如果只记一句,就记这句:

在 Odoo 里,真正昂贵的往往不是循环本身,而是你在循环里不断把“整批记录”降级成“互不相干的单条记录”。

DISCUSSION

评论区

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