☰
PostgreSQL索引扫描比全表扫描少?排查索引损坏的完整指南
2026/9/30 8:13:24 网站建设 项目流程

前一阵有个朋友给我发来几张截图,他们生产库上同一个查询,强制走 IndexScan 返回 642 万行,改成 SeqScan 却返回 821 万行,差了快两百万行。群里有人抛出一句“索引坏了”,甚至有人建议赶紧停应用做全量索引重建。我赶紧按住他:别冲动,IndexScan 比 SeqScan 返回结果更少,这个现象我见过太多次,真正是物理损坏的比例极低。

过去三年我在不同项目里排查过至少十次类似的“索引损坏”事件,最后真正需要重建索引的只有一次。绝大多数情况下,问题出在索引定义、SQL 语义、MVCC 快照这三类因素上。这篇文章就把这个坑从头到尾拆开,先说清楚为什么你不能看到结果不一致就怪索引,再手把手带你验证,最后告诉你万一真是索引损坏该怎么处理。

1. 先说结论:这个锅大概率不是索引的

1.1 为什么“IndexScan 返回少”会让人产生恐慌

IndexScan 和 SeqScan 是两种不同的数据访问路径。SeqScan 直接扫描整个堆表,一页页往下读,遇到一行判断一行,逻辑简单直接。IndexScan 则先通过索引结构定位到符合条件的索引项,再回表取出对应的数据行,或者在满足条件的场景下直接从索引里拿数据。

从直觉上看,索引的结构更“挑食”,它只包含符合特定条件的键值。如果一张表有 2000 万行,索引项却只有 1500 万个,那么走索引扫描出来的结果当然可能比全表扫描少。这一点恰恰被很多人忽略了:索引本身就是一个“部分数据集”。

这里的核心问题是:这个索引是不是部分索引(Partial Index)?索引建的时候有没有带WHERE条件?索引列上是不是大量存在 NULL?SQL 里写的到底是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 万行的状态是active:

CREATE 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(*) 只统计 status='active' 的行,返回 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列上就是 NULL,count(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 就是你的兜底方案,加上备份在手,再诡异的索引故障都能稳住局面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询