PostgreSQL 的 JSONB 列可以容纳形态不同的文档,但高效查询仍要求条件与索引有准确的对应关系。对整篇文档建立 GIN 索引,只能支持特定的 JSONB 算子,不能自动加速所有从文档提取字段的表达式。先确定数据类型和查询语义,再选择索引,才能避免建完索引却没有收益。
让算子与索引对应
包含条件 payload @> '{"status":"pending"}' 检查文档是否包含该键及对应的 JSON 字符串值。采用默认 jsonb_ops 操作符类时,整列 GIN 可以支持包含、顶层键存在判断,以及能够提取索引条件的 JSON 路径查询。索引保存可检索的组成部分;位图堆扫描仍可能访问表,并重新检查候选行,不能把它理解为完全不用回表。
payload->>'status' = 'pending' 则是在比较提取后的文本。payload 上的 GIN 并不是这个文本表达式的索引。对 (payload->>'status') 建立 B-tree 表达式索引,才与标量等值查询对应。如果很多查询需要对 status 排序、关联或施加约束,独立的有类型列可能更清楚,也能减少不断从灵活文档中推导字段的成本。
不能未经类型检查,就把所有文本条件改写成包含条件。JSON 数字和 JSON 字符串经过文本提取后可能得到相同文本,但包含算子会区分它们的 JSON 类型。只有数据约束保证相关前提时,改写才等价。应用应通过数据库驱动绑定参数,而不是把不可信输入拼接进 SQL 或 JSON 字符串;索引选择和输入处理需要分别正确。
操作符类也决定能力边界。jsonb_path_ops 支持包含以及适用的路径匹配,但不支持 jsonb_ops 提供的键存在算子。对合适数据,它可能更小、选择性更好,却不是无条件更优的替代品。同样,嵌套表达式索引必须对应实际使用的表达式,给完整文档建索引,并不意味着提取后的每棵子树都自动有独立索引。
缺失和空值是不同问题
键不存在、键对应 JSON null、键对应字符串“null”,是三种状态。存在判断会把后两种都视为存在。用 -> 提取 JSON 时,缺失键得到 SQL NULL,显式 null 得到 JSON null 值。用 ->> 提取文本时,缺失与 JSON null 都变成 SQL NULL,而字符串仍保留为文本。这些差别会影响过滤、唯一性假设和应用默认值。
如果业务关心键是否出现,应使用键存在检查;如果字段必须是字符串或数字,还要检查 JSON 类型。用普通等号和 NULL 比较,不能表达“键不存在”。另外,? 只检查顶层,不会递归查找文档任意深度的同名键。查询内部对象时,必须明确路径及对应索引,不能因某处存在同名键就认为顶层条件成立。
坏掉的样子
常见性能问题是建了 GIN,却保留不兼容的文本提取条件。另一个错误是认为索引只要适用,就一定会被选择。统计信息、选择性、表大小和成本估计都会影响计划;读取大部分数据时,顺序扫描可能合理。应先比较估计行数与实际行数,再判断问题是估计偏差、条件不匹配,还是索引本身收益不足。
大型灵活文档也会增加写入工作。GIN 要维护许多条目,更新文档依然是更新一行,会付出相应的锁和存储成本。应围绕真实负载的问题建立索引,不要为假想查询覆盖整篇文档。JSONB 语法合法,也不能证明必要业务字段存在或类型正确;这些要求要由实际契约和校验承担。新增 CHECK 时还要记住,表达式结果为 SQL NULL 也会通过检查;应明确要求键存在及类型正确,不能仅依赖可能返回空值的文本提取条件。
快速验证
把下面内容保存为 json-query.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f json-query.sql。实验在事务中创建临时数据,末尾回滚清除。其中只有百分之一的行处于 pending 状态,便于观察选择性较高时的计划。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE documents (id integer, payload jsonb NOT NULL);
INSERT INTO documents SELECT g, jsonb_build_object('status',
CASE WHEN g%100=0 THEN 'pending' ELSE 'done' END)
FROM generate_series(1,10000) AS g;
CREATE INDEX documents_payload ON documents USING gin(payload);
ANALYZE documents;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM documents WHERE payload @> '{"status":"pending"}';
EXPLAIN (COSTS OFF)
SELECT id FROM documents WHERE payload->>'status'='pending';
CREATE INDEX documents_status ON documents ((payload->>'status'));
ANALYZE documents;
EXPLAIN (COSTS OFF)
SELECT id FROM documents WHERE payload->>'status'='pending';
SELECT value, value ? 'x' AS has_key,
value->'x' IS NULL AS sql_null_json, value->>'x' IS NULL AS sql_null_text
FROM (VALUES ('{}'::jsonb),('{"x":null}'::jsonb),('{"x":"null"}'::jsonb)) v(value);
DO $$ BEGIN
IF (SELECT count(*) FROM documents WHERE payload @> '{"status":"pending"}') <> 100 THEN
RAISE EXCEPTION 'wrong containment count';
END IF;
IF '{"n":1}'::jsonb @> '{"n":"1"}'::jsonb THEN
RAISE EXCEPTION 'number and string were conflated';
END IF;
IF '{"outer":{"x":1}}'::jsonb ? 'x' THEN
RAISE EXCEPTION 'top-level existence searched recursively';
END IF;
IF ('{"x":null}'::jsonb->'x') IS NULL THEN
RAISE EXCEPTION 'JSON null confused with missing key';
END IF;
END $$;
ROLLBACK;
PostgreSQL 18.6 实测中,包含查询通过 documents_payload 使用位图索引扫描,返回一百行。文本提取条件最初使用顺序扫描,创建表达式索引后改用 documents_status。这些是当前分布下观察到的选择,并不是固定的计划承诺,也不能用来估计生产延迟。迁移到其他规模或数据形态后,需要重新检查实际计划。
最后三行分别展示缺失、显式 JSON null 和文本字符串。它们的存在标记依次为假、真、真;文本提取结果是否为 SQL NULL 的标记依次为真、真、假。断言还验证包含判断区分类型,键存在检查不会递归。修改查询形态时,应保留这些语义检查:只有结果仍符合预期,计划变快才有价值,不能用一个更快但混淆空值或类型的条件替代原有业务含义。
来源
- PostgreSQL JSON 类型与索引,GIN 操作符类与表达式索引,已核验 2026-10-15。
- PostgreSQL JSON 函数与操作符,提取、存在、包含和空值语义,已核验 2026-10-15。
- PostgreSQL 18: CHECK 约束的空值行为,CHECK 接受未知结果及显式空值约束,已核验 2026-10-15。