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

第 473 期《改 JSON 的一个字段不再重写整篇》讲过 PG SplitJSON 的存储原理、安装、数组和自动查询改写,第 474 期《从能跑到能上线》讲过 0.2.0 的权限、运维和恢复。这次接着往下走:一次改多行、几个字段一起查、迁移跑到一半出错,怎么处理?
版本也接一下:474 写的是 0.2.0,之后 0.3.0 已加入 RLS、按 id 分区、条件写入和在线迁移。旧文里的部分“不支持”已经变了, 这些属于前版能力,0.4.0 新增的是多行原子更新、路由诊断和复合 B-tree,并完善迁移的资源控制与恢复。
本期跟随总监,看看这些新接口怎样用,再看新一轮 WAL、吞吐、延迟和内存结果。
以前的
set_fields 可以合并同一行的多个字段,跨行则要在事务里逐次调用。现在增加
update_rows,把多个 ID 和各自的操作一次交给扩展。
示例使用已加载扩展模块的 0.4.0 测试会话,以扩展安装者执行。已有受管视图
public.demo040 声明热路径 counter、state,普通业务列 tenant 为 text;id 1、2 的文档都已有这两个字段,counter 为数字。
SELECT * FROM splitjson.update_rows('public.demo040', '[
{"id":2,"operations":[
{"op":"increment","path":["counter"],"delta":2}
]},
{"id":1,"operations":[
{"op":"set","path":["state"],"value":"ready"},
{"op":"increment","path":["counter"]}
]}
]');
请求把 ID 2 放在前面,ID 1 放在后面。实际加锁会按 ID 升序,返回结果仍按请求顺序;同一行内部则严格按操作顺序处理。 增量在行锁内计算,最终每个变化行只产生一次物理 UPDATE。

一次请求最多 1000 个不同 ID,每行最多 64 个 set/increment 操作。请求的 JSONB 文本上限 16 MiB,单行操作文本上限 8 MiB。需要防止覆盖旧状态时,可以附上
expected_revision(沿用 0.3.0 的行版本令牌)。
可见行版本过期,或发生类型、唯一性、权限错误,整个调用回滚。
缺失行和被 RLS 隐藏的行都返回
updated=false、空 revision,避免借接口探测无权读取的数据。可见但没有变化的行也返回 false,不过保留版本令牌。它不插入缺失行;按 ID 加锁也仍可能与其他写入发生死锁。
常见热写入还改成直接传递 PostgreSQL 原生 Datum 参数,减少 JSON 文本往返。权限、RLS、写屏障和每次规划仍然保留,没有加入持久计划缓存。少了一道转换,最终快多少还得看后面的测试。
排查时,光知道调用成功不够。我还想知道:本来打算只改热列,是否因为创建缺失字段或修改结构,走了重新打包?
0.4.0 增加
explain_update,可以在执行前查看建议路由:
SELECT splitjson.explain_update('public.demo040', 1,
'[{"op":"increment","path":["counter"]}]');
结果里的
hot-only、
repack 分别表示只改热列、需要重新打包;缺失或被 RLS 隐藏的行返回
unknown。
这个接口不取行写锁、不读 cold,结果只是当前快照中的建议。并发写入后路由可能变化,某些依赖冷值的操作是否合法,也要等实际执行才能判断。
实际执行则可以打开会话计数:
SET splitjson.track_execution = on;
SELECT splitjson.increment_field('public.demo040', 1, ARRAY['counter']);
SELECT splitjson.session_stats('public.demo040');
计数默认关闭,按后端和角色隔离,记录调用、变化行、热路由、重新打包和规划次数。连接结束就清空, 事务回滚也不撤销已经记录的计数,不能拿它统计已提交业务量。
只按一个 state 过滤比较容易。业务经常还有租户条件、数值范围和排序,几个单列索引未必正好合适。
0.4.0 的复合 B-tree 可以把普通业务列与声明热路径放在一起,指定类型、collation(排序规则)、升降序、NULLS 顺序和 INCLUDE 列。
SELECT splitjson.create_compound_index( 'public.demo040', 'demo040_tenant_counter', '{"keys":[
{"column":"tenant","collation":"C"},
{"path":["counter"],"kind":"numeric",
"order":"DESC","nulls":"LAST"}
],"include":["id"]}');
EXPLAIN SELECT id FROM public.demo040
WHERE tenant COLLATE "C" = 'demo'
AND (doc->>'counter')::numeric >= 10ORDER BY (doc->>'counter')::numeric DESC NULLS LAST
LIMIT 20;

真实规划测试用了约一万行数据,检查组合过滤、排序替代、prepared plan 在索引删除重建后的失效,以及 RLS 屏障回退。小示例里只有两行,规划器选顺序扫描完全正常。
建了复合索引,仍由 PostgreSQL 按成本选计划。它可能选择 Bitmap Heap Scan 加 Sort;INCLUDE 也不保证一定出现 Index Only Scan。numeric/boolean 热路径有类型检查,不接受把任意 SQL 塞进索引规格。
索引也有写入成本。沿用 473 期的物理检查方法,这次带索引的更新仍确认了 cold 外部指针和 TOAST 分块复用;被修改热列上的索引则照常维护。
0.3.0 已有导入导出和在线迁移,0.4.0 处理的是数据量变大以后,内存、磁盘和中断恢复怎么控制。
JSONL 导出和最终比对改成逐行处理,保留精确 JSON 数字。导入分批送入数据库,但所有批次和目标创建仍属于一个事务,后面一批失败不会留下半份目标数据。
在线重放更麻烦:一个源事务可能包含很多变化,既不能无限留在客户端内存里,也不能为了省内存就把它分成多个目标提交。

目标写入和迁移进度一起提交,再确认源 WAL。超过限额、溢写空间不足时,当前源事务不能留下部分目标提交。测试包括真实溢写后的 ENOSPC(磁盘空间不足)注入,恢复条件后核对准确重放。
任务租约绑定后端,并配合独占会话锁。另一个运行器不能仅凭“租约到期”抢走仍存活的持有者,原后端要先释放或断开。复制设置也增加步骤日志、归属核验和显式恢复,清理只针对登记资源。
初始快照丢了仍需要重建。客户端也只能在收到一个原生序列化行以后拒绝超大结果, 行大小限制不等于 PostgreSQL 单个 JSON 值的内存硬上限。这些改动不能代替数据库自身的内存与临时文件配置。
这次修正了 pgbench 延迟解析,再用相同测试程序重跑正式 0.3.0 和 0.4.0。此前撤回的百分位没有重新拿来比较。
性能实验在 PG18.6/Linux x86_64 上完成:200 行、约 16 KiB payload、4 客户端、prepared 协议、随机种子 42。核心场景每次 8 秒,规模补充每次 4 秒,各重复 3 次,展示中位数;两侧有相同用途的索引,heap fillfactor=70,原生 JSONB 使用 EXTERNAL。
两篇旧文用的是约 256 KiB 文档,这轮的数据大小、索引条件和执行方式有变化,不能拿旧文中的收益比例当作版本比较。

这轮带索引计数器负载下,0.4.0 每次提交 WAL 中位数约减少 97.1%,但 TPS 低于原生 JSONB。扩展仍要处理调用者权限、映射验证、规划和锁,少写大文档并没有消除这些 CPU 与调用成本。
相比 0.3.0,精确 state 查询的 TPS 有提升;随机热更新、混合操作和 RLS 场景则有退化。这个版本包含热写入执行路径调整,不能因此概括成“所有操作都更快”。
WAL 按集群 LSN 差计算,包含全页镜像、段边界填充和后台活动;短测也没有隔离主机其他负载。 这里的 WAL 比值不代表耗时比值,更不是生产吞吐提升倍数。
多行接口的结果,还得同时看行吞吐和延迟:

大批次摊薄了每行调用开销,也让一次请求持锁更久。图中的“变化行/秒”是事务 TPS 乘批次大小,不能读成独立事务数。 批次按业务延迟预算选,不能只挑行吞吐最高的一组。
固定跑几秒,两个方案完成的工作量不同,不能直接拿 RSS 峰值就下结论。因此另做了一组固定次数实验:200 行和 2000 行,初始每文档 16,423 字节,4 客户端;每侧更新 2000 次,完整读取 20000 次,各重复 3 次。
这组只有 id 主键,没有额外查询索引,与前面的 TPS 实验不同。双方从相同 seed 插入,逐组重置、VACUUM/ANALYZE、checkpoint,并重启自己的实验实例后预热;总共完成 288000 次业务操作。

2000 行热增量中,实例 PSS 峰值中位数约减少 43.1%;非热增量和完整读取反而增加。非热更新要还原完整 JSONB 再重新打包,整篇读取也有重组成本,使用条件会直接改变结果。
热更新的后端私有页还增加了,所以也不能把实例 PSS 下降写成“所有内存都下降”。RSS 会重复计入共享页;私有页取自 smaps,也不是 PostgreSQL memory context 的分配统计。
采样约每 20 ms 一次,可能漏掉短暂峰值,没有测系统页缓存、cgroup 峰值或长期泄漏。 这些是指定数据与操作下的驻留内存对照,不是内存上限或容量承诺。
性能和内存数字来自 PG18.6。数据库升级到 18.7 后,另外做了完整兼容性验证,以及保留数据的小版本升级实验。

两条验证都通过,v0.4.0 无需修改运行代码或存储格式。旧数据实验保留业务行、版本令牌、cold 指针和 TOAST 分块,旧解码器也实际接着处理升级前后的提交。
验证继续使用
scripts/test-remote.sh 新建私有安装、PGDATA 和 socket,没有连接或修改已有实例。
检查新 18.7 安装目录时,还发现扩展的两个共享库及 control/SQL 文件未部署。
换 PostgreSQL 安装前缀后,要把扩展文件也安装到目标前缀。已有扩展若已经是 0.4.0,仅升级 PG 小版本不需要
ALTER EXTENSION。
从 0.3.0 升扩展则沿用 474 期讲过的维护窗口、替换旧会话、ALTER 和完整检查流程,目标版本改为
'0.4.0'。本次还要通过
grant_access 显式授予新增 API,既有角色不会自动拿到新权限。完整升级说明和限制见源码包附带的
RELEASE_NOTE.zh-CN.md。
0.4.0 可以把多行更新放进一个原子请求,把业务列和热路径放进同一个复合索引,也给迁移加上了溢写限额和恢复记录。路由诊断与会话计数,让“这次究竟有没有重新打包”更容易查。
我更看重这轮测试给出的取舍: 热更新的 WAL 和实例 PSS 可以降低,同时 TPS、长批次延迟、非热更新与整篇读取的内存都可能付出代价。选批次、建索引、定内存参数,都要放回自己的业务负载里验证。
老规矩,知道写了些啥。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/31466763/viewspace-3189014/ ,如需转载,请注明出处,否则将追究法律责任。