KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · JSONB 的边界 — keel 龙骨

## 现场:一个「省事」的决定

现场:一个「省事」的决定

事件表最初长这样:id、type、payload。payload 是 jsonb,里面装请求的原始报文。

最开始很舒服。上游字段增删不用改表,加一个字段就是上游多塞一个键,连发布都不用。半年后事件表到两千万行,运营要按「事件类型 + 用户 id」统计,查询从秒级变成分钟级。

原因不难猜:这个查询得把每行的 payload 解析一遍再比字段。加索引能救吗?能,但是——加什么样的索引,以及为什么加了之后有些查询还是救不了,就是这一章要讲的事。

先猜一下:在一个 20 万行的 jsonb 表上,payload @> '{"type":"evt.1"}' 这句查询,加 GIN 索引前后的扫描缓冲数会差多少倍?

直觉模型:文档存储的代价转移

关系模型的做法是「把结构写进 schema,数据库替你看住它」。JSONB 的做法是「结构交给应用,数据库只负责存取」。

这不是谁比谁先进,是把复杂度搬了个位置:

结构在哪 谁保证结构正确 改结构要做什么
普通列 数据库 schema 数据库(类型、约束) DDL,大表要在线变更
JSONB 列 应用代码的约定 应用自己 什么都不用做

JSONB 用「数据库不再帮你看住结构」换来了「改结构零成本」。这个交换在字段还没定型、上游频繁变动时非常划算;在结构已经稳定、查询开始变重时就变成负债。判断一个 JSONB 列是好是坏,问一句就行:这张表的结构还会变吗? 会变,JSONB 是解;不会变了,它就是你还没做的那次建模。

json 与 jsonb 的区别

PG 有两个 JSON 类型,差别不是性能高低,是存什么:

SELECT '{"b":1,"a":2,"a":3}'::json AS as_json, '{"b":1,"a":2,"a":3}'::jsonb AS as_jsonb;
       as_json       |     as_jsonb     
---------------------+------------------
 {"b":1,"a":2,"a":3} | {"a": 3, "b": 1}

json 原样保存了你的输入字符串:键的顺序没变、重复的 "a" 键也留着。jsonb 把它解析成了内部结构:键去重(后出现的值胜出)、键排序、去掉空白。

所以 jsonb 存的不是文本,是一棵可以按路径查找的树。这也解释了为什么两个 jsonb 可以比较大小:

SELECT '{"b":1,"a":2}'::jsonb = '{"a":2,"b":1}'::jsonb;
 jsonb_order_insensitive 
-------------------------
 t

键序不同的两个 jsonb 相等,因为它们被解析成了同一棵树。而同样的比较对 json 会直接报错:

ERROR:  operator does not exist: json = json
LINE 1: SELECT '{"b":1,"a":2}'::json  = '{"a":2,"b":1}'::json AS jso...
HINT:  No operator matches the given name and argument types. You might need to add explicit type casts.

这个报错本身就是结论:json 类型没有等值操作符,因为它存的是文本,而 PG 不愿意替你定义「两段 JSON 文本什么时候算相等」。如果你需要比较、需要索引、需要去重,那你要的是 jsonb。

json 剩下的唯一优势是保留原始格式(键序、重复键、空白),适合「原样存档、将来可能要用原始字节做签名校验」的场景。除此之外,默认选 jsonb。

取字段的三种写法

JSONB 的取值操作符很容易混,按「返回什么类型」记最清楚:

CREATE TABLE t_evt(id bigserial PRIMARY KEY, payload jsonb);
INSERT INTO t_evt(payload) VALUES
  ('{"type":"order.created","user":{"id":7,"name":"alice"},"amount":12.50}'),
  ('{"type":"order.paid","user":{"id":8,"name":"bob"},"amount":3.00}');

SELECT payload -> 'user' AS object_field,
       payload ->> 'type' AS text_field,
       payload #>> '{user,name}' AS nested_text
FROM t_evt;
        object_field        |  text_field   | nested_text 
----------------------------+---------------+-------------
 {"id": 7, "name": "alice"} | order.created | alice
 {"id": 8, "name": "bob"}   | order.paid    | bob

有一个反直觉的地方:->> 取出来的永远是 text,哪怕原本是数字。payload ->> 'amount' 得到的是字符串 "12.50",要比较或运算必须显式转换。这在实际查询里会造成一串括号:

WHERE (payload ->> 'amount')::numeric > 10

这段表达式每一行都要算一遍,且没法用普通索引。GIN 索引能加速的是 @ 这类针对整个文档的操作符,不是这种「取出来再转换」的表达式。要加速它,得建表达式索引,那就是另一种成本了(第 06 章会对比这一点)。

GIN 索引加速的是文档包含

GIN(Generalized Inverted Index,通用倒排索引)的思路是:把一个文档拆成若干个键,为每个键记下它出现在哪些行里。查询 payload @> '{"type":"evt.1"}' 时,先在倒排表里找到含这个键值对的行号,再去堆里取行。

看它在 20 万行上的实际效果。先造数据,不建索引:

CREATE TABLE t_evt_big(id bigserial PRIMARY KEY, payload jsonb);
INSERT INTO t_evt_big(payload)
SELECT jsonb_build_object('type', 'evt.' || (g % 1000), 'seq', g, 'tag', 'tag' || (g % 100))
FROM generate_series(1, 200000) g;
ANALYZE t_evt_big;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM t_evt_big WHERE payload @> '{"type":"evt.1"}';
                       QUERY PLAN                        
---------------------------------------------------------
 Aggregate (actual rows=1 loops=1)
   Buffers: shared hit=2470
   ->  Seq Scan on t_evt_big (actual rows=200 loops=1)
         Filter: (payload @> '{"type": "evt.1"}'::jsonb)
         Rows Removed by Filter: 199800
         Buffers: shared hit=2470

200 个匹配行,代价是读了 2470 个缓冲页——整张表。Rows Removed by Filter: 199800 是「读出来又被过滤掉」的行数,这个数字接近总行数时,说明索引缺席。

建索引。注意用 jsonb_path_ops 这个操作符类:

CREATE INDEX idx_evt_payload ON t_evt_big USING gin (payload jsonb_path_ops);
ANALYZE t_evt_big;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM t_evt_big WHERE payload @> '{"type":"evt.1"}';
                                 QUERY PLAN                                 
----------------------------------------------------------------------------
 Aggregate (actual rows=1 loops=1)
   Buffers: shared hit=204
   ->  Bitmap Heap Scan on t_evt_big (actual rows=200 loops=1)
         Recheck Cond: (payload @> '{"type": "evt.1"}'::jsonb)
         Heap Blocks: exact=200
         Buffers: shared hit=204
         ->  Bitmap Index Scan on idx_evt_payload (actual rows=200 loops=1)
               Index Cond: (payload @> '{"type": "evt.1"}'::jsonb)
               Buffers: shared hit=4

缓冲数从 2470 降到 204。其中索引扫描本身只用了 4 个缓冲,剩下的 200 个是去堆里取匹配行。计划从「整表逐行过滤」变成了「索引定位 + 位图回表」。

jsonb_path_ops 是专门为 @> 设计的一个更小的操作符类。和默认的 jsonb_ops 相比,它索引的粒度更粗(只记「路径 + 值」的组合,不记单独的键),所以索引更小、@> 更快,代价是不支持 ?、?|、?& 这些「键存在」操作符。默认选 jsonb_path_ops,需要键存在查询时再换。

jsonb_path_ops 救不了 ->> 查询

这是最常被误解的一点。同一个表、同一个索引,换一个写法:

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM t_evt_big WHERE payload ->> 'type' = 'evt.1';
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=2 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Partial Aggregate (actual rows=1 loops=2)
               ->  Parallel Seq Scan on t_evt_big (actual rows=100 loops=2)
                     Filter: ((payload ->> 'type'::text) = 'evt.1'::text)
                     Rows Removed by Filter: 99900

索引在,但完全没用上,退回了并行顺序扫描。

原因是两者的语义层次不同。@> 是「文档包含」,索引里存的就是「哪个键值对出现在哪一行」,能直接回答。->> 'type' = 'evt.1' 是「先把这个文档的 type 取出来变成文本,再和字符串比」——PG 的 GIN 索引里没有「取出后的文本」这种东西,它没法回答。

这就是为什么那个「半年后变慢」的查询救不回来:代码里写的是 payload ->> 'user_id' = '42',而不是 payload @> '{"user_id":42}'。同一个业务意图,两种写法,一种能走索引一种不能。

要判断自己的查询属于哪种,看它有没有 ->> 或 #>> 出现在 WHERE 的比较里。有,就说明索引帮不上忙,此时有三条路:

  1. 改写成 @> 形式(payload @> jsonb_build_object('user_id', 42))——最省事,但要求值是确定的;
  2. 建表达式索引 CREATE INDEX ... ON t ((payload ->> 'user_id'))——能用,但每多一个查询维度就多一个索引;
  3. 把那个字段提升成真正的列——结构稳定的信号。

三条路的代价依次递增,但也依次更彻底。选哪条取决于这个字段会不会继续变。

什么时候 JSONB 是解,什么时候是债

场景 判断 理由
上游报文原样存档,只存不查 用 JSONB 结构不稳定、写入频繁、查询率低
事件/日志的多变属性 用 JSONB 属性集合随上游演化
第三方回调的原始报文体 用 JSONB 只需保存,解析时机不确定
需要按某个键做等值或范围查询 提升为列 键需要索引,且是稳定查询维度
需要 JOIN 到别的表 提升为列 JSONB 里的值不能建外键
需要统计、聚合、分组 提升为列 需要类型、需要统计信息
需要保证值不为空/非负 提升为列 JSONB 无法承载 NOT NULL 与 CHECK

最后一行值得展开。JSONB 里没有类型、没有约束、没有统计信息。你在应用层约定「amount 必须是正数」,数据库不知道这件事。等哪天有第二个写入方(数据修复脚本、另一个服务)塞了一个负数进来,没有任何东西会拦它。第 06 章会用实测数据对比同一份数据用 JSONB 存和拆列存的差别。

一个务实的混合模式是:稳定的键提升为列,多变的键留在 JSONB。订单表的 user_id、amount、status、created_at 是列,上游报文里那些偶尔出现的扩展字段留在 payload。这样既有列的类型约束和索引,又保留了「加字段不用改表」的灵活性。

生产边界

本课实验是本地 PostgreSQL 16.15,表大小 19MB、索引 13MB,数据全部在 shared_buffers 里。所以「缓冲数 2470 → 204」反映的是访问范围的变化,而不是磁盘 IO 的变化——生产上如果表远大于内存,这个差距会体现在更直接的 IO 等待上,量级差异只会更大。

替换到生产环境时要改的地方:

  1. 索引构建方式。CREATE INDEX ... USING gin 会阻塞写。生产上用 CREATE INDEX CONCURRENTLY,它不阻塞写但要扫两遍表、耗时更长,失败时会留下 indisvalid = false 的索引,必须手工 DROP 后再建,不能直接重试。
  2. 写入放大的代价。GIN 索引的维护比 B-tree 贵:每次写入都要把文档拆键并更新多个倒排项。JSONB 表如果写入量很大,GIN 索引会成为写入瓶颈。fastupdate 参数会先把更新缓存在待处理列表里,批量合并,代价是查询可能读到未合并的数据(不会漏,只是在待处理列表里多扫一点)。
  3. 新版本的能力。PG 12 起的 jsonpath(jsonb_path_query、@?、@@)提供了更强的查询表达力,也能配合 GIN 索引。本课没展开,需要做复杂路径查询时值得去看 jsonb_path_ops 与 @? 的配合方式。

上线后该盯的指标:目标表的 pg_stat_user_tables.seq_scan 是否随数据量增长(说明索引没生效)、GIN 索引的 pg_relation_size 增长(倒排项膨胀)、以及 pg_stat_statements 里 ->> 出现在过滤条件中的高频查询——这类查询是「该提升为列」的候选清单。

动手

  1. 复现本章的两次 EXPLAIN。判断标准:你能说出 Buffers: shared hit 从 2470 降到 204 中,哪一部分是索引扫描、哪一部分是回表。
  2. 把 WHERE payload @> '{"type":"evt.1"}' 改写成 WHERE payload ->> 'type' = 'evt.1',再跑一次 EXPLAIN。判断标准:计划里出现 Seq Scan,而索引名 idx_evt_payload 不出现。
  3. 建一个表达式索引 CREATE INDEX ON t_evt_big ((payload ->> 'type')),再跑第 2 步的查询。判断标准:这次走索引了。同时记录索引体积,和 GIN 索引比一比。
  4. 把 idx_evt_payload 从 jsonb_path_ops 换成默认的 jsonb_ops,跑一次 payload @> '{"type":"evt.1"}' 和一次 payload ? 'tag'。判断标准:@> 仍然走索引,而 ? 只有 jsonb_ops 才能走。

自测

  1. json 和 jsonb 的差别是「存文本」和「存结构」。为什么 jsonb 能做等值比较而 json 不能?
  2. GIN 索引能加速 @> 但加速不了 ->>。请从「索引里存了什么」的角度解释这个差别。
  3. jsonb_path_ops 比 jsonb_ops 更小更快,为什么它不是默认的唯一选择?
  4. 你有一张事件表,payload 里有一个 tenant_id 键。什么信号出现时,说明它该被提升成一列?
  5. payload ->> 'amount' 取出来的永远是 text。如果它原本是数字 12.5,写出把它和 10 比较的正确表达式,并说明为什么这个表达式无法走 GIN 索引。

↓ 下一步:03 章 · 把逻辑压进 SQL

进入 keel 阅读