ARTICLE DETAIL

资讯详情

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

IndexScan比SeqScan返回少?先别重建索引,可能是快照差异惹的祸

IndexScan比SeqScan返回少?先别重建索引,可能是快照差异惹的祸 接到“索引坏了”的告警时我第一个动作永远是按住想要REINDEX的手。IndexScan比SeqScan返回的结果更少在 PostgreSQL 运维群里几乎等同于“索引损坏”的默认开场白但上礼拜我处理的那起夜间告警恰好就是这个结论最典型的反面教材业务同事对比了同一张订单表的两种执行计划走索引返回 115230 行走全表扫描返回 132804 行差值整整齐齐的 17574 行。这个数字本身就在暗示问题大概率不是索引坏了而是两次查询压根没在同一个时间点上跑。我把整个排查过程、底层原理和重建索引的完整套路整理在这篇里。如果你也遇到过“IndexScan 比 SeqScan 结果少”或者正准备对一个看起来“可疑”的索引动手重建先花十分钟看完这篇大概率能帮你省掉一次本不必要的凌晨变更。1. 接到“索引坏了”的告警先别急着重建1.1 那个让我差点误判的告警事情发生在周四晚上十一点半业务线值班同事微信语音打过来语气很急“订单表索引坏了走索引查 pending 状态的订单只返回 115230 行全表扫描能返回 132804 行差了 17574 行。”背后的事实是他们当天在压测一个新报表查询EXPLAIN里出现了两种执行计划。一种是Index Scan using idx_orders_status on orders另一种是Seq Scan on orders。前者行数少后者行数多于是“索引损坏”四个字立刻被写进了值班群。我当时没直接下结论只让他们把三样东西发我原始 SQL、两次查询的执行计划文本、两次查询的时间戳和会话 ID。这三点信息看起来不起眼却基本决定了下判断的方向。1.2 为什么 17574 这个差值“太整齐”了索引物理损坏通常有两种表现一种是直接报错比如“invalid page in block 123 of relation base/xxx”这是索引文件层面的物理坏页另一种是索引条目丢失某个 B-tree 叶子页中缺失了一部分 TID查询走索引时就“看不见”那几个行版本。但如果丢失的是物理页里的条目缺失行数通常和页面大小、数据分布相关会产生类似 8192 字节对应的行数、或某一连续块范围的规模而不是一个看起来像业务批次大小的数字。17574 这个数让我第一反应想到的是某个批处理任务一次插入的行数。如果两个查询之间正好夹了一笔并发插入事务那么晚跑的那个计划看到更多行是完全正常的规律没有任何索引参与也必然如此。换句话说这个差值更像“时间差”而不是“页损坏”。真正的索引损坏更常见的信号是同一事务、同一查询条件、两个计划返回的行数不一致甚至直接抛错。而不是一个整整齐齐的增量。1.3 重建索引之前的风险清单在拿到可复现证据之前我拒绝直接REINDEX不是拖延而是这笔操作在 PostgreSQL 里的成本远比很多人想象得高。普通REINDEX INDEX会获取ACCESS EXCLUSIVE锁意味着整个表在重建期间几乎不可读写对线上订单表来说等于直接停服几分钟到几十分钟。REINDEX CONCURRENTLY从 PostgreSQL 12 开始可用允许重建期间继续读写但它需要等待所有可能用到该索引的事务结束还要额外消耗临时文件空间并且一旦中断会导致索引变成INVALID状态之后读不了也不能直接重建通常要DROP INDEX CONCURRENTLY后再重新CREATE INDEX CONCURRENTLY。如果问题本质是“比较口径不对”重建索引等于白忙活还可能掩盖了真正的业务误解。所以我的原则是只有用同一快照下的公平对比证明了索引确实丢行并且amcheck也给出结构异常证据之后才允许进入重建流程。2. IndexScan 和 SeqScan 到底差在哪儿从底层机制看结果集差异2.1 两张图读懂两种扫描的访问路径先快速过一遍概念方便后面所有讨论有共同语言。PostgreSQL 的表是堆表结构行数据以无序方式存在heap文件里索引是一个独立的 B-tree 结构每个条目存的是“索引键值 指向堆行的 TID文件块号 行偏移”。Seq Scan从堆表第一个页面开始逐页读到最后每读一行就用当前事务的快照判断这一行“能不能见”。Index Scan先从索引根节点一路走到叶子页找到符合条件的候选 TID再拿着 TID 回堆表读实际行同样做可见性判断。Index Only Scan是个特殊变体当索引本身包含查询所需全部列并且对应堆页面在可见性映射Visibility Map中被标记为“全页可见”时可以完全跳过回表步骤。这里最核心的结论是索引并不是数据的“复制品”它是一张 TID 导航表最终的数据和可见性判断永远发生在堆表这一层。2.2 索引缺失如何导致“少行”既然最终可见性判断在堆表上做那索引对行数的影响就只剩下“候选集的大小”。假设表里明明有 132804 行满足条件但某个索引叶子页因为某种原因丢失了其中 17574 个 TID 条目那么Index Scan的候选集就只有 115230 个 TID它根本不会去堆表读那 17574 行因为索引层已经“看不见”它们。这就是物理损坏导致“少行”的机制。反过来Seq Scan不依赖任何索引直接从堆表全量过一遍所以总能如实反应堆表里实际可见的行数。这也是为什么“IndexScan 比 SeqScan 返回更少”会被默认当作索引损坏的根本原因理论上同一张表、同一个用户查询、同一时刻两个计划返回的行集必须完全一致。一旦不一致索引就脱不了干系。2.3 同一快照下两个计划必须返回相同行集注意我说的是“同一时刻”和“同一快照”。在 PostgreSQL 里一个查询看到的数据版本由事务快照决定在READ COMMITTED隔离级别下每个语句开始时都会取一个新快照所以同一个事务里前后两条SELECT之间如果别的会话提交了新数据后一条语句就能看到更多行。在REPEATABLE READ和SERIALIZABLE级别下事务在第一次读取数据时固定快照直到事务结束后面的所有语句看到的都是同一个一致性的数据版本。这个特性直接决定了我们怎么验证“索引有没有坏”。正确姿势是把两个SET LOCAL塞进同一个REPEATABLE READ事务里让Index Scan和Seq Scan在同一份快照下 PK。只要二者结果一致索引结构就先洗清了嫌疑。我曾经用这个方式挡住了很多次不必要的重建下面是一段可以直接复制的验证 SQLBEGIN ISOLATION LEVEL REPEATABLE READ; -- 强制走索引 SET LOCAL enable_seqscan off; SET LOCAL enable_bitmapscan off; EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE status pending; -- 强制顺序扫描 SET LOCAL enable_seqscan on; SET LOCAL enable_indexscan off; SET LOCAL enable_bitmapscan off; EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE status pending; COMMIT;在同一个事务里REPEATABLE READ保证了两次EXPLAIN ANALYZE看到的快照完全相同。如果两次执行计划返回的count一致说明索引在可见性层面没有丢行之前看到的不一致大概率来自快照时间差如果两次就是不一致才值得继续查结构问题。2.4 容易绕晕人的特殊变体Index Only Scan 和可见性映射Index Only Scan是最容易误导人的执行计划节点。它的逻辑不是“只看索引”而是“当堆页面被标记为全页可见时跳过回表”。如果可见性映射文件损坏或者因为某种原因把一个应该有不可见行的页面错误标记为“全可见”Index Only Scan就可能漏掉需要过滤的元组导致返回结果少于Seq Scan。这类问题表面上也表现为“走索引的结果比全表少”但真正坏的文件不是 B-tree 索引本身而是和堆表配套的vm文件。验证时如果看到执行计划里是Index Only Scan而非Index Scan可以先把可见性映射作为高优先级怀疑对象通常重建索引并不能修复vm需要VACUUM全表或重建整表来解决。我把几种扫描节点的差异整理成了一张表方便对照执行计划节点读取对象是否回表可见性判断位置损坏后的典型表现Seq Scan堆表全页不需要堆表行版本一般不会少结果性能下降为主Index Scan索引候选 TID 回表是堆表行版本索引丢条目时结果偏少甚至报错Index Only Scan索引页面为主需要时回表依赖可见性映射vm 损坏时可能跳过不可见行导致结果偏少Bitmap Index Scan索引生成位图后再读堆部分回表堆表行版本索引或位图异常时同样可能读不到部分行3. 完整排查链路把“假损坏”一步步戳破3.1 第一步把对比口径统一到 count(*)值班同事最早发我的那条 SQL 实际上带了一个LIMIT 5000两个执行计划都截断到了 5000 行。这个口径明显不对如果业务上只关心前 5000 行那“走索引返回多少行”和“全表扫描返回多少行”根本没有可比性。我让他们把语句去掉LIMIT、去掉多余ORDER BY改成纯SELECT count(*) FROM orders WHERE status pending。count(*)是最不挑计划的统计手段它不会因为访问路径不同而提前停止扫描所有候选行都必须被依次看过才知道总数。任何其他形式的对比只要带了LIMIT、OFFSET、去重或分组都会引入新的变量。这一步虽然简单但能过滤掉一大部分“假警”。我见过太多因LIMIT和排序语义不同导致两种计划返回行数不一致的误会。3.2 第二步用可重复读事务固化快照让两个计划同台 PK统一口径之后下一步就是验证“同一快照下是否真的不一致”。我把第 2 章那段 SQL 发给了值班同事。他们跑完之后回了一个关键结果同一事务里Index Scan的count是 115230Seq Scan的count也是 115230完全一致。这意味着拿到同一份数据快照索引扫描并没有漏掉任何一条可见行。索引的嫌疑从这一刻起基本排除。顺带解释一下为什么我坚持用REPEATABLE READ而不是默认的READ COMMITTED在READ COMMITTED下同一个事务里的两条语句各自取新快照如果表在持续写入两次count完全有可能差出几千行而这个差异跟索引没有一毛钱关系。只有把快照固定住才能让两个计划做“同题作文”。3.3 第三步检查索引定义和优化器条件差异同快照一致性验证通过后仍不能直接断定“索引没坏”。我还要确认两个执行计划实际过滤的逻辑完全等价。打开两份EXPLAIN文本重点看三件事Index Cond和Filter是否对应同一组谓词。比如某个计划里Index Cond: (status pending)另一个计划里Filter: (status pending)逻辑相同但如果出现Index Cond: (status pending)配Filter: (created_at ...)说明两个计划其实在做不同的查询结果不同纯属正常。是否走了部分索引。\d orders或者查pg_index如果索引定义里带着WHERE status pending那索引天生的职责就是只装部分数据拿它跟全表扫描比总量等于拿“男科门诊人数”去跟“全医院门诊人数”比当然少。是否走了表达式索引。表达式索引装的是表达式计算结果如果查询谓词里对同一列做了类型转换或函数处理可能导致两个计划的匹配条件不完全相同。我当时查了索引定义idx_orders_status就是一个最简单的单列普通索引没有WHERE没有表达式这块干净。3.4 第四步用统计视图佐证“插入批次”的猜测既然索引层面没有毛病我转而盯上了那个 17574。我去查了pg_stat_user_tablesSELECT relname, n_live_tup, n_dead_tup, n_tup_ins, n_tup_del, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE relname orders;n_tup_ins是表创建以来的累计插入行数。如果业务侧有一条定时任务每十分钟往订单表灌一批 17574 行那么两个查询只要相差一个批次的提交时间结果就会恰好相差 17574。我让他们导出了那台压测服务器上两个查询之间的会话事件时间线果然两次对比中间夹了 1 分 30 秒而在这段时间里有一个批量导入任务刚好提交了 17574 行。182 这个数字也对得上监控曲线里的一个台阶。到这一步整个“索引坏了”的论断已经不攻自破。所谓“IndexScan 比 SeqScan 返回的结果更少”纯粹是两次查询落在了不同快照上跟索引没有关系。3.5 第五步复现并发写场景确认差异可解释为了让团队彻底信服我顺手写了一个最小复现实验。开两个会话模拟“先跑索引计划再跑全表计划中间夹一批提交”的现场-- 会话 A BEGIN ISOLATION LEVEL REPEATABLE READ; SET LOCAL enable_seqscan off; SET LOCAL enable_bitmapscan off; SELECT count(*) FROM orders WHERE status pending; -- 此时先不提交先记住结果 -- 会话 B另一条连接 INSERT INTO orders (status, payload) SELECT pending, md5(random()::text) FROM generate_series(1, 17574); COMMIT; -- 回到会话 A SET LOCAL enable_seqscan on; SET LOCAL enable_indexscan off; SET LOCAL enable_bitmapscan off; SELECT count(*) FROM orders WHERE status pending; -- 因为 REPEATABLE READ结果仍不变 COMMIT; -- 开一个全新会话 C直接走全表扫描 SET enable_seqscan on; SET enable_indexscan off; SELECT count(*) FROM orders WHERE status pending; -- 此时你会看到比会话 A 多 17574 行这个实验完美还原了所有“IndexScan 结果更少”的现场不是索引少返回而是第二次比较的查询发生在更多数据已经提交之后。而一旦用固定快照控制变量两个计划返回完全相同的数字索引从头到尾都好端端的。4. 真损坏是什么样结构校验与安全重建4.1 真损坏的三类症状如果同一快照下两个计划确实不一致或者干脆报错那才进入“真损坏”的诊断区间。我见过的真实损坏大致有三类症状查询报错例如ERROR: invalid page in block 2048 of relation base/16384/16428、ERROR: index row pointer ... is out of bounds这类是索引文件物理坏页直接看日志就能锁定对象。查询不报错但结果异常例如走索引count明显比全表少且差值没有规律可循不是固定的批次规模。这种通常是 B-tree 索引内部某个中间页或叶子页的逻辑损坏。Index Only Scan返回结果异常同时Seq Scan正常优先怀疑可见性映射错误而不是索引树本身的问题。前两类问题有一个共性它们和“两个计划在同一快照下不一致”完全对应。所以第 3 章的验证手段是区分真假损坏的边界线。4.2 用 amcheck 把问题钉死从 PostgreSQL 14 开始官方提供了amcheck扩展可以直接检测 B-tree 索引的结构完整性。用法先装扩展CREATE EXTENSION IF NOT EXISTS amcheck;然后对怀疑的索引做结构校验SELECT bt_index_check(idx_orders_status, true);第二个参数true表示启用heapallindexed模式不仅检查 B-tree 自身的内部指针一致性还会把索引里的每个条目和堆表做交叉比对能发现“索引里少了哪条 TID”这类静默损坏。代价是它会做全表扫描大表上非常耗费资源和时间所以生产环境我先建议跑快速模式SELECT bt_index_check(idx_orders_status, false);这个模式只检查索引结构不碰堆表速度快很多。如果快速模式就报错基本坐实了索引结构问题如果快速模式通过再考虑在低峰期跑一次heapallindexed全量比对确认。如果是非 B-tree 索引比如 GIN、GiST、Hash可以用更通用的verify_indexam()SELECT * FROM verify_indexam(idx_orders_status::regclass);4.3 REINDEX CONCURRENTLY 安全重建与善后确认索引损坏之后重建方案优先选并发模式而不是普通REINDEXREINDEX INDEX CONCURRENTLY idx_orders_status;要点如下REINDEX CONCURRENTLY不会长时间阻塞读写它会等所有可能使用该索引的事务结束后再开工期间新事务可以继续读写表和索引。如果重建失败索引会变成INVALID后续所有想走该索引的查询会直接跳过它。此时不要试图普通重建正确做法是DROP INDEX CONCURRENTLY idx_orders_status然后再建一个普通索引。重建结束后用第 3 章的同快照对比法再跑一遍确认Index Scan和Seq Scan的count完全一致。别忘了排查根因。重建只是治疗症状物理损坏往往伴随着磁盘坏块、掉电、复制错误或存储固件问题。我通常还要检查同盘其他表、其他索引的pg_amcheck结果防止损坏面被低估。5. 容易被误判成“损坏”的索引定义问题5.1 部分索引索引本来就只装了一部分行部分索引是最常见的“看着像坏其实没坏”第一嫌疑人。一个索引如果带WHERE子句那它永远只包含满足该谓词的行。拿它去跟全表扫描比总行数结果必然偏小但这不代表索引有任何异常。之前我协助过的另一个团队遇到过一模一样的问题一个叫idx_orders_active的部分索引只收录status active的行报表开发调优时用SET enable_seqscan off强制走索引结果发现行数比全表少了一半立刻惊动 DBA 准备重建索引。检查数据库后发现索引定义里清清楚楚写着WHERE status active查询也带了同样的条件唯一的问题就是他们比较的基准错了拿“active 订单数”跟“全部订单数”比少是正常不少才奇怪。所以拿到“IndexScan 比 SeqScan 少”的报告第一步永远是多看一眼\d 索引名或pg_index里的indpred字段。如果indpred非空那这个索引本身就是一个筛选后的子集。5.2 表达式索引和错误标定的 IMMUTABLE 函数表达式索引会创建一个“函数计算结果副本”而不是原始列值。比如CREATE INDEX idx_orders_lower_status ON orders(lower(status));如果业务代码里把你的自定义函数标记成IMMUTABLE但函数内部却依赖了外部状态比如读取配置文件、环境变量、随机数那这个索引的内容就会和查询时重新计算的结果不一致。典型症状就是走索引扫出来的行数和全表扫出来的行数对不上且差异没有规律。这不是索引物理坏页而是“逻辑损坏”重建也未必有用除非先把函数修正为真正的稳定函数再重建索引。处理这类问题的顺序应该是先审查索引定义里的函数是否真正IMMUTABLE再决定是否重建。5.3 LIMIT、并发写和时区边界组合出的“差异”三件事单独拎出来都不难组合在一起却能制造非常像“索引损坏”的假象LIMIT让比较基准失效。一个带LIMIT 100的查询不管走哪个计划都返回 100 行但和全量count一比就成了“索引少返回”。并发写让不同快照产生时间差增量。这在第 3 章已经讲透。时区边界让谓词漂移。如果查询条件里写了WHERE created_at now() - interval 1 day两次执行的now()不同那么即便同一张表得到的行集也不可能一样。这三者叠加后现场往往是凌晨两点跑报表 job同一份 SQL 前天走全表扫描返回 3 万行今天优化器改走索引返回 2.8 万行中间差了 2000 行——不是索引坏了是now()和写入业务同时在扰动。5.4 其他数据库里的类似现象这个问题的思路并不只适用于 PostgreSQL。MySQL 的 InnoDB 里二级索引是聚簇索引的“辅助导航”如果二级索引因为某种原因缺失条目走二级索引的SELECT同样可能少返回结果而直接扫主键聚簇索引或全表则正常。排查时的核心原则一致先固定同一个事务快照、统一 SQL 口径再谈索引结构校验最后才考虑重建。MySQL 社区虽然没有统一叫IndexScan / SeqScan但EXPLAIN里typeindex和typeALL的差异在语义上就是同一回事。6. 我的经验清单从今天起怎么处理“索引少返回”6.1 现场处置决策表下面这张表是我现在遇到“IndexScan 比 SeqScan 少”问题时贴在心里的处理清单。先对号入座再决定动作现象优先检查下一步动作两次查询不在同一时刻/不同会话快照边界、并发写批次用同一事务固定快照重新对比SQL 带 LIMIT、ORDER BY、去重返回行数口径不一致改为 count(*) 再对比索引定义带 WHERE部分索引确认查询条件是否包含索引谓词索引定义是表达式表达式函数稳定性审查函数是否真正 IMMUTABLE同一快照下不一致amcheck 结构校验确认损坏后再 REINDEX CONCURRENTLY执行计划是 Index Only Scan可见性映射状态检查 vm 文件VACUUM 全表查询带 now()/CURRENT_TIMESTAMP函数求值时间差异对比时固定常量或使用测试时间点6.2 几条刻进肌肉记忆的做事顺序经过这轮排查我给自己定了三条纪律也分享给你第一看到索引相关告警先要EXPLAIN ANALYZE的执行计划原文和查询的完整 SQL不看计划文本就讨论“损坏”基本是盲人摸象。第二做任何对比验证默认使用BEGIN ISOLATION LEVEL REPEATABLE READ固定快照两个计划在同一份数据上 PK这是分行数差异的“黄金法则”。不要用READ COMMITTED下两次不确定快照的结果来定罪索引。第三REINDEX永远是最后一招不是第一招。它太容易被当成“万能药”但代价不低而且会掩盖真实原因。真正的排查顺序是先统一口径、再固定快照、后查定义、最后 amcheck。这次所谓“索引损坏”事件最终证实是并发批量任务和时间差造成的误会索引本身健健康康。很多时候数据库比我们想象的稳定得多出问题的是我们对比它的方式。希望你下次再遇到“IndexScan 比 SeqScan 返回的结果更少”也能先想到固定快照这一步而不是第一时间把索引拆了重建。
返回列表