PostgreSQL 的 JSONB 让它能在关系型内核之上承载文档型工作负载——这既是优势,也是陷阱。用对了,一张表能同时做结构化查询和半结构化存储;用错了,全表扫描 + 键路径函数会让查询慢到不可接受。
核心认知:JSONB 是"带索引支持的结构化 JSON",它的威力不在存储,而在于 GIN 倒排索引能让
@>包含查询走索引。把 JSONB 当纯文本 JSON 用,是最大的性能浪费。
一、JSONB 存储与二进制格式
1.1 json vs jsonb
PostgreSQL 提供两种 JSON 类型:
| 维度 | json | jsonb |
|---|---|---|
| 存储 | 原样文本 | 二进制分解格式 |
| 键排序 | 不排序 | 内部排序 |
| 重复键 | 保留 | 只保留最后一个 |
| 空格/缩进 | 保留 | 不保留 |
| 索引支持 | 不能直接建 | GIN/B-tree/表达式 |
| 写入开销 | 低 | 略高(解析+规范化) |
| 读取/查询 | 慢(每次重解析) | 快(二进制直接访问) |
生产环境永远用 jsonb,json 仅用于"必须保留原始文本"的边缘场景。
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT,
attributes JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
1.2 二进制格式与 TOAST
JSONB 将 JSON 解析为二进制树形结构,键值被规范化、去重。当行数据超过约 2KB 时,PostgreSQL 的 TOAST 机制会自动压缩并外置存储大字段:
-- 查看字段是否被 TOAST 外置
SELECT
a.attname,
CASE WHEN a.attstorage = 'e' THEN 'external(extended)'
WHEN a.attstorage = 'm' THEN 'main'
WHEN a.attstorage = 'p' THEN 'plain'
END AS storage
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'products' AND a.attname = 'attributes';
-- 'e' 表示可压缩外置(默认,也是建议)
要点:JSONB 大字段默认走 TOAST,查询时若只取部分键,PostgreSQL 需要先解压整行再取值——这是大文档性能问题的根源。
1.3 规范化行为
-- 重复键只保留最后一个
SELECT '{"a":1,"a":2}'::jsonb; -- {"a": 2}
-- 键被排序
SELECT '{"z":1,"a":2}'::jsonb; -- {"a": 2, "z": 1}
-- 数字类型被规范化(去尾零)
SELECT '{"n":1.10}'::jsonb; -- {"n": 1.1}
二、JSONB 操作符与查询
2.1 核心操作符
| 操作符 | 含义 | 示例 |
|---|---|---|
-> | 取 JSON 值(返回 jsonb) | data->'name' |
->> | 取文本值(返回 text) | data->>'name' |
#> | 按路径取 JSON 值 | data #> '{a,b}' |
#>> | 按路径取文本值 | data #>> '{a,b}' |
@> | 左侧包含右侧(JSONB 包含) | data @> '{"tags":["vip"]}' |
<@ | 右侧包含左侧 | '{"a":1}'::jsonb <@ data |
? | 是否存在顶层键 | data ? 'name' |
?| | 任一键存在 | data ?| array['a','b'] |
?& | 全部键存在 | data ?& array['a','b'] |
|| | 合并/拼接 | data || '{"k":"v"}' |
- | 删除键 | data - 'old_key' |
2.2 常见查询写法
-- 键路径访问
SELECT name, attributes->>'color' AS color
FROM products
WHERE attributes->>'color' = 'red';
-- 包含查询(可走 GIN 索引)
SELECT * FROM products
WHERE attributes @> '{"brand": "Nike", "category": "shoes"}';
-- 数组/嵌套路径
SELECT * FROM products
WHERE attributes #> '{spec, weight}' @> '{"unit":"kg"}';
-- 键存在性
SELECT * FROM products WHERE attributes ? 'warranty';
2.3 类型注意
->> 返回 text,与数值比较时需转换:
-- 错误:text 与 numeric 比较失败
SELECT * FROM products WHERE attributes->>'price' > 100;
-- 正确:显式转换
SELECT * FROM products
WHERE (attributes->>'price')::numeric > 100;
经验:对数值字段做范围查询,建议用表达式索引(见第三章),既解决类型转换又走索引。
三、JSONB 索引(GIN/B-tree/表达式)
3.1 GIN 索引(jsonb_ops)
默认 jsonb_ops 支持 @>、?、?|、?& 操作符:
CREATE INDEX idx_products_attr ON products USING GIN (attributes);
-- 命中 GIN 索引
EXPLAIN SELECT * FROM products
WHERE attributes @> '{"category": "shoes"}';
-- ✅ Bitmap Index Scan on idx_products_attr
| 索引操作符类 | 支持操作 | 体积/速度 |
|---|---|---|
jsonb_ops(默认) | @>, ?, `? | , ?&, @@` |
jsonb_path_ops | @>, @@ | 更小、更快,但功能受限 |
-- 更小更快的 jsonb_path_ops(适合纯 @> 场景)
CREATE INDEX idx_products_attr_path
ON products USING GIN (attributes jsonb_path_ops);
3.2 表达式索引(字段提取 + B-tree)
对"取某个键"的等值/范围查询,用 B-tree 表达式索引:
-- 针对 attributes->>'sku' 的等值查询
CREATE INDEX idx_products_sku ON products ((attributes->>'sku'));
-- 针对价格数值的范围查询
CREATE INDEX idx_products_price
ON products (((attributes->>'price')::numeric));
EXPLAIN SELECT * FROM products
WHERE (attributes->>'price')::numeric BETWEEN 100 AND 200;
-- ✅ Index Scan using idx_products_price
3.3 B-tree 直接建在 jsonb 上
对整个 jsonb 列建 B-tree 意义不大(需要整体排序),但可用于唯一约束:
-- 用 hash 索引/唯一约束保证某文档唯一(不常用,慎重)
CREATE UNIQUE INDEX idx_unique_attr ON products ((attributes->>'sku'));
3.4 索引选型决策表
| 查询类型 | 推荐索引 |
|---|---|
包含查询 @> / 键存在 ? | GIN(jsonb_ops 或 jsonb_path_ops) |
单个键的等值 ->> | B-tree 表达式索引 |
| 单个键的范围(数值/日期) | B-tree 表达式索引(带类型转换) |
| 全文搜索(tsvector 抽取自 JSONB) | GIN (tsvector) 表达式 |
| 键路径嵌套包含 | GIN jsonb_path_ops |
四、查询优化实践
4.1 让 @> 走索引
-- 优化前:全表扫描
SELECT * FROM products
WHERE attributes @> '{"tags":["vip"]}';
-- 优化后:确认 GIN 生效
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM products
WHERE attributes @> '{"tags":["vip"]}';
-- 期望看到 Bitmap Heap Scan + Bitmap Index Scan on GIN
4.2 避免函数包列导致索引失效
-- ❌ 对列做函数处理,B-tree 表达式索引可能不匹配
WHERE lower(attributes->>'name') = 'nike';
-- ✅ 建立匹配的表达式索引
CREATE INDEX idx_products_lname
ON products ((lower(attributes->>'name')));
WHERE lower(attributes->>'name') = 'nike';
4.3 最小化解压开销
查询只取少量键时,jsonb 大字段可能整行解压:
-- 统计解压情况:观察整行读取 vs 仅取字段
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, attributes->>'color'
FROM products WHERE id = 100;
-- 如果 attributes 很大,建议拆出高频小字段到普通列
五、与关系型建模的权衡
5.1 何时用 JSONB,何时用列
| 场景 | 关系型列 | JSONB |
|---|---|---|
| 字段固定、查询频繁 | ✅ 推荐 | — |
| 需要外键/唯一约束 | ✅ 推荐 | — |
| 字段多变、每行结构不同 | — | ✅ 推荐 |
| 需要全文搜索/多键匹配 | — | ✅ GIN |
| 高频数值范围查询 | ✅ 列 + B-tree | 表达式索引 |
| 深层嵌套文档 | 反范式 | ✅ |
5.2 混合建模(推荐)
最合理的往往不是"全 JSONB"或"全关系",而是混合:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- 关系型:外键、索引
status TEXT NOT NULL, -- 固定高频过滤字段
amount NUMERIC NOT NULL, -- 范围查询、聚合
raw_payload JSONB, -- 半结构化扩展字段
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 高频字段建索引
CREATE INDEX idx_orders_user ON orders (user_id);
CREATE INDEX idx_orders_status ON orders (status);
-- 半结构化扩展字段走 GIN
CREATE INDEX idx_orders_payload ON orders USING GIN (raw_payload);
5.3 选择决策表
| 决策问题 | 走关系型 | 走 JSONB |
|---|---|---|
| 字段会长期稳定吗? | 是 | 否(频繁演进) |
| 需要 join/聚合/事务约束吗? | 是 | 尽量少 |
| 查询按某个值过滤吗? | 是(列索引) | 是(表达式索引) |
| 字段数量多但查询稀疏? | 不推荐 | ✅ |
| 团队是否熟悉 JSON 生态? | — | ✅ |
核心原则:可预测的、高频访问的字段用列;不可预测的、低频访问的扩展字段用 JSONB。用 JSONB 逃逸 Schema 变更,也要为性能买单。
六、大 JSON 文档的坑
6.1 TOAST 与整行读取
当单个 JSONB 文档达到数百 KB 甚至 MB 级时:
- 每次查询都要解压整个 TOAST 值才能取键
- 即使只取一个键,CPU 与 IO 成本都随文档大小线性增长
EXPLAIN (ANALYZE)中体现为较高的filter行数与 CPU 时间
6.2 大文档的拆解策略
-- 反模式:整块存大文档
UPDATE documents SET payload = '[{"severe":true,"score":99}, ... 10万项]';
-- 改进 1:高频统计字段抽成列
ALTER TABLE documents ADD COLUMN doc_size INT;
-- 改进 2:把大数组拆成子表(可 join/索引)
CREATE TABLE document_items (
doc_id BIGINT,
item JSONB,
item_score NUMERIC
);
CREATE INDEX idx_doc_items_score ON document_items ((item->>'score')::numeric);
6.3 写放大与更新成本
jsonb || 局部更新比想象中昂贵——它仍会重写该字段的整份二进制值:
-- 看似"只改一个键",实际重写整个 attributes
UPDATE products
SET attributes = jsonb_set(attributes, '{stock}', to_jsonb(100))
WHERE id = 1;
| 文档大小 | 单次局部更新成本 | 建议 |
|---|---|---|
| < 1KB | 低 | 直接局部更新 |
| 1KB ~ 10KB | 中 | 拆高频字段 |
| > 10KB | 高(TOAST 解压+重写) | 必须拆分 |
结论:JSONB 适合"低频写的扩展数据",高频更新的小字段应抽成普通列。
6.4 大 JSON 反模式清单
□ 把大数组/大文本整体塞进单个 JSONB
□ 高频 UPDATE 整个大 JSONB
□ 在大 JSONB 上做全文搜索但未抽 tsvector
□ 用 JSONB 存二进制(应存 BYTEA + 元数据列)
□ 忽略 TOAST,认为 jsonb 查询永远廉价
七、JSONB 与 MySQL JSON / MongoDB 对比
7.1 三者在文档工作负载上的定位
| 维度 | PostgreSQL JSONB | MySQL JSON | MongoDB |
|---|---|---|---|
| 存储 | 二进制树 | 二进制(优化 JSON 文本) | BSON 二进制 |
| 索引 | GIN(包含)/表达式 B-tree | 虚拟列(Generated Column)索引 | 单字段/复合/文本索引 |
包含查询 @> | ✅ GIN 高效 | ❌ 走路径表达式,弱 | ✅ 原生查询语言 |
| 事务 + 文档 | ✅ 同一事务 | ✅ 同一事务 | 需事务集合(4.0+) |
| 关系 join | ✅ 强 | ✅ 强 | ❌ 弱($lookup) |
| 文档自由度 | 无 Schema | 无 Schema | 无 Schema,更原生 |
7.2 性能特征差异
| 场景 | PostgreSQL JSONB | MySQL JSON | MongoDB |
|---|---|---|---|
| 点查单键 | B-tree 表达式索引 | Generated Column 索引 | 原生索引 ✅ |
| 任意键包含查询 | GIN ✅ 强 | 弱 | 多键索引中等 |
| 写吞吐(大文档) | TOAST 开销 | 中等 | 原生 BSON 较快 |
| 聚合分析 | SQL 强 | SQL 强 | Aggregation Pipeline |
7.3 选型建议
需要"文档 + 关系 + 事务 + 强查询"统一引擎 → PostgreSQL JSONB
已有 MySQL 生态,文档查询极简 → MySQL JSON
纯文档/大规模无固定结构,弱事务 → MongoDB
结论:PostgreSQL JSONB 不是 MongoDB 的替代品,而是"在关系型引擎里顺手获得文档能力“的最优解。真正的海量非结构化文档场景,MongoDB 仍占优势。
八、调优案例
8.1 案例:商品属性查询从 3s → 20ms
问题:商品表 attributes JSONB,前端按多属性组合筛选,全表扫描。
-- 优化前:全表扫描,3 秒
EXPLAIN SELECT count(*) FROM products
WHERE attributes @> '{"brand":"Nike","category":"shoes"}';
-- Seq Scan on products (cost=0.00..29000.00 rows=... )
优化:建 GIN 索引并确认生效:
CREATE INDEX idx_products_attr_gin
ON products USING GIN (attributes jsonb_path_ops);
EXPLAIN SELECT count(*) FROM products
WHERE attributes @> '{"brand":"Nike","category":"shoes"}';
-- Bitmap Index Scan on idx_products_attr_gin
-- 结果:20ms,提速约 150 倍
8.2 案例:价格区间查询慢
问题:WHERE (attributes->>'price')::numeric BETWEEN ... 无法走普通 GIN。
-- 优化:建带类型转换的 B-tree 表达式索引
CREATE INDEX idx_products_price_num
ON products (((attributes->>'price')::numeric));
EXPLAIN SELECT * FROM products
WHERE (attributes->>'price')::numeric BETWEEN 100 AND 200;
-- ✅ Index Scan using idx_products_price_num
8.3 案例:大文档拆列
问题:文档表 payload 平均 500KB,payload->>'status' 查询命中率 60% 但很慢。
-- 优化 1:高频字段抽列
ALTER TABLE documents ADD COLUMN status TEXT;
UPDATE documents SET status = payload->>'status';
CREATE INDEX idx_docs_status ON documents (status);
-- 优化 2:低频大字段独立存储(必要时懒加载)
ALTER TABLE documents ALTER COLUMN payload SET STORAGE EXTERNAL;
结果:高频查询从每次解压 500KB 变成直接读列,QPS 提升 10 倍。
8.4 调优参数与建议
-- 提高 GIN pending list,降低高并发写放大
ALTER TABLE products SET (gin_pending_list_limit = 8192);
-- 查询时观察 buffer 命中
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
| 参数 | 作用 | 建议 |
|---|---|---|
gin_pending_list_limit | GIN pending list 上限 | 写多读少调大 |
maintenance_work_mem | 建索引内存 | 建大 GIN 前调大 |
work_mem | 排序/哈希内存 | 大聚合查询调大 |
| TOAST 存储策略 | 大字段压缩方式 | 大文档用 EXTERNAL 避免反复解压 |
常见问题(FAQ)
为什么我的 JSONB 查询没走索引?
最常见三种原因:查询用的是 ->> 等值/范围(需要 B-tree 表达式索引而非 GIN)、类型转换导致表达式不匹配、或者数据量小优化器选择全表扫描。用 EXPLAIN 确认,必要时 ANALYZE 更新统计。
jsonb_ops 和 jsonb_path_ops 选哪个?
如果你只需要 @> 包含查询,选 jsonb_path_ops(更小、更快、查询更快);如果需要 ?、?|、?& 键存在性查询,选默认 jsonb_ops。两者不能互相替代操作符覆盖范围。
JSONB 能完全替代 MongoDB 吗?
不能。JSONB 的优势是"文档 + 关系 + 事务"一体,但 MongoDB 在真正大规模非结构化文档、原生文档查询语言、横向分片上更成熟。PostgreSQL JSONB 适合需要关系和事务的混合场景。
大 JSON 文档性能差怎么办?
原则是拆分:高频访问字段抽成普通列(可建 B-tree 索引),低频大字段独立或外部存储,避免每次查询解压整份 TOAST。同时把大数组拆成可 join 的子表,让数据库用索引而不是扫文档。
jsonb 局部更新会快吗?
jsonb_set / || 看起来是局部更新,但 PostgreSQL 仍会重写整个 jsonb 值(含 TOAST 解压与压缩)。文档越大成本越高。频繁更新的高频小字段应抽成普通列;JSONB 只承载"低频写的扩展数据”。
相关阅读
- PostgreSQL 高级 SQL 查询实战 — JSONB 深度查询、数组与全文搜索
- PostgreSQL 索引类型深度实战 — GIN 倒排索引、表达式索引原理
- PostgreSQL 查询优化实战 — EXPLAIN ANALYZE 与慢查询治理
- PostgreSQL vs MySQL vs MongoDB — 12 维度选型对比
- PostgreSQL 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。