想象你是“青云仓库”的仓管员小明。仓库里堆着成千上万个奇形怪状的盒子(JSONB 文档),每个盒子里装的东西都不一样:有的塞了标签、价格和尺寸,有的只有一个名字。老板总爱问些刁钻问题:“哪些盒子装了红色按钮?”“按价格从低到高列出所有水壶?”“哪些盒子贴过'易碎'标?”一开始,你只能一个个开箱检查(全表扫描),仓库越大越慢。后来你学会了做“物品登记册”(GIN 索引)和“价格流水账”(BTREE 表达式索引),查询速度快得飞起。但册子也会越用越厚(索引膨胀),盒子里改一样东西所有册子都得同步改(更新成本)。这篇文章就带你亲手做这几本册子,把小仓库管得明明白白。
学会为 JSONB 建正确的索引,你的文档型查询能从分钟级全表扫描提升到毫秒级命中——这是后端工程师和 DBA 处理半结构化数据最值钱的硬技能。
目录
可视化演示
展示GIN倒排索引键值对到文档映射、表达式索引B-tree路径查询优化、索引膨胀与VACUUM清理过程。切换模式查看JSONB索引核心技术。
本节配有交互动效(进入视口后自动加载,约 3KB)。
GIN索引:为JSONB每个键值对构建倒排索引实现高效包含查询 GIN为文档中每个key和value创建倒排条目 @>包含操作符走GIN索引快速定位文档 CREATE INDEX USING GIN (data) GIN是JSONB最常用索引类型支持全部操作符
查询基础:读懂谜之盒子的语言
青云仓库的盒子里装的东西完全没规律:有的盒子贴满了标签,有的只有一张纸条。如果要求所有盒子都按统一货架摆放,几乎不可能。PostgreSQL 的 JSONB 数据类型就是为这种“没有规矩”的数据准备的——它把 JSON 文档压缩成二进制格式存进一个字段,就像给盒子装上了透明侧窗:写入时数据库会自动把键排序、去掉重复键、压缩空白,这样虽然写的时候多花一点点整理时间,但后续查询时内容稳定可预测,不会出现同一个 JSON 两种写法。
有了这种盒子,查询语言也要跟上。JSONB 最常用的有四个操作符:@> 表示“左文档包含右子文档”,? 问“顶层有没有这个键”,?| 问“这些键至少有一个吗”,?& 问“这些键是不是全都有”。比如老板问“哪些盒子带 red 标签”,SQL 就是 WHERE data @> '{"tags":["red"]}'——把 target 条件写成一个小 JSON 模板,让数据库去匹配整只盒子内部。
小明明白了:老板的问题花样百出,而 JSONB 提供了丰富的问题语法。但问题来了——没有目录的话,再好的问法也得翻遍所有盒子才能回答。于是小明决定做一本真正的“登记册”。
{"tags":["red"]} 这样的 JSON 条件片段。- σ(选择运算)关系代数里表示“筛选”,相当于 SQL 的 WHERE。
- doc表中 JSONB 类型的列名,代表一整个 JSON 文档(一只盒子)。
- @>JSONB 的“包含”操作符,判断左边文档是否包含右边子文档。
- q查询条件 JSON,这里指
{"tags":["red"]}。
GIN索引原理:物品总登记册
小明想到一个妙招:与其每次开箱,不如把所有盒子里出现过的东西都抄进一本大册子。他看到 B1 盒有红色,就在“red“那行写下 B1;看到 B5 盒也有红色,再补上 B5。这本册子就是”物品总登记册“——数据库里的 GIN 索引(Generalized Inverted Index,通用倒排索引)。GIN 会把 JSONB 文档拆成一个个”词条“(键、值、键值对),为每个词条维护一张”出现在哪些行的清单”,这就是倒排思想。
GIN 天生适合 @>、?、?|、?& 这类“存在性/包含性”查询。建索引只需要一条命令:
查询时,数据库先查每个词条的倒排清单,再把多张清单做交集或并集,得到候选行号,最后回表把完整行读出来。整个过程不需要打开任何“不含条件的盒子”。
有了这本总登记册,老板问“哪些盒子有红标签”,小明翻一页就能念出 B1、B5、B8,再也不用开箱。但登记册只回答“有没有”,如果老板要“价格从低到高”,就得换一本按数字排好的流水账了。
- Hit(q)整个查询条件 q 最终命中的行集合。
- t ∈ q查询条件 q 被拆成多个词条 t,例如 "tags"、"red"。
- Post(t)词条 t 的倒排表,即“哪些盒子有这个词条”的清单。
- ∩(交集)同时包含所有词条的盒子才留下。
CREATE INDEX idx_attrs_gin ON products USING GIN (attrs);BTREE索引:价格流水账
登记册按物品名字排,管不了价格高低。小明又做了一本“价格流水账”:他把每个盒子上标的价格撕下来,从小到大排好,旁边注明盒号。这就是 BTREE 索引的用法。PostgreSQL 默认的 BTREE 索引虽然能直接建在 JSONB 列上,但它比较的是整个 JSONB 值的排序规则(键个数、字符串排序),对“盒子里某个标量”的业务查询几乎没有帮助。真正有用的做法是:先把盒子里那个值抽出来,再对它排序。
抽值用 ->> 操作符,它把 JSON 里的键值转成 SQL 文本:
但这里有个隐蔽的坑:attrs ->> 'price' 出来的是 text 类型,'89' > '100' 按字符串比较会返回 true(因为字符 '8' 大于 '1')!所以更严谨的做法是先转成数字再建索引:
价格流水账让范围查询如虎添翼,叶子节点只需翻几页就能定位。可如果老板问“名字带 pottery 的盒子”,而盒子标签上写的是"Pottery"大小写不一,那流水账还差点意思——这就轮到表达式索引上场了。
- E(r)抽取函数,把盒子里某个键的值“撕下来”。
- r.data → 'price'对 JSONB 字段做路径提取,取出 price 键的值。
- sort()BTREE 的核心工作,把所有提取值排序。
- (E(r_i), r_i)排序键与行 ID 的配对,像流水账上“价格 + 盒号”。
CREATE INDEX idx_price ON products USING BTREE ((attrs ->> 'price'));CREATE INDEX idx_price_num ON products USING BTREE (((attrs ->> 'price')::numeric));SELECT * FROM products WHERE (attrs ->> 'price')::numeric > 100;
表达式索引:预处理标签架
老板想查名字叫 pottery 的盒子,可盒子上贴的是 “Pottery“。小明索性先把所有名字都转成小写,再做一本”小写名册“:名字统一按小写登记,旁边标注盒号。数据库里这叫表达式索引(Expression Index)——把”某个函数处理后的结果”建成索引,而不是直接索引原始列:
这样做有两个铁律必须记住。第一,查询里的表达式必须和索引表达式“长得一模一样”,数据库不会帮你做代数化简,你写 lower(attrs ->> 'name'),查询里就得原封不动地写这个表达式,少一个括号、多一个类型转换,索引都会“隐身”。第二,表达式里的函数必须是 immutable(不可变)的:同样的输入永远得到同样的输出。lower() 满足,now() 这种随时间变化的函数就不行,因为索引排序的前提是“同一个值永远排在同一位置”。
小明的“小写名册”竣工后,查询时也必须用同样的“小写处理”才能翻动名册。现在册子有了,但老板怎么知道该翻哪一本?仓库里其实还站着一位调度员——下一节就看他怎么派活。
- ∀x1, x2任意挑两个输入值,穷尽一切可能。
- x1 = x2两个输入完全相等。
- f(x1) = f(x2)相等的输入经过 f 处理后,输出也必然相等。
- ⇒(蕴含)前面条件成立时,后面结论必须成立,这是 immutable 的要求。
CREATE INDEX idx_lower ON products USING BTREE ((lower(attrs ->> 'name')));查询规划:调度员的抉择
仓库里有个聪明的调度员(查询规划器),他的职责是决定:老板这个问题,是直接翻盒子快,还是翻登记册快?他手上有两本账:一本是仓库总览(表的统计信息,行数、数据分布),一本是成本清单(顺序读一页多少钱、随机读一页多少钱)。他会估算两种方案的“总工时”,然后选便宜的。
想偷看调度员怎么想,就用 EXPLAIN:
如果看到 Seq Scan,说明调度员决定全表扫;看到 Bitmap Index Scan on idx_attrs_gin,说明索引被用上了。有时候调度员放着索引不用,可能是因为表太小(几万个盒子翻一遍也就几秒)、或者条件选择性太差(80% 的盒子都有 red 标签,翻索引再回表反而更贵)。可以用 ANALYZE products; 让统计信息保持新鲜;SET enable_seqscan = off; 只能用来做实验、验证索引是否生效,生产环境乱关就像把调度员绑起来,可能弄巧成拙。
调度员不是死脑筋:小仓库直接翻反而快,别盲目迷信索引。可日子一久,小明发现登记册越来越厚,翻起来竟比以前还慢——册子出了问题,得“整理”了。
- C_seq全表扫描的总成本。
- N_page × c_seq表的物理页数 × 每页顺序读取成本。
- C_idx走索引查询的总成本。
- L_tree + N_tuple × c_row树内查找层数成本 + 预估行数 × 每行回表成本。
EXPLAIN ANALYZE SELECT * FROM products WHERE attrs @> '{"tags":["red"]}';膨胀维护:登记册变厚怎么办
登记册用久了,上面贴满撕掉的便签、涂改的痕迹,甚至整页纸只写了半行字就空着。册子页数翻了一倍,有价值的内容却没变多。这就是索引膨胀(bloat)。PostgreSQL 的多版本并发控制(MVCC)是罪魁祸首:每次 UPDATE 或 DELETE 不会真的抹掉旧数据,而是把旧版本标成“死元组”,留着给正在执行的其他事务看。指向这些死元组的索引项也暂时不能删,于是索引页里堆满了“死项空位”。
清理工作分两档。VACUUM (ANALYZE) 是常规保洁:把死元组占用的空间标为可复用,顺便更新统计信息,但它不压缩索引页。真正让册子变薄的是 REINDEX INDEX idx_attrs_gin;——把索引整个重建一遍,物理压缩到最小。生产中通常交给 autovacuum 自动打理,触发阈值由 autovacuum_vacuum_scale_factor 控制(默认 0.2,即表里 20% 是死元组时启动)。
定期整理册子是仓管员的必修课。VACUUM 像撕掉明显废纸,REINDEX 像重新抄一本干净的新册子。但比“册子变厚”更让小明头疼的是:盒子一改,所有册子都得跟着改,这账怎么算?
- L存活的、真正有用的索引项数量。
- D死亡的、已经被标记删除的索引项数量。
- L / (D+L)有效项占全部项的比例,越接近 1 越健康。
- 1 - ……把有效占比反过来,得到浪费空间的比例。
更新成本:一改全册动
小明把 B1 盒里的价格从 199 改成 299,然后傻眼了:整只盒子被重新抄了一份,GIN 登记册、价格流水账、小写名册,每一本都得新记一笔。PostgreSQL 的 MVCC 机制决定了 UPDATE 不是“原地涂改”,而是生成一个全新的行版本,旧版本留给旧事务看。行上建了多少个索引,新版本就要在多少个索引里插入新指针——这种“一份更新、多处记账”的放大效应,就是写放大(Write Amplification)。
JSONB 的更新更是雪上加霜:即使只用 jsonb_set 改其中一个键,底层仍然要把整份 JSON 序列化、重写进新的行版本,不可能只改某个字节。于是针对高频更新的大 JSONB,聪明的方法是“拆家”:把庞大但低频修改的 JSON 详情字段移到一张单独的副表,主表只保留 id、状态、创建时间等高频率查询/更新的小字段,更新主表小字段时就不再拖累大 JSONB 了。
仓库管理的智慧,不在于把什么都塞进一只大盒子,而在于把常动的放面前、把大而沉的放角落。小明把这些心得整理成册,青云仓库从此井井有条。
- C_upd一条 UPDATE 语句的总代价。
- C_heap在数据堆中写一个新行版本的成本。
- Σ(求和)把所有索引的同步成本累加起来。
- C_idx_i第 i 个索引为新行版本插入指针的成本。
AI 应用场景
电商商品属性实时检索
阶段:特征数据服务(为推荐/筛选系统提供实时商品检索)
解决的问题:商品属性千变万化,用传统 EAV 表存属性要多次 join、查询爆炸;用 JSONB 存所有属性,配合 GIN 索引,一次 @> 查询就能完成“颜色=红 + 品牌=Nike + 价格<300”的组合筛选
具体案例:某电商后台运营用多属性筛选商品,原来 300 万商品全表扫描耗时 3 秒,改为 attrs @> '{"color":"red","brand":"Nike"}' 配合 GIN 索引后降到 20 毫秒,运营的实时筛选体验质变
IoT 事件日志时序分析
阶段:数据预处理与特征抽取(模型训练前按时间窗拉取事件明细)
解决的问题:设备事件字段不规则,JSONB 灵活存储;但按时间范围拉数据时如果全表扫描,海量日志会让特征工程卡死。用表达式 BTREE 索引对时间戳键建索引,可裁剪 90% 以上的无关数据
具体案例:IoT 平台每天记录百万级设备心跳,事件明细存在 log JSONB 字段。对 (log->>'ts')::timestamptz 建表达式索引后,按“某设备某天”拉取数据从 8 分钟降到 3 秒
Python 演示
import psycopg2 def main(): conn = psycopg2.connect( dbname="postgres", user="postgres", password="postgres", host="localhost", port="5432" ) conn.autocommit = True cur = conn.cursor() cur.execute("DROP TABLE IF EXISTS products") cur.execute(""" CREATE TABLE products ( id serial PRIMARY KEY, name text, attrs jsonb ) """) cur.execute("CREATE INDEX idx_attrs_gin ON products USING GIN (attrs)") rows = [ ("红背包", '{"tags":["red","bag"],"price":199}'), ("蓝水杯", '{"tags":["blue","cup"],"price":89}'), ("红水杯", '{"tags":["red","cup"],"price":99}'), ] for name, attrs in rows: cur.execute( "INSERT INTO products (name, attrs) VALUES (%s, %s)", (name, attrs) ) cur.execute(""" EXPLAIN (COSTS OFF) SELECT id, name FROM products WHERE attrs @> '{"tags":["red"]}' """) print("执行计划:") for plan_line, in cur.fetchall(): print(" " + plan_line) cur.execute(""" SELECT id, name FROM products WHERE attrs @> '{"tags":["red"]}' """) print("命中结果:") for pid, name in cur.fetchall(): print(" id=%s name=%s" % (pid, name)) cur.close() conn.close() if __name__ == "__main__": main()
@> 查询并打印 EXPLAIN 计划,验证索引是否被使用。attrs jsonb把 attrs 列声明为 JSONB 类型,插入时自动校验并规范化 JSON。CREATE INDEX idx_attrs_gin ON products USING GIN (attrs)给 JSONB 字段建 GIN 索引,相当于给仓库做物品登记册。WHERE attrs @> '{"tags":["red"]}'JSONB 包含查询:找所有 tags 数组里含 red 的文档。EXPLAIN (COSTS OFF)查看执行计划,确认是否出现 Bitmap Index Scan。VALUES (%s, %s)参数化插入,避免 SQL 注入,JSON 以字符串传入自动转换。
常见误区
误区我给 JSONB 列建了普通 BTREE 索引,为什么 `WHERE data @> '{"a":1}'` 还是没用上?是不是数据库坏了?
正解BTREE 索引比较的是“整个 JSONB 值”的排序规则,而 @> 是“内部包含”查询,二者语义不匹配,BTREE 根本帮不上忙。就像按盒子总重量排序的清单,无法回答“哪个盒子里有红按钮”。要加速 @> 必须用 GIN;要让 BTREE 有用,得先把内部字段提取成标量(如 (data->>'price')::numeric)再建表达式索引。
为何常见:初学者把 BTREE 当作“万能索引”,忽略了索引类型必须匹配查询操作符这一前提。
误区我把每个 JSONB 字段都加上 GIN 索引,查询总该快了吧?结果写入越来越慢,查询也没快多少。
正解GIN 只加速“存在/包含”操作(@>、?、?|、?&),对 data->>'price' > 100 这种范围比较毫无帮助。而且每次写入都要更新索引,索引越多写放大越严重。正确做法是先分析查询模式:等值范围走 BTREE 表达式索引,包含存在走 GIN,二者按需选择,宁缺毋滥。
为何常见:把索引当成“越多越好”的加速器,忽略了索引本身有存储和写入成本。
误区我明明建了 GIN 索引,EXPLAIN 还是显示 Seq Scan,这个数据库优化器太笨了。
正解优化器是按成本选路的。如果表只有几百行,全表扫描哪怕扫完也比翻索引再回表快;如果 80% 的行都满足条件,走索引反而要大量回表,比顺序读更慢。先 ANALYZE 更新统计信息,再用 SET enable_seqscan = off 做对照实验,但生产环境不要长期关全表扫描。
为何常见:把“走索引”当成目标,而不是把“查询快”当成目标;忽略了优化器是基于统计数据的理性决策者。
误区我建了 `lower(data->>'name')` 表达式索引,把查询改成 `WHERE lower(trim(data->>'name')) = 'tom'` 应该也能用上吧?毕竟也是 lower 处理。
正解PostgreSQL 要求查询表达式与索引表达式完全一致(字面等价),它不会帮你化简。多一个 trim、少一个类型转换、换个写法,索引就失效。正确做法是让查询严格复用索引里的表达式,比如 WHERE lower(data->>'name') = 'tom'。
为何常见:把表达式索引当成“模糊匹配”,忽略了数据库解析树严格比对这一实现细节。
误区索引膨胀了,跑一下 VACUUM 应该就瘦回去了吧?
正解VACUUM 会清理死元组并把空页标为可复用,但索引页内的碎片空间并不会自动合并压缩,索引文件可能依然臃肿。想让物理体积真正变小,需要 REINDEX INDEX 重建索引,或 VACUUM FULL 重写表(注意会锁表)。生产环境可在维护窗口执行 REINDEX。
为何常见:混淆了“回收空间”与“压缩物理文件”两个层次,以为清理死元组等于重建索引。
面试题
Q1. JSONB 的 @> 操作符在查询中表示什么?
- A.左文档等于右文档
- B.左文档包含右子文档← 正确答案
- C.左文档大于右文档
- D.两文档取交集
答案:B。@> 是 JSONB 的“包含”操作符,判断左侧文档是否包含右侧子文档,语义上对应“盒子里面有没有这套东西”。等值用 =, 交集不是 SQL 操作符语义,C 的“大于”是普通数据类型比较,JSONB 也能比但不是查询重点。
Q2. 下列哪种索引最适合加速 JSONB 的“包含”查询(WHERE data @> '{"a":1}')?
- A.BTREE
- B.HASH
- C.GIN← 正确答案
- D.BRIN
答案:C。GIN 是通用倒排索引,把 JSONB 拆成词条并维护“词条→行号”清单,天然匹配包含/存在查询。BTREE 处理整个 JSONB 值的排序,HASH 只能做等值,BRIN 面向物理顺序相关的范围查询,都不适合 JSONB 内部包含语义。
Q3. 要为 JSONB 字段 data 的 price 键建一个支持“价格大于 100”的索引,正确做法是?
- A.
CREATE INDEX ON t USING GIN (data); - B.
CREATE INDEX ON t USING BTREE (data); - C.
CREATE INDEX ON t USING BTREE ((data->>'price')); - D.
CREATE INDEX ON t USING BTREE ((data->>'price')::numeric);← 正确答案
答案:D。范围比较需要 BTREE,且必须把 JSONB 内部字段提取成标量。->> 返回 text,'89' > '100' 按字符串比会出错,所以要先 ::numeric 转数字。GIN 不管范围查询;直接对整列 data 建 BTREE 对内部字段没用。
Q4. 假设 price 值存为 JSONB 数字,WHERE data->>'price' > '100' 会用字符串比较,那么 '89' > '100' 的结果是?
- A.true← 正确答案
- B.false
- C.NULL
- D.报错
答案:A。两个 text 按字典序逐字符比较,'8' 的字符编码大于 '1',所以 '89' 会排在 '100' 前面,比较结果为 true。这正是必须把 ->> 结果 ::numeric 再比较的原因,否则价格排序和筛选会得出反直觉结果。
Q5. GIN 索引中“倒排”的含义是什么?
- A.索引数据从表尾向前扫描
- B.从“词条”反查“包含它的行号”← 正确答案
- C.文档内部键值倒序存储
- D.索引页双向链表
答案:B。倒排索引是以“词条”为主键、值为“出现该词条的行号列表”,相当于从内容反查位置。普通 BTREE 是以行键正查内容,两者方向相反,所以叫倒排。A、C、D 都是字面误解。
Q6. 关于 SET enable_seqscan = off,正确说法是?
- A.永久禁用全表扫描,适合生产环境
- B.仅当前会话倾向走索引,常用来验证索引是否生效← 正确答案
- C.删除所有表的统计信息
- D.强制全表加锁
答案:B。enable_seqscan 是会话级开关,置为 off 只让优化器“尽量”不走顺序扫描,常用于测试某个索引能否被使用,不能作为生产调优手段。生产应靠更新统计信息和合理的成本参数让优化器自然选择最优计划。
Q7. 索引膨胀(bloat)的主要成因有哪些?
- A.大量 UPDATE/DELETE 积累的死元组← 正确答案
- B.索引页内被标记删除的空位未合并← 正确答案
- C.HOT 更新一定会导致索引膨胀
- D.使用 GIN 索引就不会膨胀
答案:AB。MVCC 下更新和删除产生死元组,对应索引项暂时不能清除,形成空位,这是膨胀主因。C 错:HOT(Heap-Only Tuple)更新在索引键不变时不产生新索引项,反而能缓解索引膨胀。D 错:GIN 同样会膨胀,只是表现形态不同。
Q8. 关于 JSONB 字段的 UPDATE 成本,下列说法正确的是?
- A.只改其中一个键,数据库会原地修改该键
- B.每次 UPDATE 生成新行版本,且每个索引都要插入新指针← 正确答案
- C.
jsonb_set能避免整行重写 - D.更新大 JSONB 不影响主表其他列
答案:B。PostgreSQL 的 MVCC 让 UPDATE 总是写新行版本,所有索引都要同步插入新项(写放大)。jsonb_set 只是在 JSON 逻辑层局部修改,物理上仍然整行重写,所以 C 错。A 错在“原地修改”,D 是干扰项,整行重写自然牵动整行。
Q9. 关于表达式索引,下列哪个说法正确?
- A.只要函数相同,查询怎么写都能命中索引
- B.查询表达式必须与索引表达式逐字一致← 正确答案
- C.表达式索引只能用 BTREE
- D.volatile 函数也可以建表达式索引
答案:B。PostgreSQL 不对方程式做代数化简,查询表达式必须与索引表达式在语法树上完全一致,否则索引“隐身”。表达式索引可配 BTREE 也可配 GIN。只有 immutable 函数才能建表达式索引,volatile 函数结果不稳定,无法排序。
Q10. 想让 WHERE lower(data->>'name') = 'tom' 稳定使用表达式索引,正确的做法包括?
- A.先建一个 GIN 索引
- B.创建
CREATE INDEX ON t USING BTREE ((lower(data->>'name')))← 正确答案 - C.查询中的写法与索引表达式逐字保持一致← 正确答案
- D.任何时刻都可以用
now()这类函数建索引
答案:BC。要命中表达式索引,必须先按同样的表达式建索引(B),且查询写法必须与索引表达式逐字一致(C)。A 不必要,GIN 不解决等值低效问题;D 错,now() 是 volatile,结果随时间变化,不能作为索引表达式。