
前一阵有个朋友给我发来几张截图他们生产库上同一个查询强制走 IndexScan 返回 642 万行改成 SeqScan 却返回 821 万行差了快两百万行。群里有人抛出一句“索引坏了”甚至有人建议赶紧停应用做全量索引重建。我赶紧按住他别冲动IndexScan 比 SeqScan 返回结果更少这个现象我见过太多次真正是物理损坏的比例极低。过去三年我在不同项目里排查过至少十次类似的“索引损坏”事件最后真正需要重建索引的只有一次。绝大多数情况下问题出在索引定义、SQL 语义、MVCC 快照这三类因素上。这篇文章就把这个坑从头到尾拆开先说清楚为什么你不能看到结果不一致就怪索引再手把手带你验证最后告诉你万一真是索引损坏该怎么处理。1. 先说结论这个锅大概率不是索引的1.1 为什么“IndexScan 返回少”会让人产生恐慌IndexScan 和 SeqScan 是两种不同的数据访问路径。SeqScan 直接扫描整个堆表一页页往下读遇到一行判断一行逻辑简单直接。IndexScan 则先通过索引结构定位到符合条件的索引项再回表取出对应的数据行或者在满足条件的场景下直接从索引里拿数据。从直觉上看索引的结构更“挑食”它只包含符合特定条件的键值。如果一张表有 2000 万行索引项却只有 1500 万个那么走索引扫描出来的结果当然可能比全表扫描少。这一点恰恰被很多人忽略了索引本身就是一个“部分数据集”。这里的核心问题是这个索引是不是部分索引Partial Index索引建的时候有没有带WHERE条件索引列上是不是大量存在 NULLSQL 里写的到底是count(*)还是count(col)如果这些都没查清楚就直接下“索引损坏”的结论十有八九会闹出乌龙。1.2 先把可能的根源列全再谈排查我习惯把“IndexScan 比 SeqScan 返回的行数少”这个问题拆成四个层次。每一层都有可能但概率完全不同层次可能原因概率评估第一层部分索引索引本身就只覆盖部分行非常高最常见的假故障第二层SQL 语义问题比如count(col)忽略 NULL高经常和第一层同时出现第三层MVCC 快照不同两次查询之间数据被并发修改高别把数据变化赖到索引头上第四层索引物理损坏B-tree 结构错乱导致漏读低罕见但确实存在这四层必须按顺序排查。前面三层都是“假损坏”处理方式是改 SQL、改查询方式或者根本不处理第四层才是真正需要重建索引的。先看执行计划、再看索引定义、再查统计信息和事务快照最后才动用amcheck这类结构校验工具这是我排查这类问题永远不变的路数。2. 最常见的三个“假损坏”原因2.1 你用的可能是个部分索引它本来就是“残缺”的PostgreSQL 里有个很实用的特性叫部分索引它允许你在创建索引时加上一个WHERE条件只对满足条件的行建索引。这样做的好处是索引体积小、写入开销低特别适合“一张表里只有少数行处于特殊状态”的场景。举个例子一张用户表status字段只有 1% 的行等于active绝大多数是inactive。你写一个查询SELECT * FROM users WHERE status active这时如果有个部分索引WHERE status active就能让索引目标非常精准扫描效率极高。问题恰恰出在这里。有些人建完索引后用SELECT count(*) FROM users对比发现走索引的结果比全表少于是以为索引坏了。可这个索引本来就不包含inactive的行拿它去统计全表数据当然对不上。我用一个最小例子来演示。先建一张 100 万行的表其中只有 10 万行的状态是activeCREATE TABLE t_user ( id integer PRIMARY KEY, status text ); INSERT INTO t_user SELECT g, CASE WHEN g % 10 0 THEN active ELSE inactive END FROM generate_series(1, 1000000) g; CREATE INDEX idx_user_active ON t_user(status) WHERE status active;这时候分开跑-- 全表扫描count(*) 正确返回 100 万 EXPLAIN ANALYZE SELECT count(*) FROM t_user; -- 走部分索引count(*) 只统计 statusactive 的行返回 10 万 EXPLAIN ANALYZE SELECT count(*) FROM t_user WHERE status active;表面上看索引扫描返回的行数少了 90 万好像“漏数据”。但实际上第二个查询带了WHERE status active条件它本来就应该只返回 10 万行。这个索引根本没有问题问题出在数据过滤条件上。遇到这种场景第一件事是执行SELECT pg_get_indexdef(idx_user_active::regclass)看建的到底是不是部分索引。如果是直接检查查询条件是否与索引的WHERE条件一致。2.2 count(col) 和 count(*)NULL 值引发的“漏行”第二个非常常见的假故障是 SQL 里的目标列写错了。count(*)统计的是表里的行数而count(col)统计的只是该列非 NULL的行数。这两个函数在语义上完全不同但很多人在排查问题时不会留意。PostgreSQL 的 B-tree 索引默认不存储全为 NULL 的索引条目。也就是说如果某一行在索引列上的值是 NULL那么这一行不会出现在普通索引里。于是就会出现一个非常迷惑的场景你用count(col)去查一个大表优化器选择了索引扫描返回的行数比表里的实际行数少很多。模拟一下这个情况CREATE TABLE t_null_test ( id integer PRIMARY KEY, val text ); INSERT INTO t_null_test SELECT g, CASE WHEN g % 3 0 THEN NULL ELSE v || g END FROM generate_series(1, 300000) g; CREATE INDEX idx_null_test_val ON t_null_test(val); -- count(*) 走主键索引或表扫描返回 30 万 SELECT count(*) FROM t_null_test; -- count(val) 走 val 索引只统计非 NULL 的行大约 20 万 SELECT count(val) FROM t_null_test;如果你只看到后面这个count(val)结果不看 SQL 本身很容易得出“索引漏了十万行”的错觉。实际上这十万行在val列上就是 NULLcount(val)本来就应该把它们排除。此时正确的做法是检查查询语句如果你需要统计的是全表行数就写count(*)如果你确实想统计非 NULL 值数量count(val)就完全正确索引只不过帮了倒忙让你更容易误解而已。2.3 MVCC 快照差异是数据变了不是索引漏了第三个坑更加隐蔽因为它不涉及索引定义而涉及数据库的多版本并发控制和快照机制。在READ COMMITTED隔离级别下同一个事务里的每一条 SQL 命令都会获取一个独立的快照。什么意思呢你在事务中先执行了一条SELECT然后另一个会话提交了一批删除或更新操作回来执行第二条SELECT时你看到的可能是完全不一样的数据集。这与索引一点关系都没有。我举个典型的场景会话 A 在一个事务里执行两次统计会话 B 在两次统计之间删了大量数据。-- 会话 A开启事务第一次统计 BEGIN; SELECT count(*) FROM t_user; -- 返回 100 万 -- 会话 B另一个连接删除 10 万行并提交 DELETE FROM t_user WHERE id BETWEEN 1 AND 100000; COMMIT; -- 会话 A第二次统计再次执行时的快照已经变了 SELECT count(*) FROM t_user; -- READ COMMITTED 下返回 90 万 COMMIT;如果第一条 SQL 走的是 SeqScan第二条 SQL 因为统计信息变化改走 IndexScan你就看到了“IndexScan 返回的行数比 SeqScan 少了十万”的假象。数据量确实少了但少的原因不是索引漏了而是这两次查询发生在不同的快照下中间的数据已经被别的会话删除。还有一个容易触发类似现象的点是自动提交模式下连续执行两个独立的查询。比如你跑一个SELECT用了两秒期间另一个会话提交了一笔大数据量的变更第二个查询恰好换了执行计划看起来就像“同样条件、不同结果”。这种情况只要把所有变量统一同一个事务、同一个计划、同一批数据再测一次问题就清楚了。3. 什么情况下才是真的索引损坏3.1 物理损坏的典型特征它通常不会“安静地少返回”先说结论PostgreSQL 里索引物理损坏时最常见的表现是直接报错而不是静默地少返回几行。比如查询执行到一半抛出来ERROR: index idx_user_active contains corrupted page at block 12345或者ERROR: invalid page in block 2345 of relation base/16385/56789再或者VACUUM的时候报告ERROR: failed to re-find parent key in index idx_user_active for deletion target page这些错误非常明确基本不需要怀疑“是不是误报”。相比之下那种“索引扫描能跑完但结果少了几行”的情况物理损坏的概率其实很低。因为 B-tree 索引的遍历逻辑是一层一层向下走的如果一个叶子页被破坏扫描器大概率会在读取该页时直接抛出错误而不是悄悄跳过那一页。但有两种例外需要留意。第一种是硬件层面的内存损坏数据读出来之后值是错的但校验没有发现异常这种极难察觉第二种是索引页中的指针被错误修改导致遍历时绕开了某个分支看起来像是安静地漏了数据。这两种情况都需要通过结构校验工具来确认光靠肉眼比对行数是判断不了的。3.2 区分“物理损坏”和“逻辑异常”我在排查时会把问题分成两类物理损坏和逻辑异常。物理损坏指的是索引页的实际内容坏了可能是磁盘坏道、内存故障、数据库异常崩溃、备份恢复不完整导致的。这类问题需要用amcheck、pageinspect或重新创建索引来验证和修复。逻辑异常则是索引本身的定义、状态或数据语义出了问题比如部分索引的过滤条件与查询不匹配、索引列上的 NULL 处理、索引被中断创建后处于INVALID状态、统计信息过旧导致优化器走错路径等等。这些问题根本不需要重建索引改 SQL 或改查询条件就能解决。还有一个逻辑异常值得单独提一下CREATE INDEX CONCURRENTLY失败后留下的无效索引。在 PostgreSQL 里并发创建索引如果因为冲突而被取消或者系统中断会留下一个indisvalid false的无效索引。优化器不会使用这个无效索引所以它不会直接导致行数变少但会在后续运维中埋雷比如VACUUM、pg_dump都可能有异常表现。排查时用下面这条 SQL 检查一下SELECT indexrelid::regclass, indislive, indisready, indisvalid FROM pg_index WHERE indexrelid::regclass idx_user_active::regclass;只要indisvalid是false就该安排重建索引了。4. 手把手排查与验证实操4.1 第一步把执行计划对齐确保“同一个基准”在判断索引是否损坏之前你必须先确认两条 SQL 的访问路径确实不同而且你看到的“IndexScan”到底用的是哪个索引。我见过太多同事拿着两份截图一份显示Seq Scan on t_user一份显示Index Scan using idx_user_active on t_user然后兴冲冲地跑来跟我说索引坏了。结果查完才发现第二个查询的 WHERE 条件多了一个字段过滤两者根本不在同一个比较维度上。对齐执行计划的标准动作是-- 先看默认计划 EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM t_user WHERE status active; -- 关闭顺序扫描强制走索引路径 SET enable_seqscan off; SET enable_bitmapscan off; EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM t_user WHERE status active; -- 恢复默认设置 SET enable_seqscan on; SET enable_bitmapscan on;这里我故意关闭了 bitmapscan因为Bitmap Index Scan和Bitmap Heap Scan的计划看起来也会带“Index”字样容易混淆视觉。关闭之后执行计划会明确显示Index Scan using xxx这才是真正的 IndexScan。对比这两份计划时除了看返回行数还要看actual time、Buffers和Rows Removed by Filter等信息。有时候返回行数一样但缓冲区差异巨大说明索引可能扫了大量无效页这虽然不直接导致“少返回”但也说明了索引的健康状况不佳。4.2 第二步验证三大概率原因逐个排除先检查索引定义SELECT pg_get_indexdef(indexrelid) FROM pg_index WHERE indexrelid idx_user_active::regclass;输出如果长这样它就是部分索引CREATE INDEX idx_user_active ON public.t_user USING btree (status) WHERE ((status)::text active::text)接着检查 SQL 的语义。把count(*)换成count(status)把EXPLAIN打开看计划。如果发现走索引的查询只统计了某一列的计数而该列有大量 NULL那问题就出在 SQL 写法上。然后验证并发快照的影响。最稳妥的方式是在同一个事务里连续执行两次同样的查询观察结果是否恒定BEGIN; SET enable_seqscan off; SELECT count(*) FROM t_user WHERE status active; SET enable_seqscan on; SELECT count(*) FROM t_user WHERE status active; COMMIT;如果同一个事务、同一个快照下强制两种计划返回的行数一致那“IndexScan 比 SeqScan 少”根本就是个伪命题之前的差异纯粹来自不同快照或不同查询条件。4.3 第三步物理校验用 amcheck 给索引做“体检”如果前面几步都排除了还剩最后一道防线用amcheck扩展对索引结构做深度校验。这个扩展是 PostgreSQL 自带的专门用来检查 B-tree 索引的逻辑一致性能够发现“不报错但结构异常”的问题。CREATE EXTENSION IF NOT EXISTS amcheck; -- 检查单个索引第二个参数为 true 时会进行更彻底的空页检测 SELECT bt_index_check(idx_user_active::regclass, true); -- 更严格的双向检查代价更高建议在维护窗口执行 SELECT bt_index_parent_check(idx_user_active::regclass, true);bt_index_check会验证索引的层级结构、项间链接、父指针等如果校验通过通常返回空结果整个过程没有任何输出一旦发现问题会直接抛出类似index ... corrupt的错误。bt_index_parent_check比前者多检查了父指针的反向一致性更全面但消耗也更大。一张 5000 万行的大表跑一次bt_index_parent_check可能耗时数分钟到十几分钟生产环境要挑低峰期。如果说得更硬核一点还可以用pageinspect扩展直接读取索引页查看某一页的统计信息CREATE EXTENSION pageinspect; SELECT * FROM bt_page_stats(idx_user_active, 1); SELECT * FROM bt_page_items(idx_user_active, 1) LIMIT 20;bt_page_stats会返回页的类型、空闲空间、存活项数等信息。如果发现一个本该是叶子页的地方出现了根页的类型值或者存活项数与堆表行数完全对不上那才是真正需要警惕的信号。4.4 第四步如果确认损坏重建索引的正确姿势确认索引损坏之后重建索引要讲方法不能在高峰期直接DROP INDEX再CREATE INDEX否则期间相关查询会全部退化成全表扫描轻则查询变慢重则把线上数据库打到内存吃紧。推荐的做法是使用并发重建REINDEX INDEX CONCURRENTLY idx_user_active;PostgreSQL 12 之后支持REINDEX CONCURRENTLY它会在不阻塞读写的前提下重建索引。12 之前的版本没有这个功能只能用替代方案新建一个同名替换索引切换后删除旧索引。-- 适合旧版本的做法 CREATE INDEX CONCURRENTLY idx_user_active_new ON t_user(status) WHERE status active; BEGIN; ALTER TABLE t_user DROP CONSTRAINT idx_user_active; ALTER TABLE t_user RENAME INDEX idx_user_active_new TO idx_user_active; COMMIT;重建完成后再用amcheck校验一遍确认索引结构OK才算真正收尾。如果损坏索引所在的表是一个超大数据量的核心业务表建议优先从备份恢复而不是只重建索引。因为索引损坏往往意味着底层存储或硬件已经出过问题只重建索引而不排查数据页可能后续还会出现类似故障。5. 常见问题速查与运维避坑经验5.1 一张速查表直接对着现象找答案我把这些年见过的典型现象和对应处理方案整理成了一张表排查时可以直接对照现象第一嫌疑验证方式正确处理走索引count(*)比全表少索引定义带 WHERE部分索引pg_get_indexdef不是故障检查查询条件count(col)比count(*)少且列存在大量 NULLNULL 语义EXPLAIN 检查null_frac改 SQL用count(*)同一事务中两次查询结果先多后少并发快照同一事务重复执行不是索引问题核对并发变更查询报corrupted page或invalid page物理损坏amcheck、pageinspectREINDEX CONCURRENTLY索引在pg_index.indisvalid false中断的并发建索引查询pg_index重建该索引索引扫描返回行数与全表扫描不同且amcheck报错真损坏bt_index_check重建索引并排查底层硬件5.2 我在运维中积累的几条硬经验第一条经验不要在生产库上随口说“索引坏了”。在没看过执行计划、没核对索引定义、没确认事务隔离级别之前任何“索引坏了”的结论都可能是错的。我在团队里定了一条规矩碰到这类问题必须先发三样东西完整 SQL、两份执行计划、索引定义内容。缺一样就不讨论“损坏”这个词。第二条经验维护窗口定期跑 amcheck 脚本。尤其是那些跑在物理机、老磁盘、有大内存压力的数据库B-tree 索引结构问题往往不是查询时立刻暴露的而是会慢慢积累。我习惯写一个每月一次的巡检脚本遍历所有数据库的所有索引依次执行bt_index_check把输出记录到监控表里。这样做的好处是就算某天真的出现“索引损坏”也有历史基线可以对比能快速判断损坏是新增的还是早就存在的。第三条经验重建索引之前先备份损坏索引对应的表空间文件。千万别觉得索引损坏了直接重建就行万一重建过程中触发了更深层的页损坏你还需要原始文件排查根因。我的习惯是先把数据库停掉做一次物理备份或者至少把涉及的表单独备份出来再开始重建。第四条经验注意数据校验和checksum选项。PostgreSQL 在initdb时默认是不开启数据页 checksum 的如果没有 checksum坏页只能靠运气才被识别。如果公司的机器可靠性一般新建实例时建议加上--data-checksums虽然有一定性能开销但能让你在出现坏块时第一时间收到错误信息而不是迷迷糊糊地少几行数据。第五条经验也是最重要的一条别拿“结果行数不同”当唯一的判断依据。很多时候IndexScan 和 SeqScan 返回的行数之所以不同只是因为统计信息过旧导致优化器在两次查询之间更改了执行计划加上并发数据的自然变化看起来像“索引出错了”。先把计划画出来把数据对齐再谈索引。我处理过的几十起案例里真正需要 REINDEX 的只有两三次剩下全是假警报。排查到这一步索引到底是不是损坏其实已经比较清楚了。最后的建议很简单碰到这类问题先深呼吸按顺序往下查——执行计划、索引定义、SQL 语义、并发快照、amcheck 结构校验一层一层剥下去绝大部分时候你会笑着发现原来是部分索引或 count(col) 在捣乱。如果这五步走完仍然有问题也别慌REINDEX CONCURRENTLY 就是你的兜底方案加上备份在手再诡异的索引故障都能稳住局面。