主题
JSON 与全文检索:两种 GIN 索引,两种失效边界
一句话:JSONB 把半结构化数据变成可索引的二进制文档,全文检索把文本变成词位向量;两者都常用 GIN,但索引只对特定操作符生效。
适用 PostgreSQL 16/17。中文分词依赖 zhparser 或 pg_jieba,需单独安装,版本与配置名以扩展文档为准。
为什么需要
业务里有两类数据不适合严格的关系表:一类是字段经常变的扩展属性、审计快照、第三方回调;另一类是需要按关键词检索的长文本。
用 MySQL 时,前者常被塞进 JSON 文本,后者要上 Elasticsearch。PostgreSQL 的选择是:JSONB 作为普通列参与事务和 JOIN,全文检索作为普通索引参与查询,不需要额外同步链路。代价是:GIN 索引有写放大,中文分词要自己装扩展。
核心机制
JSON 与 JSONB
| 维度 | json | jsonb |
|---|---|---|
| 存储 | 原始文本 | 解析后的二进制 |
| 写入 | 快,不解析 | 慢,要解析、去重、排序键 |
| 读取/操作 | 每次重新解析 | 直接读,支持索引 |
| 键顺序/空格 | 保留 | 不保留 |
| 重复键 | 保留 | 后者覆盖前者 |
| GIN 索引 | 不支持 | 支持 |
生产默认用 jsonb。只有需要保留原始报文(审计、回放)时才用 json。
常用操作符:
| 操作符 | 含义 | 是否适合 GIN |
|---|---|---|
-> / ->> | 取 JSON / 取 text | 否,除非建表达式索引 |
#> / #>> | 按路径取 JSON / text | 否,除非建表达式索引 |
@> / <@ | 包含 / 被包含 | 是 |
? / `? | /?&` | 键/数组元素是否存在 |
@? / @@ | JSONPath 是否存在/匹配 | 是(PostgreSQL 12+,以文档为准) |
两种 JSONB GIN 索引
sql
-- 默认 jsonb_ops:支持 @>、?、?|、?&、@?、@@,索引更大
CREATE INDEX idx_doc_meta ON doc USING GIN (meta);
-- jsonb_path_ops:只支持 @>(以及 @?/@@),索引更小、包含查询更快
CREATE INDEX idx_doc_meta_path ON doc USING GIN (meta jsonb_path_ops);选择规则:查询只用 @> 时选 jsonb_path_ops;需要 ? 做键存在判断时选默认 jsonb_ops。两个都建通常没有必要。
对 JSONB 中某个固定字段做等值/范围查询,B-tree 表达式索引更合适:
sql
CREATE INDEX idx_doc_status ON doc ((meta->>'status'));
-- 查询必须写同样的表达式
SELECT * FROM doc WHERE meta->>'status' = 'published';GIN 什么时候失效
这是 JSONB 查询变慢的最常见原因:
- 用
->>取字段后比较:meta->>'status' = 'published'不走 JSONB 的 GIN,除非建了上面的表达式索引。 - 用
->取子对象再比较:meta->'status' = '"published"'同样不走 GIN。 - 用
jsonb_path_ops索引却写meta ? 'status':操作符不被该 opclass 支持。 - 对列做函数/类型转换:
lower(meta->>'status')、(meta->>'age')::int > 18,需要对应的表达式索引。 - 需要排序或范围:GIN 不提供有序扫描,
ORDER BY、>、<用不上。 LIKE '%x%':GIN 不处理,除非用pg_trgm。- pending list 未清理:
fastupdate=on(默认)时,新写入先进入 pending list,查询要先扫它;写入突增后可能变慢。可调gin_pending_list_limit(默认 4MB)或手动SELECT gin_clean_pending_list('idx_doc_meta');。
全文检索的基本链路
tsvector 是文档的词位集合,tsquery 是查询条件。索引建在 tsvector 上,查询用 @@ 匹配,例如 to_tsvector('simple', 'PostgreSQL full text search') 和 websearch_to_tsquery('simple', 'postgresql search')。
中文默认没有分词器:to_tsvector('simple', '分布式事务') 会把整串当成一个词,搜「事务」匹配不到。需要 zhparser 或 pg_jieba。
zhparser 的典型配置:
sql
CREATE EXTENSION IF NOT EXISTS zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
ADD MAPPING FOR n,v,a,i,e,l WITH simple;pg_jieba 安装后通常直接提供 jiebacfg 配置,执行 CREATE EXTENSION IF NOT EXISTS pg_jieba; 后可直接用 to_tsvector('jiebacfg', '分布式事务与一致性')。
分词结果可以用 SELECT * FROM ts_debug('chinese', '分布式事务'); 检查;分词不对时,先调扩展词典,不要先怀疑索引。
最小上手
sql
-- 前提:已安装 zhparser;没有中文分词时把 chinese 换成 simple,但中文检索会退化
CREATE EXTENSION IF NOT EXISTS zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
ADD MAPPING FOR n,v,a,i,e,l WITH simple;
CREATE TABLE articles (
id bigserial PRIMARY KEY,
content text NOT NULL,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
tsv tsvector GENERATED ALWAYS AS (to_tsvector('chinese', content)) STORED
);
CREATE INDEX idx_articles_tsv ON articles USING GIN (tsv);
CREATE INDEX idx_articles_meta ON articles USING GIN (meta jsonb_path_ops);
INSERT INTO articles (content, meta) VALUES
('分布式事务需要协调多个参与者', '{"status":"published","tags":["db","distributed"]}'),
('全文检索把文本拆成词位', '{"status":"draft","tags":["search"]}');
-- 中文全文检索 + 相关性排序
SELECT id, ts_rank_cd(tsv, q) AS rank
FROM articles, websearch_to_tsquery('chinese', '分布式 事务') q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;
-- JSONB 包含查询
SELECT id FROM articles WHERE meta @> '{"status":"published"}';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles WHERE meta @> '{"status":"published"}';期望 JSONB 查询出现 Bitmap Index Scan on idx_articles_meta;全文查询出现 Bitmap Index Scan on idx_articles_tsv。如果显示 Seq Scan,先检查操作符和索引 opclass 是否匹配。
生产动作
- 生成列必须用显式配置:
to_tsvector(content)使用default_text_search_config,不是 immutable,不能用于生成列;写成to_tsvector('chinese', content)。 - 索引表达式与查询表达式一致:
to_tsvector('chinese', content)和to_tsvector('simple', content)是两个不同的表达式,不能互相用索引。 - 建索引内存:GIN 构建吃
maintenance_work_mem(默认 64MB);大表建索引前临时调大,或使用CREATE INDEX CONCURRENTLY避免阻塞写入。 - 写放大:GIN 每次写入要更新多个 posting list;更新频繁的 JSONB 列,索引维护成本明显。只索引真正用于过滤的列,不要给整张宽表的所有 JSONB 建 GIN。
- 查 pending list:
fastupdate=on时,写入先进入 pending list;可以用SELECT * FROM gin_pending_list_size('idx_articles_meta');(需pgstattuple,以版本为准)观察,必要时调整gin_pending_list_limit。 - 中文检索质量:先用
ts_debug看分词,再决定词典;不要把 Elasticsearch 的分词器行为直接套到 PostgreSQL。 - 排障路径:现象「JSONB 建了索引仍全表扫」→
EXPLAIN看Index Cond是否为空 → 操作符是否为@>、opclass 是否为jsonb_path_ops、条件是否被函数包住 → 分别改为@>、换 opclass、建表达式索引。
边界
- JSONB 不是文档数据库:适合「关系列 + 少量扩展属性」,不适合整篇文档、深层嵌套、频繁整对象更新。更新一个键也会重写整个 JSONB 值。
- GIN 不擅长范围/排序:JSONB 里的数值范围、时间排序,用表达式 B-tree,不要指望 GIN。
- 中文分词不是内置能力:
simple配置不切词;zhparser/pg_jieba需要安装扩展、维护词典,分词质量直接影响召回。 - 全文检索规模有限:百万级、单机、简单相关性排序够用;需要分布式、复杂高亮、聚合、同义词/拼写纠错时,交给 Elasticsearch。
- JSONB 的 GIN 索引很大:
jsonb_ops尤其明显;建之前估算磁盘和写入放大,必要时只索引jsonb_path_ops或表达式索引。 @?/@@的索引支持随版本变化:不同 PostgreSQL 版本对 JSONPath 的索引能力有差异,以官方文档为准。
讲清楚
JSONB 的价值是把半结构化数据放进关系库,和业务列一起事务、一起 JOIN;它的 GIN 索引只对 @>、? 这类操作符有效,写成 ->> 比较就会退化为全表扫描,这时要么改查询,要么建表达式 B-tree。全文检索是另一套结构:先把文本转成 tsvector,再在生成列上建 GIN,查询用 tsquery @@ 匹配。中文的难点不在索引,而在分词——PostgreSQL 内置配置不切中文,必须装 zhparser 或 pg_jieba,并用 ts_debug 验证分词结果。两者的共同边界是:GIN 读快写慢、不提供有序扫描,适合过滤,不适合排序和范围;规模再往上、需要复杂相关性或分布式,就该考虑 Elasticsearch。