引言
在 PostgreSQL 的实际业务场景中,JSONB 已成为存储半结构化数据的重要方式。事件日志、审计记录、埋点数据、设备状态、动态表单等场景,都倾向于使用 JSONB 来获得更高的数据灵活性。相比传统关系型建模,JSONB 的优势非常明显,无需提前定义完整 Schema、字段可动态扩展、支持深层嵌套结构、可容纳大量可选属性、避免频繁执行 ALTER TABLE,即使单条记录达到数百 KB,PostgreSQL 依然能够稳定处理。
但问题也随之出现。当业务开始对 JSONB 数据进行高频查询时,例如根据 user_id 查询事件、按 event_type 过滤、根据时间范围筛选、查询嵌套字段中的行为属性,查询复杂度与性能压力会迅速上升。核心问题实际上是如何在不拆分 JSON 文档、不完全关系化建模的前提下,让 JSONB 查询具备更高性能?PostgreSQL 提供了多种解决方案:
- GIN 索引
- Expression Index(表达式索引)
- Generated Columns(生成列)
不同方案适用于不同访问模式,其性能、存储成本与维护复杂度也存在明显差异
测试环境与数据模型
测试表结构如下:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
data JSONB NOT NULL
);
测试使用的 JSONB 文档结构如下:
JSONB
{
"user_id": 5234,
"event_type": "event_42",
"timestamp": 1712341200,
"session_id": "sess_abc123...",
"ip_address": "192.168.1.42",
"action": {
"type": "click",
"target_id": 87654,
"coordinates": {"x": 512, "y": 768},
"duration_ms": 1234
},
"device": {
"type": "mobile",
"os": "iOS",
"screen_width": 1920,
"screen_height": 1080
},
"performance": {
"page_load_time": 1234,
"dns_lookup": 123,
"tcp_connection": 234,
"server_response": 876
},
"custom_fields": { ... }
}
核心查询场景为已知字段的等值过滤和范围过滤:查询特定用户的所有事件、按事件类型过滤、缩小时间窗口范围。基于该环境,将测试不同索引方案对特定访问模式的适配性,以及各方案的实际成本。
所有测试均在 Apple M 系列主机的 Docker 容器中运行的 PostgreSQL 18.2 上进行。表格包含 50,000 行数据,每行均为贴近实际的 JSONB 事件文档。查询基准测试在预热缓存后运行 20 次,统计平均值/最小值/最大值;插入基准测试分为 5 组,每组插入 5,000 行。文中包含完整的 schema 和脚本,可复现测试结果。
JSONB 的三种索引方案
方案一:GIN 索引
JSONB 最常见的索引方案是 GIN(Generalized Inverted Index)。
CREATE INDEX idx_gin ON events USING GIN (data);
-- or the path-only variant:
CREATE INDEX idx_gin_path ON events USING GIN (data jsonb_path_ops);
GIN 会对 JSON 文档中的所有 Key 与 Value 建立倒排索引,因此适合包含关系查询、Key 存在性查询、动态字段搜索。例如:
SELECT id FROM events WHERE data @> '{"user_id": 5234}';
该查询能够正确使用 GIN 索引。但下面这种写法无法使用 GIN:
SELECT id FROM events WHERE cast(data->>'user_id' AS INT) = 5234;
原因是 GIN 索引适用于包含性和键存在性操作符(@>、?、?|、?&),并不支持提取字段后的普通等值比较。
对于包含性查询,GIN 索引可正常生效且查询速度较快,但仍慢于同一字段上的 B-tree 索引,因为 GIN 查找需要更多的记账操作:
-- GIN jsonb_ops + containment operator
Bitmap Index Scan on idx_gin
Index Cond: (data @> '{"user_id": 5234}')
lanning Time: 1.173 ms | Execution Time: 1.295 ms
-- GIN jsonb_path_ops + containment operator
Bitmap Index Scan on idx_gin_path
Index Cond: (data @> '{"user_id": 5234}')
Planning Time: 3.342 ms | Execution Time: 0.450 ms
jsonb_path_ops变体体积更小,在包含性查询中速度更快,但不支持键存在性操作符(?、?|、?&)。两种 GIN 变体均无法支持ts > 1700000000这类范围谓词,此类查询总会触发过滤步骤。
方案二:Expression Index(表达式索引)
PostgreSQL 支持对表达式建立 B-tree 索引。
CREATE INDEX idx_user_id ON events (cast(data->>'user_id' AS INT));
当查询谓词与索引中定义的表达式完全匹配,且 ANALYZE 已收集该表达式的统计信息时,查询优化器会使用该索引:
SELECT id FROM events
WHERE cast(data->>'user_id' AS INT) = 5234;
Bitmap Heap Scan on t_expr
Recheck Cond: ((data ->> 'user_id')::integer = 5234)
Heap Blocks: exact=3
-> Bitmap Index Scan on idx_user_id
Index Cond: ((data ->> 'user_id')::integer = 5234)
Planning Time: 1.168 ms | Execution Time: 0.341 ms
该等值查询的执行时间与 GIN 索引基本相当。
方案三:Generated Columns(生成列)
Generated Columns 是 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
);
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);
针对生成列的查询为标准的类型列查找,查询优化器将其视为常规 B-tree 列,并能做出精准的估算:
SELECT id FROM events WHERE user_id = 5234;
Bitmap Heap Scan on t_gen
Recheck Cond: (user_id = 5234)
Heap Blocks: exact=3
-> Bitmap Index Scan on idx_user_id
Index Cond: (user_id = 5234)
Planning Time: 1.159 ms | Execution Time: 0.407 ms
生成列还原生支持范围查询和复合索引,无需额外复杂操作,只需像常规列一样组合即可:
-- Indexed range query on generated timestamp column
CREATE INDEX ON events (event_type, ts);
SELECT id FROM events
WHERE event_type = 'event_42' AND ts > 1700000000;
-- Execution Time: 0.698 ms (vs 6.6 ms with GIN + post-filter)
性能对比:查询效率
基于三种方案,针对 user_id 字段的等值过滤,预热缓存后运行 20 次的查询结果如下(平均值):
| 方案 | 平均值(ms) | 最小值(ms) | 最大值(ms) |
|---|---|---|---|
| GIN jsonb_ops + @> | 0.198 | 0.101 | 1.769 |
| GIN jsonb_path_ops + @> | 0.197 | 0.032 | 3.115 |
| Expression index | 0.106 | 0.018 | 1.705 |
| Generated column B-tree | 0.112 | 0.016 | 1.839 |
结果说明:
- B-tree 方案整体优于 GIN;
- Expression Index 与 Generated Columns 性能接近;
- GIN 波动更明显;
- GIN 仍需额外 recheck。
且波动更明显:预热缓存下 GIN 最大值为 3.1ms,而 B-tree 最大值为 1.8ms。
更值得注意的是,当 GIN 索引存在但查询使用提取式等值写法时的表现:
-- GIN index exists, but this query gets a seq scan:
SELECT id FROM events WHERE cast(data->>'user_id' AS INT) = 5234;
-- Execution Time: 47.935 ms (same as no index at all)
GIN 不支持此类操作符,这是 JSONB 使用过程中最容易踩的坑之一。
完整成本分析:存储与写入
存储成本
50,000 行数据在不同方案下的磁盘占用如下:
| 方案 | 表大小 | 索引大小 | 总计 |
|---|---|---|---|
| Expression indexes (4) | 18 MB | 3.5 MB | 21 MB |
| Generated columns + 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 |
结论:
- GIN 索引明显更大;
- jsonb_ops 最昂贵;
- Generated Columns 仅增加少量存储;
- B-tree 更适合已知字段查询。
原因在于,GIN 会索引所有 Key、所有路径、所有 Value,而 B-tree 仅存储目标字段。
注意事项:上述数据基于短键和紧凑值的文档。若 JSON 文档越复杂,Key 越长,层级越深,字符串越大,GIN 膨胀越严重。
写入吞吐量
5,000 条 INSERT 测试:
| 方案 | 平均值(ms) | 最小值(ms) | 最大值(ms) |
|---|---|---|---|
| Generated columns + B-tree (4) | 157 | 91 | 317 |
| Expression indexes (4) | 163 | 93 | 366 |
| GIN jsonb_path_ops | 171 | 73 | 408 |
| GIN jsonb_ops | 334 | 225 | 525 |
结果非常明显:
- jsonb_ops 写入代价最高;
- Generated Columns 与 Expression Index 接近;
- GIN 在高写入场景存在明显延迟波动。
原因在于 GIN 存在 Pending List 机制,后台 Flush 时容易出现写入抖动。
方案选择指南
- 表达式索引是成本最低的迁移方案。无需修改 schema 结构,无需调整应用插入逻辑,写入开销极小。若团队已有生产环境表格,仅需加速少量已知慢查询,合理配置的表达式索引是首选。局限在于:每个查询必须与索引定义中的表达式完全匹配,随着代码库迭代,维护难度会增加。
- 生成列的存储成本和写入开销略高于表达式索引,但具备其他方案无法提供的优势:提取的值成为一级列,可基于这些列构建复合索引、在视图中引用、通过 ORM 暴露,无需在各处嵌入提取逻辑即可进行排序或聚合操作。对于新表格或可迁移的表格,生成列是长期维护性最优的解决方案。
- GIN 索引适用于不同场景。当查询模式灵活或未知------如搜索键的存在性、临时过滤任意字段、支持任意结构文档的包含性查询时,GIN 索引极具优势,且无简洁的 B-tree 替代方案。但对于已知字段的常规等值和范围过滤,GIN 索引的存储成本更高、写入延迟更大,且仅支持一种操作符(@>,不支持=)。
简易决策指南
| 场景 | 推荐方案 |
|---|---|
| 未知或临时字段查询 | GIN(@>、键存在性操作) |
| 已知字段、少量查询、无需修改 schema | 表达式索引 |
| 已知字段、高查询量、代码库迭代频繁 | 生成列 |
| 已知字段+范围查询(如时间戳) | 生成列+复合 B-tree |
| 混合场景:部分已知字段+部分临时查询 | 生成列+GIN(两者结合) |
需要注意的问题
无论选择哪种方案,都需要关注以下几个问题:
- 核心优势在于数据的类型化和关系化。生成列并非万能,其(及表达式索引)在等值过滤中优于 GIN 的原因,是两者能生成带精确统计信息的类型化标量值,使查询优化器能做出准确的行计数估算,并选择低成本的比较操作。JSONB 具备灵活性但缺乏透明度,一旦将字段提取为类型化列或表达式,PostgreSQL 就能对其进行合理的逻辑处理。
- 表达式索引要求谓词完全匹配 。基于
cast(data->>'user_id' AS INT)的索引,无法被写法为(data->>'user_id')::int的查询使用,转换格式必须完全一致。生成列可避免这种脆弱性------任何引用列名的查询均可受益。 - 生成列的表达式必须是不可变的 。表达式不能引用依赖时间、会话状态或其他外部因素的函数,
NOW()、CURRENT_USER等函数均不可使用。 - 生成列无法直接更新。其值始终由源列派生,若更新 JSONB 数据,生成列会自动重新计算。
- GIN 在高写入场景下会产生额外维护开销 。GIN 索引会构建内部挂起列表,并定期刷新(由
gin_pending_list_limit控制)。在持续写入负载下,这种刷新可能导致延迟峰值,这在上述基准测试的最大值中已体现。B-tree 索引无此机制。
本文 Benchmark 仅基于一种数据结构与单一机器环境。在更大规模的数据场景下(例如数亿行数据),Cache Miss、Index Bloat 与 IO 开销会逐渐成为主导因素。虽然不同方案的绝对性能数据会发生变化,但整体趋势通常仍然成立。在正式迁移或架构改造前,仍应基于真实业务数据进行 Benchmark 验证。
结论
对于以可预测 JSONB 字段的等值和范围过滤为主的工作负载,结论明确:基于类型化值的 B-tree 索引------无论是通过表达式索引还是生成列------在读取延迟和写入吞吐量上均优于 GIN 索引。GIN 的优势在于灵活性,而非已知字段访问模式的速度;当明确知道需要过滤的字段时,目标式 B-tree 索引的性能始终优于 GIN。
若从零开始构建或可迁移表格,生成列是维护性最优的选择。它使频繁查询的字段易于访问,消除应用查询层中的 JSONB 提取逻辑,并原生支持复合索引和范围查询。若需在不修改 schema 的前提下为现有表格添加索引,表达式索引可以极低的写入开销实现 90%的性能提升。
GIN 索引仍有其适用场景------临时包含性搜索、键存在性检查以及查询模式随文档变化的场景。除此之外,应将 JSONB 字段转换为关系型结构,以获得更优的查询性能和维护性。
对于以等值过滤与范围查询为主、且查询字段相对固定的 JSONB 场景,测试结果已经非常明确:基于类型化值的 B-tree 索引------无论是 通过表达式索引还是生成列------在读取延迟与写入吞吐方面,都明显优于 GIN。
GIN 的核心优势在于"灵活性",而不是固定字段访问模式下的极致性能。当查询字段已经明确时,针对性的 B-tree 索引几乎始终能够优于 GIN。
若从零开始构建或可迁移表格,生成列是可维护性最好的方案。它能够让高频查询字段直接成为可访问列,消除业务 SQL 中大量 JSONB 提取逻辑,自然支持复合索引,更高效地支持范围查询。
如果是在已有业务表上进行优化、又不希望修改 Schema,那么 表达式索引往往是成本最低的方案。它能够以更低的写入开销,获得接近生成列的查询性能。
GIN 仍然是 JSONB 体系中不可或缺的工具,但应该用于真正适合它的场景:
- Ad-hoc 动态查询
- Containment 查询
- Key Existence 检测
- 查询字段无法提前确定的文档搜索
而对于其余大多数固定字段访问场景:
最终仍应尽可能让 JSONB 字段重新"关系化"。
原文链接:
https://richyen.com/postgres/2026/05/11/generated_columns_jsonb.html
作者:Richard Yen