很多人第一次看到 sparse='data' 这种写法,会觉得它像个小技巧:
- 字段还是正常字段;
- 只是换了个地方存;
- 对业务开发没什么本质影响。
但从 /home/ubuntu/odoo-temp/addons/base_sparse_field 的实现看,Odoo 真正想解决的问题其实很现实:
有些字段“偶尔才会有值”,如果每个都建成真实列,表会越来越宽;但如果完全绕开 ORM,又会失去字段声明、视图、默认值和权限体系的好处。
sparse field 就是 Odoo 给这类“低命中率字段”做的折中方案。
一、它解决的不是“存不下”,而是“宽表越来越难维护”
源码文档直接写得很明确:
- sparse 字段“很小概率非空”;
- 多个这类字段可以被序列化进一个共同的存储位置;
- 这个公共位置就是一个
serialized字段。
翻成人话就是:
- 常用字段,照样给数据库独立列;
- 很少被填、但又想保留字段体验的配置项,塞进一个 JSON 风格的大包里。
这特别适合:
- 大量可选设置;
- 模块扩展里偶发出现的补充字段;
- 管理后台“有就显示、没有长期为空”的参数。
二、真正的关键不在 Serialized,而在 Field 被补了一层行为
fields.py 里最重要的动作不是新建类,而是直接给 fields.Field 做 monkey patch。
_get_attrs() 干了什么
只要字段声明了 sparse:
- 默认
store=False - 默认
copy=False - 自动挂上
compute = _compute_sparse - 如果不是只读,再挂
inverse = _inverse_sparse
这意味着 sparse 字段在 ORM 看起来像普通字段,但底层其实变成了:
“读的时候从公共字典里拿,写的时候再回填进去。”
所以它不是额外的数据库列,而是“借住”在另一个序列化字段里。
三、读流程:_compute_sparse() 只是把字典里的值映射回来
读的时候,Odoo 对每条记录做的事很直白:
- 先取
record[self.sparse],也就是那块公共存储; - 再用当前字段名去字典里取值;
- 如果是关系字段,再补一层
.exists(),把已失效关系清理掉。
这个设计很关键,因为它说明 sparse 字段并没有绕过字段类型系统。
即便值最终存成 JSON 风格数据,回到 record 上时仍然要重新变成:
- Boolean
- Integer
- Float
- Char
- Selection
- Many2one
也正因为这样,业务代码访问 record.boolean、record.partner 时,手感和普通字段基本一样。
四、写流程:_inverse_sparse() 不是简单覆盖,而是“有值写入、无值删除”
写入时更有意思。
_inverse_sparse() 会先把字段值转成可读写格式,再更新那份字典:
- 有值:写入
values[self.name] - 没值:直接把 key 从字典里删掉
这说明 sparse 字段设计不是“存 False”,而是:
尽量只保留真正有意义的键。
所以你把字段清空之后,底层不是把 JSON 改成 "my_field": false,而是把这个键整个移除。
这也是它为什么能“压缩”——不是压缩算法意义上的压缩,而是避免大量空值占位。
五、Serialized 字段本身只做两件事:转缓存、转记录
Serialized 类看起来很薄:
convert_to_cache():把 dict 转成 JSON 字符串convert_to_record():把字符串再json.loads()回 dict
另外它还有两个非常容易被忽略的点:
1)数据库列类型就是 text
也就是说,Odoo 并没有为这个老模块专门依赖 JSONB 之类数据库能力,而是优先用通用文本列承载。
2)prefetch = False
这说明框架作者很清楚:
- serialized 包可能很大;
- 不应该像普通字段那样默认预取;
- 否则你只是看一个字段,却顺手把整包都拉进缓存,得不偿失。
六、为什么 Odoo 不允许你后期改“存储系统”或重命名 sparse 字段
models.py 里 ir.model.fields.write() 的限制非常硬:
- 不能把 sparse 字段改挂到别的
serialization_field_id - 不能随便重命名已经存在的 sparse 字段
原因其实不复杂。
因为底层公共存储是字典,key 就是字段名本身。
一旦你:
- 改字段名;
- 或把它从
data挪到别的 serialized 字段;
那以前已经写进 JSON 的 key 就会和当前字段定义脱节。
结果不是“自动迁移”,而是老数据直接失联。
所以框架直接禁止这种高风险修改,而不是假装可以平滑演进。
七、_reflect_fields() 才是 sparse 字段能被系统“重新认识”的关键
很多人以为 sparse 字段只影响读写。
实际上,_reflect_fields() 还会把 serialization_field_id 反射回 ir.model.fields。
这一步的意义是:
- 数据库里不仅保存了“有这个字段”;
- 还保存了“这个字段归哪块 serialized 存储托管”。
后面 _instanciate_attrs() 再把这个信息还原成 attrs['sparse'] = serialization_record.name。
也就是说,Odoo 并不是只在 Python 声明层知道 sparse; 它还把这种关系反射进模型元数据里,保证重启、更新、重新装载后还能恢复。
八、测试用例其实把设计意图暴露得很彻底
test_sparse_fields.py 很值得看,因为它几乎把作者的预期行为都写明了:
- 新建记录时,
data为空; - 每写一个 sparse 字段,
data就多一个键; - 清空字段时,
data里的对应键会被移除; Many2one也能正确 round-trip;ir.model.fields上能反查出serialization_field_id.name == 'data'。
这说明 sparse 机制最核心的承诺只有两个:
- 对开发者来说,字段仍像普通字段;
- 对存储层来说,只保留真正被用到的键。
九、实战里什么时候适合用,什么时候最好别用
适合用
- 可选配置很多,但单条记录只会填极少一部分;
- 你需要字段声明、视图展示、ORM 访问体验;
- 你接受“这个字段主要是配置位,不是高频筛选列”。
不适合用
- 你要频繁做 SQL 级筛选、聚合、索引优化;
- 字段是核心业务字段,经常参与 domain / group by;
- 你以后很可能改字段名或迁移存储结构。
因为 sparse 的优先级是:
保留 ORM 体验 + 减少宽表压力,而不是提供最强的数据库查询能力。
总结
base_sparse_field 本质上不是“教你把字段塞进 JSON”。
它真正做的是:
- 用
serialized字段当公共容器; - 用
compute/inverse把 sparse 字段伪装成正常字段; - 用
ir.model.fields反射保证这套映射关系能长期存在; - 用禁止重命名/换存储位,避免历史数据悄悄失联。
如果只记一句,可以记这句:
sparse 字段不是“少一列”这么简单,而是 Odoo 在“字段声明体验”和“数据库宽表代价”之间做的一次工程化妥协。
DISCUSSION
评论区