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
->取出来还是jsonb,可以继续往下钻,也可以和别的jsonb比较;->>取出来是text,可以直接和字符串比、可以塞进WHERE的等值条件;#>>是->的变体,用数组指定路径,一次钻到底并转成text。
有一个反直觉的地方:->> 取出来的永远是 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 的比较里。有,就说明索引帮不上忙,此时有三条路:
- 改写成
@>形式(payload @> jsonb_build_object('user_id', 42))——最省事,但要求值是确定的; - 建表达式索引
CREATE INDEX ... ON t ((payload ->> 'user_id'))——能用,但每多一个查询维度就多一个索引; - 把那个字段提升成真正的列——结构稳定的信号。
三条路的代价依次递增,但也依次更彻底。选哪条取决于这个字段会不会继续变。
什么时候 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 等待上,量级差异只会更大。
替换到生产环境时要改的地方:
- 索引构建方式。
CREATE INDEX ... USING gin会阻塞写。生产上用CREATE INDEX CONCURRENTLY,它不阻塞写但要扫两遍表、耗时更长,失败时会留下indisvalid = false的索引,必须手工DROP后再建,不能直接重试。 - 写入放大的代价。GIN 索引的维护比 B-tree 贵:每次写入都要把文档拆键并更新多个倒排项。JSONB 表如果写入量很大,GIN 索引会成为写入瓶颈。
fastupdate参数会先把更新缓存在待处理列表里,批量合并,代价是查询可能读到未合并的数据(不会漏,只是在待处理列表里多扫一点)。 - 新版本的能力。PG 12 起的
jsonpath(jsonb_path_query、@?、@@)提供了更强的查询表达力,也能配合 GIN 索引。本课没展开,需要做复杂路径查询时值得去看jsonb_path_ops与@?的配合方式。
上线后该盯的指标:目标表的 pg_stat_user_tables.seq_scan 是否随数据量增长(说明索引没生效)、GIN 索引的 pg_relation_size 增长(倒排项膨胀)、以及 pg_stat_statements 里 ->> 出现在过滤条件中的高频查询——这类查询是「该提升为列」的候选清单。
动手
- 复现本章的两次
EXPLAIN。判断标准:你能说出Buffers: shared hit从 2470 降到 204 中,哪一部分是索引扫描、哪一部分是回表。 - 把
WHERE payload @> '{"type":"evt.1"}'改写成WHERE payload ->> 'type' = 'evt.1',再跑一次EXPLAIN。判断标准:计划里出现Seq Scan,而索引名idx_evt_payload不出现。 - 建一个表达式索引
CREATE INDEX ON t_evt_big ((payload ->> 'type')),再跑第 2 步的查询。判断标准:这次走索引了。同时记录索引体积,和 GIN 索引比一比。 - 把
idx_evt_payload从jsonb_path_ops换成默认的jsonb_ops,跑一次payload @> '{"type":"evt.1"}'和一次payload ? 'tag'。判断标准:@>仍然走索引,而?只有jsonb_ops才能走。
自测
json和jsonb的差别是「存文本」和「存结构」。为什么jsonb能做等值比较而json不能?- GIN 索引能加速
@>但加速不了->>。请从「索引里存了什么」的角度解释这个差别。 jsonb_path_ops比jsonb_ops更小更快,为什么它不是默认的唯一选择?- 你有一张事件表,
payload里有一个tenant_id键。什么信号出现时,说明它该被提升成一列? payload ->> 'amount'取出来的永远是text。如果它原本是数字12.5,写出把它和10比较的正确表达式,并说明为什么这个表达式无法走 GIN 索引。
↓ 下一步:03 章 · 把逻辑压进 SQL