jsonb:什么时候该用,什么时候不该
Postgres 有两个 JSON 类型。名字只差一个字母,行为差得很远。
json 存原文,jsonb 存结构
Section titled “json 存原文,jsonb 存结构”SELECT '{"b":1, "a":2, "a":3}'::json::text AS as_json,
'{"b":1, "a":2, "a":3}'::jsonb::text AS as_jsonb;json把你给的字符串一字不差地存下来:双空格保留、键顺序保留、重复的"a"两个都保留。它只在写入时校验一次语法,之后每次读取都要重新解析。jsonb解析成二进制结构再存:空格被规范化、键被重排序、重复键只保留最后一个。读取时无需解析,可以建索引。
99% 的场景应该用 jsonb。用 json 只有一个理由:你需要原样回显用户提交的 JSON,包括它的格式和键顺序。
SELECT pg_column_size('{"a":1,"b":[1,2,3],"c":{"d":"hello world"}}'::json) AS json_字节,
pg_column_size('{"a":1,"b":[1,2,3],"c":{"d":"hello world"}}'::jsonb) AS jsonb_字节;jsonb 为每个键和数组元素维护一个 JEntry 头(记录类型和偏移量),换来的是不解析就能定位任意子元素。空间换时间,而且换得值——但别指望它省空间。
操作符:箭头的数量决定返回类型
Section titled “操作符:箭头的数量决定返回类型”SELECT tags -> 'category' AS "-> 返回 jsonb",
tags ->> 'category' AS "->> 返回 text",
tags -> 'labels' -> 0 AS "数组按下标",
tags #>> '{labels,1}' AS "#>> 按路径取 text",
tags ? 'weight_g' AS "? 键是否存在",
tags @> '{"category":"tool"}'::jsonb AS "@> 是否包含"
FROM products WHERE sku = 'SKU-0001';记住一条就够了:多一个 > 就是转成 text。
-> 返回 jsonb,可以继续往下取;->> 返回 text,是链条的终点。新手最常见的错误是用 -> 取到值之后直接和字符串比较——'"tool"'::jsonb <> 'tool'::text,永远不相等。
@>(包含)是最重要的操作符,因为只有它能高效走 GIN 索引。
把 jsonb 拆成行:
SELECT sku, k AS 键, v AS 值 FROM products, jsonb_each(tags) AS e(k, v) WHERE sku = 'SKU-0001';
SELECT sku, jsonb_array_elements_text(tags -> 'labels') AS label FROM products WHERE sku = 'SKU-0002';
反过来,把行聚合成 jsonb:
SELECT jsonb_agg(jsonb_build_object('sku', sku, 'price', price)) AS items
FROM (SELECT sku, price FROM products ORDER BY sku LIMIT 3) t;这个组合在「一次查询返回嵌套结构给前端」时非常有用——把主表和明细在数据库里拼好,省掉应用层的 N+1 查询。
SQL/JSON path
Section titled “SQL/JSON path”PG 12 引入了 SQL 标准的 JSON path 语法,做复杂筛选比操作符串联清楚:
SELECT sku,
jsonb_path_query_array(tags, '$.labels[*]') AS 全部标签,
jsonb_path_exists(tags, '$.weight_g ? (@ > 1000)') AS 重量超1000,
jsonb_path_query_first(tags, '$.nested.missing') AS 不存在的路径
FROM products WHERE sku IN ('SKU-0001', 'SKU-0002') ORDER BY sku;? 在 path 里是过滤器,@ 指代当前元素——和操作符 ?(键存在)是两回事,别混。
索引:两种 GIN 的取舍
Section titled “索引:两种 GIN 的取舍”给 jsonb 建 GIN 索引有两种操作符类:
DROP TABLE IF EXISTS docs;
CREATE TABLE docs (id int PRIMARY KEY, d jsonb);
INSERT INTO docs
SELECT i, jsonb_build_object(
'cat', 'c' || (i % 50),
'n', i % 997,
'tags', to_jsonb(ARRAY['t' || (i%31), 't' || (i%17), 't' || (i%7)]),
'nested', jsonb_build_object('a', i % 13, 'b', 'v' || (i % 23)))
FROM generate_series(1, 20000) i;
CREATE INDEX d_ops ON docs USING gin (d);
CREATE INDEX d_path ON docs USING gin (d jsonb_path_ops);
ANALYZE docs;
SELECT pg_size_pretty(pg_relation_size('docs')) AS 表,
pg_size_pretty(pg_relation_size('d_ops')) AS "默认 jsonb_ops",
pg_size_pretty(pg_relation_size('d_path')) AS "jsonb_path_ops";| 操作符类 | 索引内容 | 支持 | 体积 |
|---|---|---|---|
jsonb_ops(默认) |
每个键和每个值各存一项 | @> ? `? |
?&` 以及 path 查询 |
jsonb_path_ops |
只存**「路径 + 值」的哈希** | 只支持 @> 和 path |
小约 25% |
实测 20000 行:jsonb_ops 648 kB,jsonb_path_ops 472 kB。行数越多、键名越长,差距越明显。
选择规则:如果你的查询只用 @>(绝大多数情况),用 jsonb_path_ops——更小、更快。需要 ?(判断键是否存在)就只能用默认的。
SELECT count(*) FROM docs WHERE d @> '{"cat":"c7"}'什么时候不该用 jsonb
Section titled “什么时候不该用 jsonb”这是本节最重要的部分。jsonb 的诱惑在于「不用改表结构」,代价往往在半年后才显现。
不该用 jsonb 的信号:
- 这个字段会被频繁查询或 JOIN。 jsonb 里的值没有独立的统计信息,优化器对
d ->> 'status' = 'x'的选择率估算基本靠猜(默认 0.5%),容易选错计划。 - 这个字段有明确且稳定的 schema。 已知的字段就该是列——列有类型检查、有 NOT NULL、有默认值、有统计信息、有外键。
- 需要保证内部一致性。 jsonb 里的东西没法建外键,
CHECK也写得很别扭。 - 会被频繁局部更新。 Postgres 没有「原地改 jsonb 的一个键」——
jsonb_set是读出整个值、改完、整行重写。字段越大越贵,而且每次都产生一个新的行版本(阶段三会讲这意味着什么)。
适合用 jsonb 的信号:
- 真正稀疏且不可预知的属性(不同商品类目的规格参数);
- 原样存下来的外部载荷(webhook body、第三方 API 响应、审计快照);
- 结构频繁演进且不值得为每次演进做迁移的配置。
先自己回答,再点开对照。
json 和 jsonb 在存储上的三个具体差异是什么?
json 存原文,jsonb 存解析后的二进制结构。三个可以直接观察到的差异:
- 空格:
json一字不差地保留,jsonb规范化掉; - 键顺序:
json保留你写入的顺序,jsonb重排序; - 重复键:
json两个都留着,jsonb只保留最后一个。
由此派生出两条性能差异:json 只在写入时校验一次语法,之后每次读取都要重新解析;jsonb 读取时无需解析,而且能建索引。
常见错误:以为 json 只是 jsonb 的「未优化版本」,所以把线上的 json 列直接 ALTER 成 jsonb 是纯粹的性能改进。这个转换会丢信息——重复键被丢掉一个、键顺序被改写、空格被抹平。只要有任何下游依赖原文(回显用户提交的内容、对报文重新计算签名),转完之后就对不上了。json 存在的唯一理由就是这个,而它恰恰是最容易被当成「历史包袱」清理掉的。
为什么 jsonb 常常比 json 占更多空间?换来了什么?
因为 jsonb 要为每一个键和每一个数组元素额外维护一个 JEntry 头,记录它的类型和偏移量。原文里没有这些字节。
换来的是不解析就能定位任意子元素——json 每次读取都得从头扫一遍文本,jsonb 顺着偏移量直接跳。同一批字节还让 GIN 索引成为可能。空间换时间,而且换得值。
常见错误:把「二进制格式比文本紧凑」这条在别处成立的经验搬过来。protobuf、MessagePack 确实更小,但它们省的是键名——靠 schema 或短标签把字段名从载荷里拿掉。jsonb 一个键名都不省,还在每个键上加了头。「二进制」和「紧凑」在这里没有因果关系。
-> 和 ->> 的区别?为什么用 -> 取值再和字符串比较总是不相等?
多一个 > 就是转成 text。 -> 返回 jsonb,可以继续往下取;->> 返回 text,是链条的终点。
不相等是因为 jsonb 里的字符串标量自带双引号:tags -> 'category' 得到的是 jsonb 值 "tool"(含引号的六个字符),而你比较的目标是 text 值 tool(四个字符)。'"tool"'::jsonb 和 'tool'::text 永远不相等。
常见错误:知道类型不对,于是加一个显式 ::text 转换——(tags -> 'category')::text。这看起来比 ->> 更严谨,结果却是带引号的 "tool",WHERE 条件静默返回零行。更阴险的是它只在字符串值上出错:如果你拿数字字段验证,(tags -> 'weight_g')::text 和 tags ->> 'weight_g' 结果完全一样,测试根本发现不了。取值就用 ->>,不要用 -> 加转换去凑。
jsonb_ops 和 jsonb_path_ops 该怎么选?
看你用不用 ?:
- 查询只用
@>和 path(绝大多数情况)→ 用jsonb_path_ops。它只存「路径 + 值」的哈希,实测 20000 行时 472 kB,比默认的 648 kB 小约 25%,行数越多、键名越长差距越大。 - 需要判断键是否存在(
??|?&)→ 只能用默认的jsonb_ops,因为它把每个键和每个值各存了一项。
常见错误:把这道题当成只有两个选项的选择题,建完 GIN 就以为查询会走索引,然后写 WHERE d ->> 'cat' = 'c7'。GIN 索引的两种操作符类都不支持 ->> 的等值比较——只有 @>、? 系列和 path 查询能走进去。而且如果你查的本来就是一个固定的键,正确答案是第三个选项:给它建 B-tree 表达式索引 ((d ->> 'cat')),比 GIN 小得多、写入快得多,还支持范围和排序。GIN 是为「不知道会查哪个键」准备的。
(详见索引类型全览)
列举三个「不该用 jsonb」的信号。
- 这个字段会被频繁查询或 JOIN——jsonb 里的值没有独立的统计信息,优化器对
d ->> 'status' = 'x'的选择率基本靠猜(默认 0.5%)。 - schema 明确且稳定——已知的字段就该是列,列才有类型检查、
NOT NULL、默认值、统计信息和外键。 - 需要保证内部一致性——jsonb 里建不了外键,
CHECK也写得很别扭。 - 会被频繁局部更新——
jsonb_set是读出整个值、改完、整行重写。
常见错误一:以为「选择率估不准」只是少走一次索引,建个 GIN 就补上了。索引决定的是能不能快速定位,统计信息决定的是优化器要不要走这条路、以及上层用什么 JOIN 方式。一个估成 0.5% 实际 80% 的条件,会让优化器在 JOIN 顺序上做出灾难性的选择,索引再好也救不回来。务实的解法是把热点字段用生成列抽出来,让它拥有真正的统计信息(详见约束与生成列、统计信息与选择率)。
常见错误二:把 jsonb_set 当成 MongoDB 的 $set 那样的局部更新,觉得改一个小键很便宜。Postgres 没有「原地改 jsonb 的一个键」这回事——整行重写,字段越大越贵,而且每次都产生一个新的行版本(详见行版本与可见性)。一个 20 KB 的 jsonb 字段,改一个布尔值和改整个文档的代价完全相同。