GIN 索引从复合值中提取可搜索的键,并记录这些键对应哪些行。全文文档、数组和 JSONB 都可以使用这种访问方法,但每个索引表达式仍需要匹配的操作符类。GIN 提供的是一组支持特定谓词的搜索能力,不是一建就能自动加速所有字段、类型和查询条件的万能索引。评估它之前,必须先明确应用到底怎样查找数据。

索引的是搜索表示

全文搜索中,to_tsvector 把正文转换成规范化词项,tsquery 描述要匹配的条件。建在这个向量上的 GIN 可以先缩小候选行集合,避免每次请求都解析全部文档。但是常见词仍可能对应很大的候选集合,后续读取行、复查条件、计算排名和排序都可能成为主要开销。索引查得快,并不代表整个搜索页面一定快,更不意味着返回所有匹配项没有成本。全文 GIN 保存词项而不保存权重标签,因此带权重限制的搜索需要复核行,不能只从索引候选就判断完全匹配。

文本配置本身属于查询语义。English 配置可能把 “outage” 词干化成不同于 simple 配置的词项,所以建立在 to_tsvector('english', body) 上的表达式索引,需要查询使用匹配的表达式。省略配置会让转换依赖会话设置,也不会匹配这个显式 English 索引。两参数形式还保证索引中的转换不随默认配置漂移,否则相同正文可能在不同时间产生不同词项。

其他 GIN 用法使用不同操作符。JSONB 的默认操作符类与 path 操作符类支持的条件并不完全相同,数组包含索引也不是全文索引。应根据真实谓词选择列、表达式和操作符类,而不是看到一种数据类型支持 GIN 就直接添加。如果应用查询不兼容,索引仍消耗存储和写入资源,却无法带来读收益;这种情况比没有索引更容易给人错误信心。

待处理条目仍可搜索

一篇文档可能贡献许多键,所以维护倒排结构的写入工作量较大。启用 fastupdate 时,新条目可以先进入 pending list,再合并到主索引结构。查询会同时搜索待处理列表和主结构,因此它调整的是维护工作的时机及额外查询成本,不是用延迟可见性换写入速度。当前快照中可见的新行,在列表清理之前也必须能够被查到。

VACUUM、自动 ANALYZE、显式清理函数或列表超过阈值,都可能触发合并。列表过大会拖慢搜索,前台写入碰上清理周期也可能出现延迟尖峰。增大阈值只是改变什么时候付出成本,并没有消除维护工作。调整前应结合导入速度、查询延迟、自动维护行为和维护窗口评估,不能把某个配置值当成对所有业务都适用的答案。

坏掉的样子

常见回归是应用重构后改了文本配置或表达式,查询仍返回正确结果,却失去了原先预期的访问路径。检查 EXPLAIN 中实际使用的条件和索引,先确认语义一致,再讨论规划器选择。小表采用顺序扫描本身并不能证明配置错误;但 simple 表达式确实不能拿 English 表达式索引当成相同语义使用,强行关闭顺序扫描也不会修正这种不匹配。

另一个误判是只根据索引与表的大小比例诊断膨胀。词项很多的文档本来就可能需要较大的倒排结构。更新后的增长趋势、死条目、待处理列表状态以及查询实际工作量,比一个固定比例更有解释力。REINDEX 本身消耗空间与时间,也可能影响可用性,应先确认问题并安排维护方式,不能没有证据就把周期性重建当成默认处方。

快速验证

把 SQL 保存为 gin.sql,用 psql -X -v ON_ERROR_STOP=1 -f gin.sql 连接一个可丢弃的 PostgreSQL 18 数据库。临时表最初有一万行,其中十行包含稀有词 outage;索引建成之后,再插入一条同样匹配的新文档。全部对象均由实验创建,最后回滚清理。

\set ON_ERROR_STOP on
BEGIN;
SET LOCAL statement_timeout = '5s';
CREATE TEMP TABLE docs (id integer PRIMARY KEY, body text);
INSERT INTO docs
SELECT g, CASE WHEN g % 1000 = 0 THEN 'outage incident' ELSE 'routine maintenance' END
FROM generate_series(1,10000) AS g;
CREATE INDEX docs_body_gin ON docs USING gin (to_tsvector('english', body));
ANALYZE docs;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
INSERT INTO docs VALUES (10001, 'outage recovery');
SELECT count(*) AS matches_before_pending_cleanup FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
SELECT gin_clean_pending_list('docs_body_gin'::regclass);
SELECT count(*) AS matches_after_pending_cleanup FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
EXPLAIN (COSTS OFF)
SELECT id FROM docs
WHERE to_tsvector('simple', body) @@ plainto_tsquery('simple', 'outage');
DO $$ BEGIN
  IF (SELECT count(*) FROM docs WHERE to_tsvector('english',body)
      @@ plainto_tsquery('english','outage')) <> 11 THEN
    RAISE EXCEPTION 'search lost a visible document';
  END IF;
END $$;
ROLLBACK;

在 PostgreSQL 18.6 上,English 表达式选择了 docs_body_gin 的 bitmap index scan。插入后立即查询得到十一行,显式清理待处理列表后仍是十一行,直接反驳“pending list 会牺牲搜索新鲜度”的说法。改为 simple 配置的表达式使用顺序扫描。清理返回的页数、缓冲区总数、具体耗时和计划选择都是环境观测值,不能作为跨机器常量;匹配行数才是夹具中确定的正确性预期。

这个例子没有对批量导入做基准测试,也没有证明 GIN 适合所有搜索。生产评估还应包含常见词、空查询、文档更新、排名和分页要求,并在代表性的并发下同时验证正确性与延迟。否则只用十条稀有词命中的漂亮计划,容易掩盖实际用户输入造成的大候选集合和后续排序成本。

来源