慢 SQL 已经命中Index Only Scan需要的列也都放进了INCLUDE但Heap Fetches仍接近返回行数。问题不在“索引没有覆盖”而在覆盖只完成了一半。索引决定值能否从索引取得Visibility Map 决定 MVCC 可见性是否还要去 heap 验证。节点叫 Index Only Scan不承诺实际零次 heap fetch。先把三个容易混淆的概念拆开PostgreSQL 的普通表是 heap索引与 heap 分开存储。B-tree 叶子项保存键值、INCLUDE载荷和指向 heap tuple 的 TID但不保存足以独立判断所有 MVCC 快照可见性的完整信息。一次真正避免回 heap 的读取需要同时满足三层条件访问方法能力索引类型能返回原始值 查询覆盖性查询需要的列都能从索引取得 页面可见性对应 heap page 的 VM all-visible 位为真 该索引项无需访问 heap前两层决定优化器能否生成Index Only Scan路径第三层在执行期间逐页决定是否真的跳过 heap。一个扫描可以对部分页面只读索引对另一些页面回 heap它不是非黑即白的开关。可复现实验同一计划Heap Fetches 三次变化前提与控制变量实验面向 PostgreSQL 18.6建议在独立测试库运行。需要能创建普通表观察 VM 的可选步骤还需要安装pg_visibility扩展及相应权限。保持以下条件不变同一张表、同一索引、同一查询、同一返回行数只改变页面是否刚被修改以及是否执行VACUUM。实际耗时受缓存、硬件和并发影响不应照抄本文中的数量级核心证据是计划节点、Heap Fetches与 VM 的方向性变化。DROPTABLEIFEXISTSpayment;CREATETABLEpayment(idbigintGENERATED ALWAYSASIDENTITYPRIMARYKEY,tenant_idbigintNOTNULL,created_at timestamptzNOTNULL,amountnumeric(12,2)NOTNULL,statustextNOTNULL)WITH(autovacuum_enabledfalse);INSERTINTOpayment(tenant_id,created_at,amount,status)SELECT3,timestamptz2026-08-30 12:0008-g*interval1 second,(g%100000)/100.0,paidFROMgenerate_series(1,100000)ASg;CREATEINDEXpayment_cover_idxONpayment(tenant_id,created_atDESC)INCLUDE(amount);ANALYZEpayment;这里暂时关闭表级 autovacuum只为避免后台任务在两次观测之间自行改变 VM确保实验可重复。生产环境不能照搬这个设置实验结束必须恢复或删表。第一次索引覆盖但新写页面还没有 all-visibleEXPLAIN(ANALYZE,BUFFERS,WAL,SETTINGS)SELECTcreated_at,amountFROMpaymentWHEREtenant_id3ORDERBYcreated_atDESCLIMIT10000;预期能够看到Index Only Scan using payment_cover_idx同时Heap Fetches大于 0常见情况是接近 10000。刚插入的页面尚未由VACUUM设置 all-visible 位所以执行器虽然能从索引取得created_at和amount仍要访问 heap 判断行对当前快照是否可见。这一步证明Index Only Scan是执行路径名称不是零回表验收结果。第二次VACUUM 后再次执行VACUUM(ANALYZE)payment;EXPLAIN(ANALYZE,BUFFERS,WAL,SETTINGS)SELECTcreated_at,amountFROMpaymentWHEREtenant_id3ORDERBYcreated_atDESCLIMIT10000;在没有其他会话和页面修改干扰时预期仍是同一个Index Only Scan但Heap Fetches显著下降理想情况下为 0。普通VACUUM确认页面中的 tuple 对所有当前及未来事务可见后能够设置 VM 的 all-visible 位。这一步证明 VM 可以让执行器跳过 heap它不证明生产表会长期维持这些位也不证明耗时改善完全来自磁盘 I/O——heap page 可能本来就在 shared buffers 或操作系统缓存中。第三次只更新非覆盖列UPDATEpaymentSETstatussettledWHEREtenant_id3ANDcreated_attimestamptz2026-08-29 12:0008;EXPLAIN(ANALYZE,BUFFERS,WAL,SETTINGS)SELECTcreated_at,amountFROMpaymentWHEREtenant_id3ORDERBYcreated_atDESCLIMIT10000;status既不是索引键也不在INCLUDE中但修改数据页仍会清除相应页面的 VM 位。第三次计划可能依旧显示Index Only ScanHeap Fetches却重新升高。如果该 UPDATE 满足 HOT 条件PostgreSQL 可以不为新版本增加所有相关索引项这能降低索引维护代价却不能让已修改页面继续保持 all-visible。HOT 与 Index Only Scan 解决的是不同问题。最后再次执行VACUUM(ANALYZE)payment;EXPLAIN(ANALYZE,BUFFERS,WAL,SETTINGS)SELECTcreated_at,amountFROMpaymentWHEREtenant_id3ORDERBYcreated_atDESCLIMIT10000;若没有长事务阻挡旧版本清理也没有新的写入Heap Fetches应再次下降。完整因果链是INSERT / UPDATE / DELETE 修改页面 ↓ 保守地清除 VM all-visible ↓ Index Only Scan 对这些页回 heap 做可见性检查 ↓ VACUUM 确认页面满足条件并重设 all-visible ↓ 后续扫描可再次跳过 heap用 pg_visibility 直接观察页面状态如果测试库允许安装 PostgreSQL 自带扩展可把执行计划证据与 VM 本身对上CREATEEXTENSIONIFNOTEXISTSpg_visibility;SELECT*FROMpg_visibility_map_summary(payment::regclass);分别在初始写入后、VACUUM后、UPDATE 后再查询观察all_visible页数。也可以计算占 heap 总页数的比例SELECTc.relpagesASheap_pages,v.all_visible,round(100.0*v.all_visible/NULLIF(c.relpages,0),2)ASall_visible_pctFROMpg_classAScCROSSJOINLATERAL pg_visibility_map_summary(c.oid)ASvWHEREc.oidpayment::regclass;pg_visibility_map_summary读取的是 VM 摘要。pg_class.relpages是最近一次VACUUM、ANALYZE或部分 DDL 更新的估算值不是实时精确页数因此这个百分比适合排障趋势不适合当作财务式精确口径。扩展还提供逐块检查但扫描数据块比只看 VM 更贵。生产排障应先用汇总和执行计划未经评估不要对大表频繁跑全表逐页函数更不要调用截断 VM 的修复函数做“验证”那是有破坏性的维护动作不是普通观测手段。为什么 Visibility Map 必须保守VM 为每个 heap page 保存两个位all-visible页面里的 tuple 都对所有当前及未来事务可见可供 Index Only Scan 使用all-frozen页面里的 tuple 都已冻结反回卷 VACUUM 可以跳过。索引没有自己的 VM。页面一旦发生数据修改相关位会被清除只有VACUUM会设置 VM 位。它可能出现“位为 0但页面实际已经满足可见条件”的保守假阴性却不能冒险把不满足条件的页面标成 all-visible否则查询可能跳过必要的 MVCC 判断并返回错误结果。VM 很小通常能留在缓存中。Index Only Scan 用一次廉价 VM 查询替代更随机、更大的 heap 访问收益主要来自这个尺寸与局部性差异。INCLUDE 只是 payload不是免费午餐下面两个索引对查询覆盖性都可能有效但语义不同CREATEINDEXpayment_key_amount_idxONpayment(tenant_id,created_atDESC,amount);CREATEINDEXpayment_include_amount_idxONpayment(tenant_id,created_atDESC)INCLUDE(amount);在第二个索引里amount是非键 payload不参与 B-tree 搜索和排序定位唯一索引的唯一性只约束键列不包括INCLUDE列非键列会从 B-tree 上层页面做 suffix truncation主要保留在叶子层仍会复制数据、增大叶子元组和索引尺寸payload 太宽可能触及索引元组大小上限导致写入失败。如果查询还要按amount过滤或排序单纯INCLUDE (amount)不会让它成为有效的搜索键。反过来把每个返回列都加入索引可能用读路径的局部收益换来全局写放大、更多 WAL、更差缓存密度和更长维护时间。尤其在持续更新的热表上VM 位频繁被清除本就很难稳定获得零 heap fetch此时宽覆盖索引的收益可能远小于成本。Index Scan、Bitmap Heap Scan 与 Index Only Scan 怎么选路径典型优势主要代价不应只看什么Index Scan少量行、索引顺序能消除排序每条候选记录可能随机访问 heap“用了索引”Bitmap Heap Scan先汇总 TID再按 heap block 较有序读取可组合多个索引构建 bitmap且通常不能直接保留索引输出顺序“不是 Index Scan”Index Only Scan覆盖查询且大量页面 all-visible 时减少 heap 访问热页仍会 heap fetch宽索引增加写成本节点名称中的OnlySequential Scan高命中比例或小表时顺序读取会读取大量不需要的页面“没用索引就是慢”优化器根据估算成本选择整条路径。SET enable_seqscan off可以做诊断对照却不能证明被强制的计划在生产中更优它只是大幅抬高某类路径成本也不保证在无替代方案时绝对禁用。生产排障从只读证据到最小修复遇到“覆盖索引还是慢”先保留慢 SQL、参数、计划与时间窗口然后按以下顺序检查。1. 看实际执行证据EXPLAIN(ANALYZE,BUFFERS,WAL,SETTINGS)SELECTcreated_at,amountFROMpaymentWHEREtenant_id3ORDERBYcreated_atDESCLIMIT10000;重点不是只找Index Only Scan而是同时看Heap Fetches与实际返回行数的比例shared hit/read区分访问量与物理读取迹象估算行数与实际行数偏差是否出现排序、循环放大或错误连接路径多次执行后证据是否稳定。EXPLAIN ANALYZE会真实执行语句。对写 SQL 或昂贵查询应在安全副本、只读事务或明确评估后使用不能把它当无成本观察命令。2. 看表是不是天然“太热”SELECTrelname,n_live_tup,n_dead_tup,n_tup_ins,n_tup_upd,n_tup_hot_upd,last_autovacuum,autovacuum_countFROMpg_stat_user_tablesWHERErelidpayment::regclass;累计统计存在刷新延迟也可能在崩溃恢复等场景被重置它适合和时间窗口、监控增量一起解释不能单次截图就下结论。3. 查 VACUUM 为什么没有恢复 VM常见原因包括写入速率持续高于清理节奏、autovacuum 阈值对大表过宽、worker 或 I/O 资源不足、长事务/旧快照阻挡旧版本移除以及刚好在观测前又发生了写入。长事务可先只读检查SELECTpid,usename,application_name,state,xact_start,backend_xmin,wait_event_type,wait_eventFROMpg_stat_activityWHERExact_startISNOTNULLORDERBYxact_start;不要看到旧事务就直接终止连接。先确认业务所有者、事务用途、重试能力和影响范围再决定是否处理。4. 最小化修复而不是先加宽索引可能的根因修复包括为特定大热表调低 autovacuum 触发阈值或提高其可用资源缩短应用事务清理idle in transaction根因把冷热数据按生命周期分区使历史分区更容易维持 all-visible缩小查询范围或改用真正匹配过滤、排序的键列删除收益不足的 payload控制索引尺寸与写放大。手工VACUUM可用于验证“VM 是不是主因”或临时缓解但若业务持续改写同一批页面效果会很快消失。VACUUM FULL会重写整表并取得ACCESS EXCLUSIVE锁不是解决 Heap Fetches 高的常规手段。变更上线与回滚边界若最终决定增加覆盖索引生产方案至少应包括变更前记录查询 p50/p95/p99、Heap Fetches、表写入量、WAL、索引尺寸和复制延迟大表优先评估CREATE INDEX CONCURRENTLY同时认识它耗时更长、会增加系统负担且失败后可能留下 invalid index先在一个租户、一个只读副本或小流量查询上验证不要用“索引创建成功”替代业务验收验收读延迟下降的同时确认写延迟、WAL、缓存命中与 autovacuum 没有恶化到不可接受达到锁等待、复制延迟、磁盘水位或写延迟停止阈值时不继续扩大范围。新增索引通常可通过删除索引回退但已经产生的写放大、缓存扰动和创建期间负载不能瞬间撤销。DROP INDEX CONCURRENTLY也有自身限制回滚脚本必须提前在目标拓扑验证。这个实验能证明什么证据能证明不能证明节点是 Index Only Scan优化器选了可从索引取值的路径执行期间没有访问 heapHeap Fetches 高大量候选项仍需 heap 可见性检查每次访问都发生物理磁盘 I/OVACUUM 后 Heap Fetches 下降VM 可见性是重要变量autovacuum 配置已经适合生产UPDATE 后再次上升页面修改清除了 VM 优势UPDATE 一定没有使用 HOTall-visible 比例高大部分 heap page 在 VM 中被标记当前这条查询访问的每个页面都 all-visible性能结论必须连接到业务指标查询尾延迟是否下降、吞吐是否提升、写入 SLA 是否保持、资源成本是否合理。单独追求Heap Fetches 0可能把数据库优化成一个更贵但业务无感的版本。面试时怎么讲可以这样回答PostgreSQL 的覆盖索引只保证查询所需值存在于索引MVCC 可见性仍属于 heap tuple。Index Only Scan 先查目标 heap page 的 Visibility Mapall-visible 为真才跳过 heap否则仍产生Heap Fetches。数据修改会清位VACUUM才能设置所以热表上即使有INCLUDEIndex Only Scan 收益也可能不稳定诊断必须看EXPLAIN (ANALYZE, BUFFERS)的实际回表与写放大而不是只看节点名称。实验清理DROPTABLEIFEXISTSpayment;若测试库专门为本实验安装且没有其他对象依赖pg_visibility可由管理员评估后执行DROP EXTENSION pg_visibility。不要在共享环境为了“清理”擅自删除已有扩展。官方资料PostgreSQL 18Index-Only Scans and Covering IndexesPostgreSQL 18Visibility MapPostgreSQL 18pg_visibilityPostgreSQL 18VACUUMPostgreSQL 18Cumulative Statistics SystemPostgreSQL 18.6 源码标签 REL_18_6
