ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

IndexScan比SeqScan结果少?先排查这5类原因再决定重建索引 先别急着重建索引也别急着回一句“索引坏了reindex 吧”。我接到过不下十次这种求助最后真正需要重建索引的不到一成。前两天同事火急火燎跑过来给我看两条执行计划同一张订单表同一个 SQL 条件强制走 IndexScan 时返回 100 行走 SeqScan 时返回 150 行。对方的第一个判断就是索引损坏了。这个疑问在数据库交流群里也很常见问法几乎一模一样IndexScan 比 SeqScan 返回的结果更少是不是索引坏了先说结论。绝大多数情况下这个差异不是索引损坏而是你用来对比的两个结果本质上来自两个“看起来一样但实际不一样”的查询或者来自两次不同时刻的快照。这篇文章就把这类问题的排查逻辑完整拆一遍从执行计划、统计信息、MVCC 到部分索引再到真正索引损坏时的检测手段。如果你正被这个问题卡住别急着重建索引按下面的顺序走一遍90% 的坑都能绕开。1. 先理解执行计划里的两行IndexScan 与 SeqScan1.1 两者的返回行数按理说必须一致Sequential ScanSeqScan和 Index ScanIndexScan是查询规划器对同一个逻辑查询生成的两种物理执行路径。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 万行statusPAID 的行还剩 150 行但最近批量更新后还没有跑 ANALYZEplanner 拿到的分布数据还是旧版本可能估算出 IndexScan 只返回 1 行、SeqScan 返回 150 行。这时用普通 EXPLAIN 对比两个计划你就会看到 rows 一个为 1、一个为 150很容易得出“索引扫描少返回了 149 行”的结论。但真实执行不是这样。加上 ANALYZE 再看IndexScan 的 actual rows 仍然是 150因为它会沿着索引结构把所有匹配项找出来而不是按估算值来“少拿几行”。所以第一步要建立正确认知只有 EXPLAIN ANALYZEMySQL 8.0.18 是 EXPLAIN ANALYZE里的 actual rows 才有资格作为结果集大小的证据。EXPLAIN 单纯显示的 rows 只是成本模型给规划器用的参考值把它当成真实的查询结果去比较是这类误判的头号来源。2. 真正导致“IndexScan 结果更少”的 5 类常见原因2.1 统计信息过期把成本模型带偏先说最常见的一种统计信息过期。表数据被大量 UPDATE、DELETE 或批量导入之后没有做 ANALYZE / VACUUMplanner 还拿着老的分布估算成本。结果就是在 EXPLAIN 输出里IndexScan 的预估 rows 可能写的是 1、10、100而 SeqScan 的预估 rows 是 150看起来就像“索引只返回很少的行”。但你要知道索引扫描是执行器真正跑出来的物理操作它会根据索引结构找到所有符合条件的条目。除非索引本身真的缺页否则即使统计信息再旧actual rows 也不会变少。真正会变的只是计划选择、扫描行数和成本估算。解决方式很简单跑一次ANALYZE ordersMySQL 是ANALYZE TABLE orders;再重新 EXPLAIN两边预估行数就会明显靠近。如果你们数据库有定期统计信息收集任务先确认任务的执行时间点和这次表变更的时间点。很多时候问题截止到这一步就已经解决了。2.2 两个查询的谓词根本不是同一个第二类原因最容易被忽略但占比很高你对比的两个执行计划SQL 文本看着一样实际谓词或路径条件已经被改写。PostgreSQL 执行计划里有两个字段要认真区分Index Cond和Filter。Index Cond是索引能直接精确定位的条件Filter是索引扫完之后对回表行做的二次过滤。同样是SELECT * FROM orders WHERE statusPAID AND amount100;如果有复合索引(status, amount)IndexScan 的 Index Cond 可能同时包含两个条件如果只有 status 单列索引IndexScan 的 Index Cond 是statusPAIDFilter 是amount100。两条路径返回行数一样但 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;这个索引物理上只包含 statusPAID 的行。对一个同样带statusPAID谓词的查询来说走这个索引和走 SeqScan 的结果集仍然一致不会少但如果你直接去数索引里有多少行再对比整张表的行数那当然少。很多“索引损坏”的投诉其实是开发同学把“索引里存的记录数”和“表里的记录总数”混在一张报表里了。函数索引同理。比如CREATE INDEX idx_orders_lower_email ON orders ((lower(email)));走这个索引的查询条件是WHERE lower(email) ab.com它返回的是对 email 做小写后的匹配结果。你要是拿WHERE email Ab.com的 SeqScan 来对比两个结果可能不一样但这明显是比较基准错误。所以每当你发现 IndexScan 返回的行集像是“某个子集”时先看一眼索引定义PG 用pg_get_indexdefMySQL 用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 PostgreSQLamcheck 和 pg_amcheckPostgreSQL 从 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 MySQLCHECK TABLE 与重建思路MySQL InnoDB 下的常用命令是CHECK TABLE orders;这个命令会把二级索引和聚簇索引逐一比对返回OK或者具体的页号错误。如果检查发现索引损坏MyISAM 可以直接REPAIR TABLEInnoDB 通常不建议更可靠的方式是重建索引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可以用ALGORITHMINPLACE, LOCKNONE在线执行但要注意索引本身损坏到影响查询时部分 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 ... rows100而不是rows150。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 statusPAID;看和哪个计划一致打印出索引完整定义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 跑 amcheckMySQL 跑 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 statusPAID AND refund_flag1不走索引的是WHERE statusPAID。很显然是两个语义不同的查询结果差 7 行完全正常。拿到完整 SQL 文本之后这个工单 5 分钟就关了。另一个案例是 MySQL 5.7 环境业务用FORCE INDEX (idx_order_status)之后返回行数和普通 count 不一致。查到最后FORCE INDEX 导致优化器忽略了自己会用的覆盖索引被迫回表而回表读到的行由于并发 UPDATE 版本不同两次查询所在事务快照不同于是行数对不上。强制索引本身不改变结果集但会改变执行时机和锁行为在长事务和高并发下更容易踩中快照差异。第三个案例是 PostgreSQL 里通过 EXPLAIN ANALYZE 看到一个节点 actual rows0而业务明确说有数据。最后发现查询访问的不是同一个 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), ALGORITHMINPLACE, LOCKNONE;或者直接OPTIMIZE TABLE orders;。注意无论哪种方式重建前先确认磁盘空间够索引文件等于原索引大小的数倍空间都不罕见。还有一条老经验重建完记得重新 ANALYZE。PG 的 REINDEX 不会更新统计信息MySQL 的 OPTIMIZE TABLE 通常会有统计信息更新但不要依赖。跑一遍 ANALYZE让优化器重新拿到准确的 cardinality否则重建完索引不一定更准。6.2 让这种误判少发生的日常习惯与其每次靠人工排查不如从流程上减少误判。我自己的习惯是日常维护里固定 ANALYZE 任务尤其在大批量数据变更之后PostgreSQL 环境定期跑 VACUUM 和 pg_amcheckMySQL 环境定期跑 CHECK TABLE 和 pt-table-checksum任何关于执行计划的问题要求提交方直接附上 EXPLAIN ANALYZE 输出而不是 EXPLAIN 输出遇到“结果集少”的工单先让业务方提供完整 SQL、表 DDL 和索引 DDL没有任何上下文就开始 reindex多半是在撞运气。最后再分享一个个人体会。这种“IndexScan 比 SeqScan 少”的疑问我在过去几年里处理过很多次最终真正需要重建索引的不到一成。大多数是统计信息过期、比较基准错误、快照不一致这三个原因叠加出来的幻觉。索引损坏是一个需要证据支撑的重型结论别让一个没加 ANALYZE 的执行计划就把它认定下来。先把执行计划的估算值和实际值分开在同一事务里复现两条访问路径再决定要不要动手重建。如果你遇到这个标题里的问题建议把这条排查流程完整走一遍。十个工单里九个会在第三步之前结束剩下的那一个amcheck 会给你明确的答案。
返回列表