暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

胖头鱼的技术专栏-473 改 JSON 的一个字段不再重写整篇:我给 PostgreSQL 18 写了个扩展(20261004)

原创 胖头鱼的鱼缸 6天前
70

数据库管理473期 2026-10-04

胖头鱼的技术专栏-473 改 JSON 的一个字段不再重写整篇:我给 PostgreSQL 18 写了个扩展(20261004)

作者:胖头鱼的鱼缸(尹海文) Oracle ACE Pro: Database PostgreSQL ACE 10年+数据库行业经验 拥有OCM 11g/12c/19c、MySQL 8.0 OCP、Exadata、CDP等认证 墨天轮MVP,ITPUB认证专家 圈内拥有“总监”称号,非著名社恐(社交恐怖分子) 全网同名:胖头鱼的鱼缸 ITPUB:yhw1809 除授权转载并标明出处外,均为“非法”抄袭

914fcc7ad57defa7868c3be1ca7fb4f5.jpg

前阵子我写过一篇《胖头鱼的技术专栏-468 JSON改一个字段,凭什么要重写整篇?(20260910)》,把 MongoDB、PostgreSQL、Oracle 三家在高频 JSON 更新场景下的写放大摆在一起横评了一轮。文末我留了个"退而求其次"——卡在 PostgreSQL 上的,把高频更新的字段拆出来做 top-level column,JSONB 只存半静态属性。

那篇发完,我一直在思考:说起来轻巧,真干过的人都知道它是句正确的废话——JSON 文档里改一个字段,拆列谁不会?问题是拆完之后,应用的读写路径全得跟着改一遍,而这个改动量,恰恰是大多数团队宁可忍着 CPU 告警也不动手的原因。

所以这次我没接着写文章,而是动手把这件事做进数据库里了。本期跟随总监,看看这个刚做完的 PostgreSQL 18 扩展:PG SplitJSON(pg_splitjson)0.1.0,Apache 2.0 开源。

一、那句"退而求其次"

先把上一篇的结论摆回来,不然后面接不上。

那篇文章的核心结论是:单字段高频更新这件事,Oracle 26ai 的 JSON Relational Duality View 是当时对比的几种方案里,唯一在存储层真正消灭了整篇重写的那个——或者说,是所有具备这类"JSON 文档 ↔ 规范化关系表"映射能力的数据库里走在最前面的那一类。它的做法是不让 JSON 当物理存储单元,物理上还是规范化的关系表,JSON 只是上面的一层访问接口。

而 PostgreSQL 这边,我给的建议是结构调整:手动把 stock、price、state 这些高频字段拎出来做独立列。

这个建议没错,但它有个隐含前提——你愿意改应用。

现实里我碰到过的情况基本是这样的:

  • 文档模型是产品上线时就定下来的,几百处代码在读 doc->>'state';
  • 拆列意味着所有读写 SQL 重写,回归测试一轮,上线窗口排期三个月;
  • 更要命的是,拆出来的列和 JSONB 里剩下的内容是两份数据,双写一致性得应用层自己保证。

于是大部分团队的真实选择是:先加机器,再调 autovacuum,实在扛不住了再排期重构。放到国产化的大环境里,这条路就更难走了——换库这件事本身不是技术问题,只改一个字段这种诉求,通常排不进替换项目的需求清单。

我就想——"把热字段拆出去"这件事,凭什么每次都要人肉做一遍?能不能让数据库自己干,对外还是一张 JSON 表?

这就是 PG SplitJSON 的由来。

二、拆列这笔账

假设有这么张表:

CREATE TABLE orders ( id bigint PRIMARY KEY, doc jsonb NOT NULL -- 256KB 的订单详情,其中 state 每天改几十次 );

手工拆完之后是这样:

CREATE TABLE orders ( id bigint PRIMARY KEY, state text, -- 拆出来了 doc jsonb NOT NULL -- 剩下的冷内容 );

SQL 看着就两行,但实际要付的账有四项:

  1. 读路径断了——原来 SELECT doc->>'state' 的地方,现在要改成 SELECT state;ORM 里定义的嵌套结构也得跟着改;
  2. 写路径要双写——改 state 得同时更新列和 doc 里的旧值,否则两份数据不一致,这时候你已经不是在优化,是在制造 bug;
  3. JSON 语义丢了——doc->'state' IS NULL 到底是"字段不存在"还是"字段值是 JSON null"?拆成普通列之后这个区别没了,而不少业务是依赖这个区别的;
  4. 索引语义变了——原来建的是 jsonb_path_ops 的 GIN 或者表达式索引,拆完要换 B-tree,执行计划全变。

说白了,手工拆列解决的是存储问题,制造的是接口问题。

PG SplitJSON 想做的事情就一条:把拆列这件事收进数据库内部,对外仍然是一张带 id 和 doc 的 JSON 表——应用该怎么读写还怎么读写,物理上热字段已经躺在独立列里了。

三、热字段进独立列,冷模板留个坑

扩展的核心机制一句话能说完:你声明哪些路径是热的,它把这些路径的值拆到普通列,剩下的内容封成一个冷模板,读的时候再由视图拼回完整 JSON。

底下是这么摆的:

胖头鱼的技术专栏XXX配图1内部存储布局.png

  • 冷模板不是"删掉热字段后的 JSON",而是一个带版本号和路径列表的封装格式(splitjson.cold)。热字段原来的位置留的是 null 占位符,还原时按声明路径填回去;
  • 占位符只在声明过的热路径上有意义——它不是一个特殊 JSON 对象,所以不会和用户数据撞车;
  • 文档里没有声明的字段,不会凭空造出来。写入 {"payload":"no hot fields"} 这样的文档,扩展不会给你塞一个 state: null 进去,原 JSONB 负载原样保留;
  • 热路径声明用键数组,比如 [["stats","count"],["state"]]——这是为了避免 key 里带点号时的歧义(声明 stats.count 到底是一个 key 还是两层?)。

热路径的下标规则也在这里定死:字符串表示对象键,非负整数表示固定数组下标。所以 ["items",0,"price"] 和 ["items","0","price"] 是两条不同的路径——前者是数组第一个元素的 price,后者是对象里 key 为 "0" 的那层。这个区别在 PostgreSQL 里本来就存在,扩展只是照着原生语义实现了。

胖头鱼的技术专栏XXX配图2冷热分离存储结构.png

四、安装 PG SplitJSON

编译安装走 PGXS,需要 PG18 的 server headers:

make PG_CONFIG=/path/to/pg18/bin/pg_config make PG_CONFIG=/path/to/pg18/bin/pg_config install

然后在测试库里建表——注意这里建出来的是视图,不是表:

CREATE EXTENSION pg_splitjson; SELECT splitjson.create_table('public.events', '[["stats","count"],["state"]]'::jsonb); INSERT INTO public.events VALUES (1, '{"stats":{"count":1},"state":"new","payload":{"large":"cold"}}'), (2, '{"payload":"no hot fields"}');

更新走专用 API:

-- 已存在的热字段:只改那一列 SELECT splitjson.set_field('public.events', 1, ARRAY['stats','count'], '2'::jsonb); -- 批量:全热字段时只产生一次物理 UPDATE SELECT splitjson.set_fields('public.events', 1, '[{"path":["stats","count"],"value":3},{"path":["state"],"value":"ready"}]'); -- 计数器用这个:读和算都在行锁内完成,不会丢增量 SELECT splitjson.increment_field('public.events', 1, ARRAY['stats','count'], 0.5); SELECT * FROM public.events; -- 只有 id 和完整 doc,内部列看不见

这里有个细节:increment_field 不会帮你初始化缺失的计数器。字段不存在、值是 JSON null、目标不是数字,一律报 22023。

乍看有点不近人情,但这恰恰是对的——计数器丢了和计数器凭空出现,前者至少你能发现,后者可能三个月后对账才发现。

普通的 SQL DML 也照样支持,只是走不了快速路径:

UPDATE public.events SET doc = jsonb_set(doc, '{state}', '"done"') WHERE id = 1;

这条会重新拆分整个文档。所以想要性能,得用专用 API——天下没有免费的午餐,这个我在适用边界里还会再说一遍。

五、简单测试

这次最硬的一组数字在这儿。

测试用一份约 256 KiB 的文档(8192 个 MD5 字符串拼出来的冷负载,pg_column_size(doc) 实测 262,186 字节),只改里面一个小小的计数器,连续更新 300 次:

更新场景 实现 WAL 字节数 累计耗时
对象计数器 原生 JSONB 86,795,752 671.824 ms
对象计数器 PG SplitJSON 热字段 64,144 82.799 ms
数组计数器 原生 JSONB 86,788,432 505.206 ms
数组计数器 PG SplitJSON 固定数组热字段 64,120 67.564 ms

换算一下:

场景 WAL 减少比例 原生/扩展 WAL 比值 原生/扩展耗时比值
对象字段 99.9261% 1,353.14 8.11
固定数组字段 99.9261% 1,353.53 7.48

也就是说:300 次更新,原生 JSONB 写了 82.8 MB 的 WAL,扩展写了 62 KB。

这个 1,353 倍的差距,本质上是"每次都把 256KB 重写一遍"和"每次只写一个计数器"的差距——文档越大、热字段越小,差距越夸张,因为它俩是同一个比值里的分子和分母。

下面是测试的条件:

  • PostgreSQL 18.6,Linux x86_64,GCC 8.5.0,全新独立实验实例;
  • wal_compression=off、full_page_writes=on、块大小 8 KiB、表 fillfactor=70;
  • 原生 JSONB 列用 STORAGE EXTERNAL(不压缩,这是公平对比的前提);
  • 每组先 CHECKPOINT,再单会话单事务循环跑完 300 次;
  • 热计数器上没有额外 B-tree 索引,表只有 id 主键;
  • WAL 按实例级 LSN 差测量,可能含后台 WAL;时间含少量测量语句开销。

1,353 倍比的是 WAL 的量,8.11 倍比的是这 300 次加起来的耗时,跟生产吞吐都不是一回事——它不等价于"快了 8 倍"。单条提交的开销、跑久了会不会膨胀、生产持续负载长什么样,仍然需要去探索。

胖头鱼的技术专栏XXX配图3写入实测WAL与耗时.png

六、冷文档到底动没动

光看 WAL 和时间还不够——你怎么知道扩展真的没重写冷文档,而不是偷偷重写了只是没记进 WAL?

所以我加了一组物理存储检查,用 pageinspect 直接翻 heap 里那个 18 字节的 external TOAST 指针:

  • 连续热更新前后,冷文档的 TOAST 指针、chunk ID、分块数量、总字节数、内容摘要全部保持不变;
  • 带索引的固定数组热更新、热数组内部批量更新、热字段删除,也都验证了冷 TOAST 复用;
  • 反过来,当数组结构变化导致固定位置移位时,冷模板确实重新存储了,热值和索引同步更新。

这组检查的意义在于:前面的性能数字是"结果",这里的 TOAST 指针不变是"证据"。有了它,才能说清楚收益是从哪来的,而不是玄学。

顺带把正确性和并发的验证结果也列一下:

验证项 实测结果
不同形状文档回归 256 个文档通过
原生数组语义对照 338 组 set/delete,结果与 SQLSTATE 和原生 JSONB 一致
对象原子增量 3 个会话共 300 次增量,无计数丢失
固定数组字段增量 3 个会话共 150 次增量,无计数丢失
热数组子树增量 3 个会话共 150 次增量,无计数丢失
全热批量更新 审计触发器确认一次物理 UPDATE
备份恢复 整库 pg_dump/pg_restore 后,数组、索引、权限、更新检查通过

七、一点额外的好处

拆出来之后还有个额外好处:热字段变成了普通列,就能建普通 B-tree 索引,查询也能走普通索引扫描。

但这里有个坑——应用写的是 doc->>'state' = 's_101',它不知道 state 已经跑到独立列里了。这种情况下,PostgreSQL 默认会老老实实把 10,000 行文档全部还原出来再过滤。

所以 0.1.0 做了一个自动查询改写:在规划前加载模块,支持的常量路径提取表达式会被改写到热列上。

LOAD 'pg_splitjson'; -- 或管理员配 session_preload_libraries SELECT splitjson.create_path_index('public.orders','orders_state_text_idx', ARRAY['state'],'text'); EXPLAIN SELECT id FROM public.orders WHERE doc->>'state'='new';

10,000 行视图上的对照结果:

自动改写 实际执行方式 执行时间 执行阶段 shared buffers
关闭 顺序扫描、还原文档、过滤 9,999 行 64.511 ms 26,631 hit + 3,554 read
开启 Bitmap Index Scan + Bitmap Heap Scan 0.586 ms 3 read

开启之后,过滤条件直接打在热列上,访问两个索引块加一个 heap 块就完事了。

但这组的措辞要更保守:

  • 这次的冷字符串只有约 2 KiB,不是写入测试那套 256 KiB;
  • 开关各测一次,缓存条件不同,属于计划和存储访问机制的展示;
  • 那 110 倍的耗时比值不能表述成"生产查询普遍快 110 倍"。原生 JSONB 自己也能建表达式索引,这里比的是"改不改写到热列",不是"能不能用索引"。

一句话总结这节:自动改写的价值是让查询命中热列索引、避免逐行重组完整文档,不是凭空变出一个新索引能力。

胖头鱼的技术专栏XXX配图4查询自动改写.png

八、三种数组用法和一把行锁

数组是这次做得比较细的一块。三种用法,代价完全不同:

  1. 整个数组当热值——["items"] 声明为热路径,读写都快,但改数组内部一个元素仍然要重写整个热数组;
  2. 固定位置下标——["items",0,"counter"] 声明为热路径,只有这一个槽独立存储,改它就只写它;
  3. 热子树——已经拆出来的热数组内部再细改,扩展只更新对应热列。

第 2 种有个必须知道的副作用:固定数组槽代表的是位置,不是身份。你在 items 数组头部插一个元素,原来第 0 位的 counter 就变成第 1 位了——这时候位置移位会触发重新拆分,冷模板重新存储,热值和索引同步更新。

批量更新这块,set_fields 的行为:

  • 1~64 个操作,全落在已存在的热路径上时,合并成一次物理 UPDATE;
  • 混杂冷热、要建新字段、或者引起结构变化时,还原一次、按顺序执行、一次写回;
  • 任意一步失败整批回滚。

并发方面:

  • 专用 API 更新时持有行锁到事务结束,同一行不同热字段的更新仍然串行——这一点没变,别指望它解决热点行争用;
  • 计数器必须用 increment_field。在应用里读出来再加一然后 set_field 写回去,照样丢增量;
  • 普通视图 UPDATE/DELETE 会在行锁内检查包括业务列在内的完整旧行,发现行变了报 40001,应用按自己的事务策略重试就行。

九、一些边界

在 0.1.0 版本,还存在一些边界:

第一,它没有绕开 MVCC。 热字段更新照样生成新的 heap tuple,照样有行版本、行锁、WAL 和 vacuum。它省掉的只是"那个 256KB 的冷值被反复重写"这一部分——省得很多,但不是零。

第二,读完整文档要重组。 每次 SELECT * 都要把冷模板和所有热列拼起来。所以这套东西适合"冷内容大、热字段小、更新频繁、但整篇读取不那么频繁"的负载。反过来,如果你每次都要读整篇文档,那是在给每个查询加 CPU。

第三,热路径不能动态改。 建表时声明,之后改不了。要换声明,得 migrate_table 重建。这条在 0.1.0 是硬限制。

第四,它不是完整的 ORM 透明层。 视图的 ON CONFLICT、RLS、分区、自动逻辑复制重组,这些 0.1.0 都没提供。另外 pg_dump -t 只导业务视图是不完整的,备份要整库 pg_dump -Fc(这条已经实测通过了)。

还有几项没测:单条提交的成本、长期膨胀、大尺寸热值、生产持续负载。上面这些数字适合说明"大冷文档、小热字段、高频更新"这个场景的机制和收益,别直接拿去做容量规划。

十、能用么?

给张速查表收个尾:

你的情况 建议
文档几十 KB 以上,冷内容长期不变,少数字段高频改 值得试,收益最大
文档几 KB,更新频率一般 别折腾,原生 JSONB 够了
每次查询都要读完整文档,更新反而不多 不合适,重组开销会吃掉收益
数组结构经常增删元素 谨慎,固定槽移位会触发重新拆分
热点行的并发争用才是瓶颈 不合适,行锁没变
需要 RLS / 分区 / 视图级 UPSERT 等后续版本
已经在用 Oracle 26ai(或有同类能力的数据库) 优先用数据库自带的那套方案,内核级的毕竟是内核级的

和上一篇文章的立场是一致的——这个扩展是在 PostgreSQL 上逼近"JSON 文档 ↔ 关系存储"效果的一次工程尝试,不是替代品。Duality View 那一类方案是把 JSON 从物理存储层降级为访问层,这个扩展是在 PostgreSQL 的 heap + MVCC 之上做冷热分离,思路同源,落点不同:一个改存储模型,一个改存储布局。有现成的内核级能力时,没必要自己在外面糊一层。

总结

回到开头那个别扭劲儿。

上一篇文章我给出的建议是"把高频字段拆出来"——这个建议本身没错,错在我默认了"拆列的成本由应用承担"是件理所当然的事。

PG SplitJSON 做的事情,说白了就是把这个成本从应用层挪回数据库层:你声明哪些路径是热的,剩下的活它自己干,对外还是那张 id + doc 的 JSON 表。

本期实测的数字说明的是缩小存储更新单元到底能省多少,实际效果取决于你的文档大小、热字段大小和更新模式——0.1.0 是初始实现,先在测试环境跑,别直接上生产。

项目地址:https://github.com/Haiwen-Yin/pg_splitjson ,Apache 2.0,PG18 可编译,欢迎提 issue。

众所周知,写文章是给人看的,写代码是给自己挖坑的——这次两件事一起干了。

老规矩,知道写了些啥。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

文章被以下合辑收录

评论