用生成列提升 JSONB 查询效率:PostgreSQL 三种索引方案实测对比

引言

在 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

相关推荐
STDD3 小时前
Abiotic Factor多人生存建筑游戏《非生物因素》 专用服务器搭建教程
服务器·数据库·游戏
淼淼爱喝水3 小时前
【Ansible 入门实战】三种变量详解
java·linux·数据库·ansible·playbook
云草桑3 小时前
Odoo企业商用到底是不是免费的?
数据库·odoo·erp
燕-孑3 小时前
redis详解-进阶
数据库·redis·缓存
BGD104501733 小时前
datagear(7)-期末作业:综合数据分析
数据库·数据分析
Hoxy.R3 小时前
百家争鸣下的 Vastbase G100:一次国产数据库体验与思考
数据库
gf13211113 小时前
python_更新飞书多维表格的单项关联字段
数据库·python·飞书
染指11103 小时前
8.向量数据库-RAG基础2
大数据·数据库·人工智能·rag
随身数智备忘录3 小时前
从点检到全生命周期:设备管理体系能解决哪些场景痛点?一套设备管理体系的实战应用
java·网络·数据库