news 2026/9/30 8:13:23

IndexScan比SeqScan结果少?先排查这5类原因再决定重建索引

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
IndexScan比SeqScan结果少?先排查这5类原因再决定重建索引

先别急着重建索引,也别急着回一句“索引坏了,reindex 吧”。我接到过不下十次这种求助,最后真正需要重建索引的不到一成。

前两天同事火急火燎跑过来,给我看两条执行计划:同一张订单表,同一个 SQL 条件,强制走 IndexScan 时返回 100 行,走 SeqScan 时返回 150 行。对方的第一个判断就是:索引损坏了。这个疑问在数据库交流群里也很常见,问法几乎一模一样:IndexScan 比 SeqScan 返回的结果更少,是不是索引坏了?

先说结论。绝大多数情况下,这个差异不是索引损坏,而是你用来对比的两个结果,本质上来自两个“看起来一样但实际不一样”的查询,或者来自两次不同时刻的快照。这篇文章就把这类问题的排查逻辑完整拆一遍,从执行计划、统计信息、MVCC 到部分索引,再到真正索引损坏时的检测手段。如果你正被这个问题卡住,别急着重建索引,按下面的顺序走一遍,90% 的坑都能绕开。

1. 先理解执行计划里的两行:IndexScan 与 SeqScan

1.1 两者的返回行数按理说必须一致

Sequential Scan(SeqScan)和 Index Scan(IndexScan)是查询规划器对同一个逻辑查询生成的两种物理执行路径。SeqScan 把整张表的堆页从头到尾读一遍,逐行判断过滤条件;IndexScan 先通过 B-tree 索引找到符合条件的 TID / 主键,再回表取出数据行。两条路径的代价模型不同,但它们在逻辑上执行的是同一个 SQL 谓词,所以只要查询文本一样、事务快照一样、数据没有在两次执行之间变化,两个计划返回的行集合必须完全一致。

这就像你去图书馆找一本书:一种方式是从第一排书架挨个看过去,另一种方式是先查图书目录找到索书号,再去对应书架取书。只要找的是同一本书,两条路线最终拿到手的书不会不一样。索引是目录,不是存放书籍的另一个版本。

有一个例外需要先记住:如果索引是部分索引(partial index),或者查询走的是表达式索引,那索引里存放的行集合本来就不是全表行集合。这个后面专门讲,普通 B-tree 索引并不存在“只索引了部分行”的情况。所以一上来就怀疑索引损坏,从理论顺序上讲是站不住脚的。

1.2 EXPLAIN 里的 rows 是估算,不是实际值

大多数“IndexScan 返回更少”的误会,都出在把 EXPLAIN 输出的 rows 当成了真实返回行数。EXPLAIN 输出的是规划器根据统计信息估算出来的预计行数,不是执行后的真实行数。PostgreSQL 基于 pg_class.reltuples、pg_statistic 里的直方图和最常见值来估算;MySQL 基于索引基数、数据页估算。统计信息一旦过期,估算可以错得离谱。

举个例子。一张订单表有 1000 万行,status='PAID' 的行还剩 150 行,但最近批量更新后还没有跑 ANALYZE,planner 拿到的分布数据还是旧版本,可能估算出 IndexScan 只返回 1 行、SeqScan 返回 150 行。这时用普通 EXPLAIN 对比两个计划,你就会看到 rows 一个为 1、一个为 150,很容易得出“索引扫描少返回了 149 行”的结论。

但真实执行不是这样。加上 ANALYZE 再看,IndexScan 的 actual rows 仍然是 150,因为它会沿着索引结构把所有匹配项找出来,而不是按估算值来“少拿几行”。所以第一步要建立正确认知:只有 EXPLAIN ANALYZE(MySQL 8.0.18+ 是 EXPLAIN ANALYZE)里的 actual rows 才有资格作为结果集大小的证据。EXPLAIN 单纯显示的 rows 只是成本模型给规划器用的参考值,把它当成真实的查询结果去比较,是这类误判的头号来源。

2. 真正导致“IndexScan 结果更少”的 5 类常见原因

2.1 统计信息过期把成本模型带偏

先说最常见的一种:统计信息过期。表数据被大量 UPDATE、DELETE 或批量导入之后没有做 ANALYZE / VACUUM,planner 还拿着老的分布估算成本。结果就是在 EXPLAIN 输出里,IndexScan 的预估 rows 可能写的是 1、10、100,而 SeqScan 的预估 rows 是 150,看起来就像“索引只返回很少的行”。

但你要知道,索引扫描是执行器真正跑出来的物理操作,它会根据索引结构找到所有符合条件的条目。除非索引本身真的缺页,否则即使统计信息再旧,actual rows 也不会变少。真正会变的只是计划选择、扫描行数和成本估算。

解决方式很简单:跑一次ANALYZE orders(MySQL 是ANALYZE TABLE orders;),再重新 EXPLAIN,两边预估行数就会明显靠近。如果你们数据库有定期统计信息收集任务,先确认任务的执行时间点和这次表变更的时间点。很多时候,问题截止到这一步就已经解决了。

2.2 两个查询的谓词根本不是同一个

第二类原因最容易被忽略,但占比很高:你对比的两个执行计划,SQL 文本看着一样,实际谓词或路径条件已经被改写。

PostgreSQL 执行计划里有两个字段要认真区分:Index Cond和Filter。Index Cond是索引能直接精确定位的条件,Filter是索引扫完之后对回表行做的二次过滤。同样是SELECT * FROM orders WHERE status='PAID' AND amount>100;,如果有复合索引(status, amount),IndexScan 的 Index Cond 可能同时包含两个条件;如果只有 status 单列索引,IndexScan 的 Index Cond 是status='PAID',Filter 是amount>100。两条路径返回行数一样,但 EXPLAIN 输出看起来完全不一样。

还有一种情况更隐蔽:MySQL 的索引条件下推(ICP)和分区裁剪会把部分谓词下推到存储引擎层,或者直接消除某些条件。你拿优化前的 SQL 和优化后的执行计划对照,容易觉得“索引少查了一些数据”。遇到这种情况,先恢复执行计划的完整信息,把每层 Node 的 condition 拉出来逐条核对,而不是只看总行数。

2.3 MVCC 快照和隔离级别的干扰

MVCC 是另一个高频干扰源。你在窗口 A 执行 IndexScan 查询返回 150 行,另一个人在窗口 B 执行 DELETE 删掉 50 行并提交,然后你在窗口 A 再次执行查询返回 100 行。两次结果不同,和索引没有半点关系,只和数据版本有关。

不同隔离级别下表现还不一样。PostgreSQL 的 READ COMMITTED 是每个语句获取一个新快照,REPEATABLE READ / SERIALIZABLE 是事务内第一次查询时固定快照;MySQL 的 REPEATABLE READ 也会在事务第一次读时建立一致性视图。如果你开了两个会话,一个跑在自动提交下,一个在长事务里,再赶上一个并发的删除或更新提交,结果很容易出现“走索引少、走顺序扫多”的错觉。

正确做法:把两个查询放在同一个事务、同一个会话、同一个隔离级别里执行,并且在事务结束前不要让其他会话修改数据。这一点做不好,后面所有排查都是白费。

2.4 部分索引和函数索引天然只装了一部分行

如果索引定义里带了 WHERE 条件,它就是一个部分索引(PostgreSQL partial index)。比如:

CREATE INDEX idx_orders_paid ON orders(id) WHERE status = 'PAID';

这个索引物理上只包含 status='PAID' 的行。对一个同样带status='PAID'谓词的查询来说,走这个索引和走 SeqScan 的结果集仍然一致,不会少;但如果你直接去数索引里有多少行,再对比整张表的行数,那当然少。很多“索引损坏”的投诉,其实是开发同学把“索引里存的记录数”和“表里的记录总数”混在一张报表里了。

函数索引同理。比如CREATE INDEX idx_orders_lower_email ON orders ((lower(email)));,走这个索引的查询条件是WHERE lower(email) = 'a@b.com',它返回的是对 email 做小写后的匹配结果。你要是拿WHERE email = 'A@b.com'的 SeqScan 来对比,两个结果可能不一样,但这明显是比较基准错误。

所以每当你发现 IndexScan 返回的行集像是“某个子集”时,先看一眼索引定义(PG 用pg_get_indexdef,MySQL 用SHOW INDEX FROM),确认索引定义里有没有 WHERE、有没有表达式,再决定要不要紧张。

2.5 仅索引扫描里的可见性映射,最容易产生误读

PostgreSQL 有一种特殊的 Index Only Scan,它不需要回表,直接从索引元组返回数据。用到的关键是 visibility map:当一个数据页的所有行对当前所有事务都可见时,页会被标记为 all-visible,扫描器看到这个标记就认为索引里的版本是可信的,不用回表检查。

这里有个常见误读:如果 visibility map 还没更新,Index Only Scan 会回表多做一些 Heap Fetch,性能变差,但结果集不会变少。索引只负责定位“可能符合条件的行”,每一行是否对当前事务可见,最终由堆表里的版本链和事务状态决定。VM 标记再老、再不准,最多让你多回表,不可能让 where 条件的结果少一行。

我碰到过一个案例,同事用 EXPLAIN 看到Heap Fetches: 0,觉得很得意,认为索引扫得很干净;但业务反馈同一张表 count 结果不稳定。实际原因是两个查询在不同快照下跑的,和 VM 无关。所以遇到“Index Only Scan 少了”的说法,优先看快照,再看谓词,最后才考虑检测工具。

3. 什么时候才怀疑真损坏:症状和检测工具

3.1 索引损坏的真实症状

真正索引损坏时,通常不是“静默少返回几行”。B-tree 索引损坏更常见的表现是:查询直接报错,比如could not read block 123 in file "base/xxx/yyy": read only 0 of 8192 bytes;唯一索引突然出现重复值;amcheck 报告上下级指针不一致;或者索引扫描返回的行里混进了不属于该条件的垃圾数据。

如果你的应用日志里根本没有 ERROR,执行计划也能正常输出,数据页读得出来,那“静默少几行”的概率比逻辑条件写错的概率低很多。数据库在正常运行时,索引页损坏往往会触发 Checksum / WAL 校验,而不是安静地丢掉一个索引项。当然,硬件故障、文件系统 bug、非正常断电确实可能导致这种静默损坏,所以它不在排除范围,只是应该排在做完所有逻辑排查之后。

3.2 PostgreSQL:amcheck 和 pg_amcheck

PostgreSQL 从 10 开始提供了官方校验工具 amcheck。用法很简单:

CREATE EXTENSION amcheck; SELECT bt_index_check('idx_orders_status'::regclass);

bt_index_check校验索引内部结构是否一致,持有 SHARE UPDATE EXCLUSIVE 锁,基本不阻塞业务。bt_index_parent_check会更彻底地检查上下层父子关系,但锁更重,一般放在维护窗口跑。如果是命令行环境,可以直接用pg_amcheck工具,它可以批量检查多个表/索引,还能在线运行。

pg_amcheck --all

如果 amcheck 没报错,说明索引的 B-tree 结构是干净的。注意:amcheck 默认不校验索引里的每个 TID 都对应堆里的可见行,它主要校验物理结构一致性。要衡量“索引项与堆行是否一一对应”,可以用bt_index_check(..., heapallindexed => true),这会逐项比对堆,代价高但更严。线上如果数据量大,先检查索引有没有物理结构问题,再决定要不要开 heapallindexed。

3.3 MySQL:CHECK TABLE 与重建思路

MySQL InnoDB 下的常用命令是:

CHECK TABLE orders;

这个命令会把二级索引和聚簇索引逐一比对,返回OK或者具体的页号错误。如果检查发现索引损坏,MyISAM 可以直接REPAIR TABLE,InnoDB 通常不建议,更可靠的方式是重建索引:

ALTER TABLE orders DROP INDEX idx_orders_status, ADD INDEX idx_orders_status (status);

或者整表重建:

OPTIMIZE TABLE orders;

在 MySQL 8.0 中,ALTER TABLE ... DROP INDEX / ADD INDEX可以用ALGORITHM=INPLACE, LOCK=NONE在线执行,但要注意索引本身损坏到影响查询时,部分 DDL 可能无法顺利进行。如果聚簇索引或系统表空间损坏,最好走备份恢复,而不是盲目 REPAIR。

另外不要忽略主从环境。用pt-table-checksum定期对比主库和从库的 checksum,很多“索引坏”最终查出来是从库数据不一致。这个工具不是专门修索引的,但在排查“结果集不一致”时非常好用。

4. 完整的排查流程(照这个顺序做)

4.1 第一步:把比较条件锁死

无论问题描述得多吓人,第一步都是消除变量。明确以下几件事:

  • 执行 SQL 的文本是否完全一样,只换执行计划?
  • 两个查询是否来自同一个会话、同一个事务、同一个隔离级别?
  • 两次执行之间有没有并发写入事务提交?
  • 表结构和索引定义是否在两次执行之间发生过变更?

如果以上任何一项不能确认,先按“同一事务、同一会话、同一 SQL”重新跑一遍。最简单的方式是开一个事务,把两个执行计划包在里面:

BEGIN ISOLATION LEVEL REPEATABLE READ; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'PAID'; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'PAID'; ROLLBACK;

第二个 EXPLAIN 可以先用SET enable_seqscan = off;强制走索引,跑完再开回来。注意这种强制开关只能用来诊断索引能不能用,不能用来证明结果是否正确。

4.2 第二步:用 EXPLAIN ANALYZE 看 actual rows

有 actual rows 才有发言权。PostgreSQL 里这样跑:

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM orders WHERE status = 'PAID';

看输出里的actual ... rows=100,而不是rows=150。MySQL 8.0.18 以上也可以用:

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'PAID';

MySQL 的EXPLAIN ANALYZE会真正执行语句并输出实际时间、实际行数。这一步下来,大多数“IndexScan 少”的误会当场就消失了。如果 actual rows 真的不一样,再看下一步。

4.3 第三步:核对 count(*)、索引定义与谓词

如果两个计划的 actual rows 确实不同,不要慌,先做三件事:

  • 在同一事务里执行SELECT count(*) FROM orders WHERE status='PAID';,看和哪个计划一致;
  • 打印出索引完整定义:PG 用SELECT pg_get_indexdef('idx_orders_status'::regclass);,MySQL 用SHOW CREATE TABLE orders;;
  • 回到 EXPLAIN ANALYZE 文本里,把 Index Cond、Filter、Rows Removed by Filter 全部抄出来。

这一步能揪出大部分 partial index、函数索引、隐式类型转换、谓词改写问题。确认完这些,要么找出“少”的逻辑原因,要么确认没有逻辑原因。

4.4 第四步:最后才做损坏检测

逻辑排查全部做完,还是怀疑索引物理损坏,再上工具。PostgreSQL 跑 amcheck,MySQL 跑 CHECK TABLE。如果工具没报错,基本可以给业务方一个明确结论:索引没坏,是统计信息、快照或谓词的问题。

不要一开始就跑REINDEX/REPAIR。一来重建索引会消耗大量 IO,锁表时间可能很长;二来如果真正的原因是统计信息过期,重建索引完全治标不治本,过两天问题又出来。

5. 快速判断表与常见问题

5.1 五种场景的一页速查

对比现象最大嫌疑验证方法是否需要重建索引
EXPLAIN rows 少于实际结果统计信息过期EXPLAIN ANALYZE 看 actual rows,执行 ANALYZE不需要
两个计划实际返回行数一致,只有预估不一致成本模型估算只信 actual rows不需要
查询条件里带了额外谓词或隐式条件比较基准错误对比 Index Cond / Filter不需要
两次执行不在同一快照MVCC / 隔离级别同一事务内重新对比不需要
索引只包含部分行(partial/表达式)索引定义特殊查看索引 DDL不需要
查询报错或 amcheck 报错真实损坏amcheck / CHECK TABLE需要重建或恢复

这张表基本覆盖了 99% 的线上场景。你会发现每一行都不指向“索引损坏”,直到最后一行。

5.2 常见误判案例实录

有个案例让我印象很深。开发反馈说某订单表走索引返回 98 行,不走索引返回 105 行,怀疑索引坏了。我把两条 SQL 拉出来发现:走索引的 SQL 是WHERE status='PAID' AND refund_flag=1,不走索引的是WHERE status='PAID'。很显然是两个语义不同的查询,结果差 7 行完全正常。拿到完整 SQL 文本之后,这个工单 5 分钟就关了。

另一个案例是 MySQL 5.7 环境,业务用FORCE INDEX (idx_order_status)之后返回行数和普通 count 不一致。查到最后,FORCE INDEX 导致优化器忽略了自己会用的覆盖索引,被迫回表;而回表读到的行由于并发 UPDATE 版本不同,两次查询所在事务快照不同,于是行数对不上。强制索引本身不改变结果集,但会改变执行时机和锁行为,在长事务和高并发下更容易踩中快照差异。

第三个案例是 PostgreSQL 里通过 EXPLAIN ANALYZE 看到一个节点 actual rows=0,而业务明确说有数据。最后发现查询访问的不是同一个 schema,或者 search_path 指向了同名的旧表。这类问题排查起来比索引损坏更绕,所以遇到“结果集少”先确认你查的到底是哪张表。

6. 关于重建索引和日常维护的几条经验

6.1 重建索引的正确姿势

如果真的检测到索引损坏,或者你出于稳妥决定重建,优先用在线方式。PostgreSQL 里:

REINDEX INDEX CONCURRENTLY idx_orders_status;

CONCURRENTLY选项不会长时间阻塞表读写,但会消耗额外系统资源和磁盘空间,适合维护窗口外紧急处理。MySQL 8.0 里如果要在线重建,通常用:

ALTER TABLE orders DROP INDEX idx_orders_status, ADD INDEX idx_orders_status (status), ALGORITHM=INPLACE, LOCK=NONE;

或者直接OPTIMIZE TABLE orders;。注意无论哪种方式,重建前先确认磁盘空间够,索引文件等于原索引大小的数倍空间都不罕见。

还有一条老经验:重建完记得重新 ANALYZE。PG 的 REINDEX 不会更新统计信息,MySQL 的 OPTIMIZE TABLE 通常会有统计信息更新,但不要依赖。跑一遍 ANALYZE,让优化器重新拿到准确的 cardinality,否则重建完索引不一定更准。

6.2 让这种误判少发生的日常习惯

与其每次靠人工排查,不如从流程上减少误判。我自己的习惯是:

  • 日常维护里固定 ANALYZE 任务,尤其在大批量数据变更之后;
  • PostgreSQL 环境定期跑 VACUUM 和 pg_amcheck,MySQL 环境定期跑 CHECK TABLE 和 pt-table-checksum;
  • 任何关于执行计划的问题,要求提交方直接附上 EXPLAIN ANALYZE 输出,而不是 EXPLAIN 输出;
  • 遇到“结果集少”的工单,先让业务方提供完整 SQL、表 DDL 和索引 DDL,没有任何上下文就开始 reindex,多半是在撞运气。

最后再分享一个个人体会。这种“IndexScan 比 SeqScan 少”的疑问,我在过去几年里处理过很多次,最终真正需要重建索引的不到一成。大多数是统计信息过期、比较基准错误、快照不一致这三个原因叠加出来的幻觉。索引损坏是一个需要证据支撑的重型结论,别让一个没加 ANALYZE 的执行计划就把它认定下来。先把执行计划的估算值和实际值分开,在同一事务里复现两条访问路径,再决定要不要动手重建。

如果你遇到这个标题里的问题,建议把这条排查流程完整走一遍。十个工单里九个会在第三步之前结束,剩下的那一个,amcheck 会给你明确的答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 8:13:14

Hindsight实战:Agent经验沉淀与记忆分层架构设计

1. 从“hindsight”这个词说起:为什么它值得单独拿出来聊 第一次看到“hindsight”作为项目标题,我脑子里蹦出来的不是某个具体工具,而是一个很朴素的场景:你在跟一个 AI Agent 协作,它前面明明已经确认过“这个项目用…

作者头像 李华
网站建设 2026/9/30 8:13:07

跨模态开发实践:用DeepSeek实现视频内容自动生成技术文档

简介:这份PDF文档面向希望将DeepSeek应用于跨模态开发的开发者与研究者,聚焦视频内容自动生成技术文档这一具体场景,帮助读者打通从文本、图像到视频的跨模态处理链路。文档共37页,以1个PDF文件交付,压缩包约2.07MB&am…

作者头像 李华
网站建设 2026/9/30 8:12:52

4步解除群晖NAS硬盘兼容性限制:Synology_HDD_db 完整上手指南

4步解除群晖NAS硬盘兼容性限制:Synology_HDD_db 完整上手指南 【免费下载链接】Synology_HDD_db Add your HDD, SSD and NVMe drives to your Synologys compatible drive database and a lot more 项目地址: https://gitcode.com/GitHub_Trending/sy/Synology_HD…

作者头像 李华
网站建设 2026/9/30 8:12:14

港股增发摊薄与30%强制要约:德祥引入瑞凯的资本运作拆解

最近德祥地产再披露增发方案的公告,我把几百字的公告翻来覆去看了几遍。里面最扎眼的是战略投资者瑞凯集团的持股比例。公告明确预计,这轮增发完成后,瑞凯的持股要走到30.9%。按港股老司机的说法,这个比例一过去,事情的…

作者头像 李华
网站建设 2026/9/30 8:12:01

高可用架构设计实战:从MySQL到Kubernetes的容灾与故障恢复

凌晨两点半,手机在床头柜上疯狂震动。值班同事的声音有点发虚:“主库挂了,从库没顶上,现在只读页面全在报错。”我一边套外套一边问:“半同步复制配了没?”“配了。”“自动切换脚本呢?”“切了…

作者头像 李华
网站建设 2026/9/30 8:11:52

进程状态模型:三态、五态、七态的实战解析与诊断

1. 进程状态模型:不是教科书里的死概念,而是操作系统调度的“实时心跳图”你有没有遇到过这样的场景:打开任务管理器,看到某个程序明明没窗口、没响应,却死死占着20%的CPU和1.2GB内存;或者在Linux终端敲ps …

作者头像 李华