很多开发者第一次排查 Odoo ORM 性能时,都会遇到一个很别扭的现象:
- 明明只是访问了
record.name - 日志里却突然多出一串 SQL
- 而且这串 SQL 往往不只查当前这 1 条记录
这不是 ORM “失控”了,而是 Odoo 故意这样设计的。
从 /home/ubuntu/odoo-temp/odoo/orm/environments.py、odoo/orm/fields.py 和 odoo/orm/models.py 的源码来看,字段读取不是“直接查数据库”,而是先走 cache → miss → prefetch → batch fetch → 回填 cache 的链路。你看到的一串 SQL,本质上是框架在努力把“后面大概率还会访问的数据”一次性准备好。
一、真正的起点不是 SQL,而是 cache
在 Odoo 里,字段读取首先会尝试从环境缓存中拿值。
Environment.get() 的逻辑很直接:
- 先根据
record和field找 field cache - 再用当前 record id 去取值
- 如果没有,就抛
CacheMiss(record, field)
也就是说,CacheMiss 不是异常情况里的“系统出错”,而是 ORM 字段读取流程里的一个正常分支信号:
这个字段现在还没在缓存里,请继续走取数流程。
所以当你在调试器里看到 CacheMiss,不要第一反应就以为程序坏了。很多时候,它只是说明当前值还没被装进缓存。
二、为什么不是只查这一条,而是顺手查一批
关键在 Field._to_prefetch()。
在 /home/ubuntu/odoo-temp/odoo/orm/fields.py 里,这个方法会拿当前记录的 record._prefetch_ids,再通过 expand_ids(...) 扩出一组候选 id,然后筛掉缓存里已经有值的那些,只保留尚未命中的记录,最后按 PREFETCH_MAX 裁一批出来。
这意味着:
- 你访问的是
record.name - Odoo 想的却是“既然这条记录所在的 recordset 很可能接下来还会访问同字段,那不如一起拿”
所以它不是“当前 record 缺值就只补当前 record”,而是:
- 找到同一个预取上下文里的 id 集
- 尽量批量拿值
- 让后续循环读取少打 SQL
这也是为什么在一个 recordset 循环里,第一条记录看起来像触发了 SQL,后面的记录却很安静:第一条已经把同批次的值预热进 cache 了。
三、真正把数据灌进缓存的是 _fetch_query()
在 /home/ubuntu/odoo-temp/odoo/orm/models.py 里,_fetch_query() 是很关键的底层方法。
它做的事情不是“返回 read 结果字典”,而是:
- 决定哪些字段属于列字段(
column_fields) - 决定哪些字段要走字段自己的
read()逻辑(other_fields) - 对列字段构造 SQL,一次性查回来
- 用
field._insert_cache(...)把值写入 cache - 再补处理那些不能直接按列取的字段
这里有两个很值得开发者注意的设计点。
1)它关心的是“回填缓存”,不是“立刻返回给你”
这说明 Odoo ORM 的读流程本质上是缓存驱动的。
你代码里写的是:
partner.name
但框架底层在做的是:
- 先保证缓存可用
- 然后字段 getter 再从缓存取结果
换句话说,字段访问看起来像对象属性读取,底层却是一个带缓存协议的批量取数系统。
2)它不会粗暴覆盖已有缓存
源码里 _insert_cache() 用的是“只填空位、不覆盖已有值”的策略。这一点很重要,因为事务里可能已经有待 flush 的脏值,框架不能为了读数据库又把这些内存中的待写值冲掉。
所以 _fetch_query() 的角色不是“数据库真相覆盖一切”,而是:
- 在不破坏当前事务状态的前提下
- 把缺的值补到缓存里
这就是它看起来比“简单 SELECT 一把”更绕的原因。
四、为什么有时一个字段会顺手把依赖字段也取出来
在同一个文件里,ORM 在决定 fetch 哪些字段时,还会结合字段依赖、字段是否可预取、是否有访问权限等条件,把一部分依赖字段也带上。
这就是很多人看到的另一个现象:
- 我明明只想看
display_name - 为什么连别的列也一起查了
答案通常不是“多余”,而是因为:
- 这个字段本来就依赖别的字段计算或格式化
- 或者这个字段所在的预取组被一起拉取
所以调 ORM 性能时,别把“额外字段也被查了”简单理解成浪费。先分清它到底是:
- 字段依赖导致的必要预取
- 还是你代码无意中触发了太大的 recordset 预取面
五、这条链路最常见的误解
误解 1:访问一个字段就一定只会发一条针对当前 id 的 SQL
不对。Odoo 优先追求的是减少整体碎片查询,不是严格做到“一次属性访问只查当前对象”。
误解 2:看到 CacheMiss 就说明性能不好
也不对。CacheMiss 本身只是“还没进缓存”。真正要看的是:
- miss 之后是不是批量补齐了
- 后续是否复用缓存
- 还是在循环里不断制造新的预取上下文
误解 3:SQL 多,就是 ORM 不聪明
也不一定。
如果第一轮多查一点,换来后续几十次字段读取都不再 hit DB,这在整体上反而可能更省。
六、开发和调试时怎么用这套认知
如果你在排查“为什么这里突然打很多 SQL”,建议按这个顺序看:
1)先看访问的是不是第一次命中该字段
第一次读取字段,本来就可能触发 fetch。重点不是“有没有 SQL”,而是“有没有重复 SQL”。
2)看 recordset 是不是被你切碎了
如果你不断:
- 单条
browse(id) - 单条传入 helper
- 单条循环里重新生成 recordset
那 _prefetch_ids 的上下文会被你切散,ORM 就更难帮你做批量预取。
3)看是不是字段链过深
像:
records.mapped('partner_id.country_id.name')
本来就会触发多层关系读取。这里要分析的是整条链的预取效率,而不是只盯最终一个 name。
4)必要时直接去看 _fetch_query() 处理的是哪些字段
如果你想解释“为什么这批字段会一起查”,源码比猜更可靠。
七、一个更准确的心智模型
理解 Odoo 字段读取,最好别用“对象属性”这个过于轻量的心智模型,而要换成:
访问字段 = 向 ORM 申请一个值;如果缓存没有,ORM 会尽量把同批次可能需要的值一起装进来。
一旦你接受这个模型,很多现象都会变得顺理成章:
- 第一条记录比较“贵”
- 后面的记录便宜
- 某个字段访问会顺手带出同组字段
- 一次 miss 后不是只补一条,而是补一批
结语
CacheMiss、_to_prefetch()、_fetch_query() 这三层,正好构成了 Odoo 字段读取最核心的一条底层链路:
CacheMiss负责声明“缓存里还没有”_to_prefetch()负责决定“这次该顺手补谁”_fetch_query()负责真正把值批量放进缓存
所以,下次你再看到“只是访问一个字段,为什么数据库忽然动起来”,更准确的答案不是“ORM 又偷偷查库了”,而是:
ORM 正在把一次字段访问,扩展成一轮有策略的批量预取。
DISCUSSION
评论区