Odoo 开发

Odoo 字段为什么会突然触发一串 SQL:CacheMiss、_fetch_query 和预取链路讲透

很多人以为访问 record.name 只是“拿个字段值”,但 Odoo 真正走的是缓存命中、CacheMiss、预取扩张、批量取数再回填缓存的一整条链。本文结合官方源码,讲清字段读取为什么会带出一串 SQL,以及这条链路该怎么调试。

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

很多开发者第一次排查 Odoo ORM 性能时,都会遇到一个很别扭的现象:

  • 明明只是访问了 record.name
  • 日志里却突然多出一串 SQL
  • 而且这串 SQL 往往不只查当前这 1 条记录

这不是 ORM “失控”了,而是 Odoo 故意这样设计的。

/home/ubuntu/odoo-temp/odoo/orm/environments.pyodoo/orm/fields.pyodoo/orm/models.py 的源码来看,字段读取不是“直接查数据库”,而是先走 cache → miss → prefetch → batch fetch → 回填 cache 的链路。你看到的一串 SQL,本质上是框架在努力把“后面大概率还会访问的数据”一次性准备好。

一、真正的起点不是 SQL,而是 cache

在 Odoo 里,字段读取首先会尝试从环境缓存中拿值。

Environment.get() 的逻辑很直接:

  • 先根据 recordfield 找 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 结果字典”,而是:

  1. 决定哪些字段属于列字段(column_fields
  2. 决定哪些字段要走字段自己的 read() 逻辑(other_fields
  3. 对列字段构造 SQL,一次性查回来
  4. field._insert_cache(...) 把值写入 cache
  5. 再补处理那些不能直接按列取的字段

这里有两个很值得开发者注意的设计点。

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

评论区

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