很多 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 这个语法本身,而是下面这种写法:
- 大批量
search()出结果; - 循环里每次重新
browse(rec.id); - 再访问关系字段;
- 又在内层循环里做更多零散查找。
这时你相当于反复告诉 ORM:
- 忘了刚才那一大批记录;
- 现在只看我眼前这一条;
- 下一次又重新来。
数据库当然会被你来回敲。
八、实战里更稳的写法思路
我更建议遵守几条简单规则:
- 能保留原 recordset,就别在循环里反复
browse(id); - 已知后面要读哪些字段时,优先考虑
search_fetch()或显式fetch(); - 尽量在 recordset 上做
grouped()/sorted()/filtered(),少掉回纯 Python 零散对象; - 少在循环里频繁切 context / env,除非你真的要改变权限或公司边界;
- 排查性能时,别只看 SQL 慢不慢,也看是不是你的代码把
_prefetch_ids语义打没了。
总结
Odoo ORM 的性能关键,不只是“有没有索引”或“SQL 快不快”,还在于:
- recordset 是否共享
_prefetch_ids; search_fetch()/fetch()是否把缓存一次性热起来;- 你的代码有没有保住 recordset 的批量语义。
如果只记一句,就记这句:
在 Odoo 里,真正昂贵的往往不是循环本身,而是你在循环里不断把“整批记录”降级成“互不相干的单条记录”。
DISCUSSION
评论区