存 JSONB 很爽——不用预定义 schema,不用频繁迁移表结构,几百 KB 的嵌套文档一条记录就能装下。但到了查询环节,事情就变了:按 user_id 过滤、按时间范围筛选、按 event_type 分类,这些再普通不过的操作在 JSONB 上跑起来既慢又容易写错索引。
核心矛盾很清晰:JSONB 的灵活性恰恰是查询性能的敌人。Postgres 对 JSONB 内部结构是"盲"的——没有类型统计、没有精确的行数估算, planner 只能猜。本文对比三种让 JSONB 可查的方案,用实测数据告诉你什么场景该选什么。
三种方案,三种代价
先建一张最简表,塞入 50,000 条真实事件文档,所有基准在 PostgreSQL 18.2、热缓存、20 次取均值下完成:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
data JSONB NOT NULL
);
文档结构类似审计日志——混合原始字段、嵌套对象、随时间累积的元数据:
{
"user_id": 5234,
"event_type": "event_42",
"timestamp": 1712341200,
"action": {"type": "click", "target_id": 87654},
"device": {"type": "mobile", "os": "iOS"},
"performance": {"page_load_time": 1234},
"custom_fields": { ... }
}
我们要加速的查询很朴素:等值过滤、范围过滤、组合条件。下面逐个方案拆解。
方案一:GIN 索引——"全量倒排"的代价
GIN 是 JSONB 的"官方"索引,把文档里每个键值对都拆出来建倒排条目:
-- 默认变体:支持 @> 、? 、?| 、?& 全部操作符
CREATE INDEX idx_gin ON events USING GIN (data);
-- path-only 变体:只支持 @> ,索引更小更快
CREATE INDEX idx_gin_path ON events USING GIN (data jsonb_path_ops);
GIN 只认包含操作符,不认提取后的等值比较——这是最常见的踩坑点:
-- ✅ GIN 能用:包含查询
SELECT id FROM events WHERE data @> '{"user_id": 5234}';
-- Execution Time: 0.450 ms (jsonb_path_ops)
-- ❌ GIN 不用:提取等值,直接退化为全表扫描
SELECT id FROM events WHERE cast(data->>'user_id' AS INT) = 5234;
-- Execution Time: 47.935 ms(和无索引一样)
范围查询更是 GIN 的盲区——timestamp > 1700000000 只能先 GIN 粗筛再逐行过滤,组合条件也无法走复合索引。
方案二:表达式索引——"零迁移"的快速通道
Postgres 允许在表达式结果上建 B-tree:
CREATE INDEX idx_expr_user_id ON events (cast(data->>'user_id' AS INT));
查询谓词必须逐字匹配索引定义的表达式,否则 planner 不认:
-- ✅ 匹配索引表达式
SELECT id FROM events
WHERE cast(data->>'user_id' AS INT) = 5234;
-- Execution Time: 0.341 ms
-- ❌ 写法不同,索引失效(哪怕逻辑等价)
SELECT id FROM events
WHERE (data->>'user_id')::integer = 5234;
-- 全表扫描
这个"逐字匹配"的脆弱性在代码演进中是隐患——重构 SQL 时稍改写法,索引就悄悄失效了。
方案三:生成列——让字段"浮出水面"
PostgreSQL 12 引入的生成列,在写入时自动从 JSONB 提取值、存为独立类型列、与源数据始终同步:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
data JSONB NOT NULL,
user_id INT GENERATED ALWAYS AS ((data->>'user_id')::INT) STORED,
event_type TEXT GENERATED ALWAYS AS (data->>'event_type') STORED,
ts BIGINT GENERATED ALWAYS AS ((data->>'timestamp')::BIGINT) STORED,
action TEXT GENERATED ALWAYS AS (data->>'action'->>'type') STORED
);
-- 每个生成列都是普通类型列,直接建 B-tree
CREATE INDEX idx_user_id ON events (user_id);
CREATE INDEX idx_event_type ON events (event_type);
CREATE INDEX idx_ts ON events (ts);
CREATE INDEX idx_action ON events (action);
查询变成最朴素的列引用,planner 拿到精确统计,走 B-tree:
SELECT id FROM events WHERE user_id = 5234;
-- Execution Time: 0.407 ms
范围和组合查询天然支持——复合索引就是普通多列 B-tree:
CREATE INDEX ON events (event_type, ts);
SELECT id FROM events
WHERE event_type = 'event_42' AND ts > 1700000000;
-- Execution Time: 0.698 ms
-- 同查询用 GIN + 后过滤:6.6 ms,慢了近 10 倍
实测:读、写、存的全面对比
等值查询(user_id = 5234,热缓存 20 次均值)
| 方案 | 均值 (ms) | 最小 (ms) | 最大 (ms) |
|---|---|---|---|
| GIN jsonb_ops + @> | 0.198 | 0.101 | 1.769 |
| GIN jsonb_path_ops + @> | 0.197 | 0.032 | 3.115 |
| 表达式索引 B-tree | 0.106 | 0.018 | 1.705 |
| 生成列 B-tree | 0.112 | 0.016 | 1.839 |
均值差距不大,但看最大值:GIN 的 3.1 ms vs B-tree 的 1.8 ms。GIN 的 recheck 步骤和内部 pending list 刷新在高负载下会放大这个差距。
存储(50,000 行)
| 方案 | 表大小 | 索引大小 | 合计 |
|---|---|---|---|
| 表达式索引 (4 个) | 18 MB | 3.5 MB | 21 MB |
| 生成列 + B-tree (4 个) | 20 MB | 3.5 MB | 23 MB |
| GIN jsonb_path_ops | 18 MB | 13 MB | 31 MB |
| GIN jsonb_ops | 18 MB | 18 MB | 36 MB |
生成列多占 2 MB 表空间(4 列 × ~40 字节/行),换来的是类型化统计和复合索引能力。GIN 单索引就 13–18 MB——因为它索引了每一个键路径,文档越冗长膨胀越严重。
写入吞吐(5,000 行/轮 × 5 轮,表内已有 50,000 行)
| 方案 | 均值 (ms) | 最小 (ms) | 最大 (ms) |
|---|---|---|---|
| 生成列 + B-tree (4) | 157 | 91 | 317 |
| 表达式索引 (4) | 163 | 93 | 366 |
| GIN jsonb_path_ops | 171 | 73 | 408 |
| GIN jsonb_ops | 334 | 225 | 525 |
GIN jsonb_ops 写入耗时是 B-tree 方案的 2 倍——它要拆解整篇文档为键值对逐一插入。高方差(525 ms 最大值)源于 pending list 批量刷新。
实践:从零搭建生成列加速的 JSONB 表
下面是一个可以直接复制运行的完整示例,从建表到验证查询计划:
-- 1. 建表:JSONB 文档 + 生成列提取常用过滤字段
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
data JSONB NOT NULL,
user_id INT GENERATED ALWAYS AS ((data->>'user_id')::INT) STORED,
event_type TEXT GENERATED ALWAYS AS (data->>'event_type') STORED,
ts BIGINT GENERATED ALWAYS AS ((data->>'timestamp')::BIGINT) STORED,
action_type TEXT GENERATED ALWAYS AS (data->'action'->>'type') STORED
);
-- 2. 建索引:单列 + 复合
CREATE INDEX idx_events_user ON events (user_id);
CREATE INDEX idx_events_type_ts ON events (event_type, ts);
CREATE INDEX idx_events_action ON events (action_type);
-- 3. 插入测试数据(修改 batch_size 和字段值来适配你的场景)
INSERT INTO events (data)
SELECT jsonb_build_object(
'user_id', (random() * 10000)::int,
'event_type', 'event_' || (random() * 100)::int,
'timestamp', (1700000000 + (random() * 10000000)::bigint),
'action', jsonb_build_object('type', 'click', 'target_id', (random()*100000)::int),
'device', jsonb_build_object('type', 'mobile', 'os', 'iOS')
)
FROM generate_series(1, 50000);
-- 4. 收集统计(必须!否则 planner 对生成列的估算不准)
ANALYZE events;
-- 5. 验证:等值查询走 B-tree
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE user_id = 5234;
-- 6. 验证:复合范围查询走复合索引
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events
WHERE event_type = 'event_42' AND ts > 1700000000;
-- 7. 验证:更新 JSONB 后生成列自动同步
UPDATE events
SET data = jsonb_set(data, '{user_id}', '9999')
WHERE id = 1;
SELECT id, user_id, data->>'user_id' AS raw_uid
FROM events WHERE id = 1;
-- user_id 列应显示 9999,与 data 内部一致
运行前注意:random() 生成的 user_id 不一定包含 5234,等值查询可能返回 0 行——这没关系,重点是看 EXPLAIN 输出是否走了 Index Scan。
选型决策清单
不要凭感觉选,对照你的实际情况:
| 你的场景 | 推荐方案 |
|---|---|
| 查询字段不确定、需要任意键存在性检查 | GIN(@>、?、?|) |
| 已知少数字段,不想改表结构,快速加索引 | 表达式索引 |
| 已知字段多、查询频繁、代码持续演进 | 生成列 + B-tree |
| 已知字段 + 范围查询(时间窗口等) | 生成列 + 复合 B-tree |
| 既有已知字段又有临时 ad-hoc 查询 | 生成列 + GIN(两者并存) |
几个容易忽略的边界:
- 表达式索引的写法必须逐字一致。
cast(x as int)和x::integer对 planner 是两个不同表达式。团队里两种写法混用,索引就有一半是摆设。生成列不存在这个问题——列名就是列名。 - 生成列的表达式必须是 immutable。
NOW()、CURRENT_USER不能用。时间戳字段应该存为 JSONB 内的数值(如 Unix epoch),再提取到生成列。 - 生成列不能直接 UPDATE。它永远从源列派生。改 JSONB 后自动重算——这其实是优势,不存在数据不一致的可能。
- GIN 在写入密集表上的延迟尖峰。
gin_pending_list_limit控制内部 pending list 刷新阈值,持续高压写入时会出现偶发的数百 ms 延迟。B-tree 没有这个机制。
根本原则:让数据重新变得可推理
三种方案的性能差距,根源不在索引算法本身,而在 Postgres 能否对值做精确推理。JSONB 是黑盒——planner 不知道里面有多少种 user_id、它们的分布如何、比较代价多大。一旦提取为类型化列或表达式,planner 就拿到了统计信息,能选最便宜的路径。
生成列把这个推理能力最大化:值变成一等公民列,统计完整、复合索引自然、ORM 可映射、排序聚合不用嵌套提取。多占 2 MB 表空间,换来的是整个查询层的简化和稳定。
GIN 留给真正需要它的场景——文档结构多变、查询模式不可预测、需要"这个键存在吗"或"包含这组值吗"。对于已知的等值和范围过滤,B-tree 在类型化值上永远比 GIN 在黑盒上更快、更省、更可控。