跳到正文

JSON 与全文检索:两种 GIN 索引,两种失效边界 ​

一句话:JSONB 把半结构化数据变成可索引的二进制文档,全文检索把文本变成词位向量;两者都常用 GIN,但索引只对特定操作符生效。

适用 PostgreSQL 16/17。中文分词依赖 zhparser 或 pg_jieba,需单独安装,版本与配置名以扩展文档为准。

为什么需要 ​

业务里有两类数据不适合严格的关系表:一类是字段经常变的扩展属性、审计快照、第三方回调;另一类是需要按关键词检索的长文本。

用 MySQL 时,前者常被塞进 JSON 文本,后者要上 Elasticsearch。PostgreSQL 的选择是:JSONB 作为普通列参与事务和 JOIN,全文检索作为普通索引参与查询,不需要额外同步链路。代价是:GIN 索引有写放大,中文分词要自己装扩展。

核心机制 ​

JSON 与 JSONB ​

维度jsonjsonb
存储原始文本解析后的二进制
写入快,不解析慢,要解析、去重、排序键
读取/操作每次重新解析直接读,支持索引
键顺序/空格保留不保留
重复键保留后者覆盖前者
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。

把一门技术讲透到既懂也会用