
注: 本文为安丫科技刘峰的原创,请尊重知识产权,转发请注明出处,不接受任何抄袭、演绎和未经注明出处的转载。
在 PostgreSQL 性能调优中,Index Only Scan 几乎是 DBA 心中的“理想状态”。
Explain 一出来,看到 Index Only Scan,
很多人下意识就松了一口气: “覆盖索引生效了,堆表不会再被访问。”
但现实往往很残酷——
执行计划走了 IOS
Heap Fetches 却动辄上万
查询时间甚至比普通 Index Scan 还慢
这时候你可能会怀疑:是索引建错了?是 PG 优化器不聪明?还是 IOS 根本是个“伪优化”?
事实上,问题不在索引,而在 PostgreSQL 为了 MVCC 一致性 付出的那份“代价”。
本文将从 PostgreSQL 内核设计 出发,结合真实实验数据,彻底回答一个困扰无数 DBA 的问题: Index Only Scan,为什么并不 Always Only?
01
核心原理深度剖析
要理解 IOS 的回表行为,不能只看索引,必须理解 PostgreSQL 独特的存储架构。
根本矛盾:“瘦”索引与 MVCC 的代价
在 Oracle 或 MySQL (InnoDB) 中,索引叶子节点通常包含版本信息或直接指向最新记录。
但在 PostgreSQL 中,设计哲学完全不同:
堆表元组 (Heap Tuple)
存储真实数据。
头部包含 xmin (创建事务ID) 和 xmax (删除/更新事务ID)。
这是判断数据“可见性”(这行数据对当前事务是活是死)的唯一依据。
索引元组 (Index Tuple)
为了保持索引轻量,PG 的索引(如 B-Tree)只存储 Key (键值) 和 CTID (物理行指针)。
问题来了
索引自己不知道它指向的那行数据是“活的”还是“死的”。
当一个 Update 发生时,PG 会插入一条新版本的记录,并更新索引。
此时,索引中可能同时存在指向“旧版本”和“新版本”的条目。
当查询扫描索引时,它无法区分哪个条目有效。
为了保证事务隔离级别(MVCC),它本能地觉得:“我必须拿着 CTID 去堆表里看一眼 xmin/xmax 才能确定。”
这就是回表 (Heap Access) 的原始动力。
破局者:Visibility Map (VM)
如果每次 IOS 都要回表,那它和普通 Index Scan 就毫无区别了。
为了优化这一过程,PG 引入了 Visibility Map (VM)。
定义
VM 是一个位图文件(后缀 _vm),它不记录具体的行,而是以 Page (数据页,默认 8KB) 为单位进行标记。
All-Visible (全可见位):
Bit = 1
该 Page 上所有的元组对当前所有的活跃事务都是可见的。
这意味着该页没有死元组,也没有未提交的事务。
Bit = 0
该 Page 上可能包含死元组,或者刚刚被修改过。
IOS 的决策逻辑图解
当执行器进行 Index Only Scan 时,它会遵循以下决策树:
graph TD
A[Start: 扫描获取索引项 Index Tuple] --> B{获取 CTID 指向的 Page ID}
B --> C[查询 Visibility Map (VM)]
C -- "Bit = 1 (全可见)" --> D[✅ 信任索引]
D --> D1[直接返回 Index Tuple 中的数据]
D1 --> End[输出结果 (无回表)]
C -- "Bit = 0 (脏页/未知)" --> E[⚠️ 不信任索引]
E --> E1[回表 Heap Fetch (随机I/O)]
E1 --> E2{检查 Heap Tuple 头信息 (xmin/xmax)}
E2 -- "可见" --> E3[返回数据]
E2 -- "不可见 (Dead Tuple)" --> E4[丢弃数据]
E3 --> End
E4 --> End
路径 C -> D
这是我们想要的“真·IOS”,性能极高。
路径 C -> E
这就是执行计划中 Heap Fetches 产生的原因。
VM 说是脏的,PG 就必须去查堆表。
02
场景复现实验
为了验证原理,我们构造一个“脏”VM 环境和一个“净”VM 环境进行对比。
环境准备
创建一个表,并关闭自动清理 (Autovacuum),以便我们能手动控制 VM 的状态,观察“脏”状态下的表现。
-- 1. 建表
CREATE TABLE ios_test (id int, info text);
-- 2. 【关键】关闭自动清理,防止后台进程偷偷更新 VM
ALTER TABLE ios_test SET (autovacuum_enabled = false);
-- 3. 插入 10 万条基础数据
INSERT INTO ios_test SELECT generate_series(1, 100000), 'initial_data';
-- 4. 创建覆盖索引 (只包含 id)
CREATE INDEX idx_ios_id ON ios_test(id);
ANALYZE ios_test;
制造“脏”数据
我们更新前 20,000 行数据。这个操作会导致:
1)Heap 中产生了 20,000 个新版本元组和20,000 个死元组。
2)Index 中新增了条目。
3)VM 对应的 Page 标记被置为 0 (Dirty)。
UPDATE ios_test SET info = 'dirty_data' WHERE id <= 20000;
03
实验结果与深度解读
场景 A:VM 未更新(Dirty State)
此时 VM 认为数据页是脏的。
我们查询 id <= 20000。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM ios_test WHERE id <= 20000;
真实执行计划:
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Only Scan using idx_ios_id on ios_test (cost=0.29..837.13 rows=23705 width=4) (actual time=0.028..14.126 rows=20000.00 loops=1)
Index Cond: (id <= 20000)
Heap Fetches: 33658 <-- 【核心痛点】
Index Searches: 1
Buffers: shared hit=27456 <-- 【高 I/O】
Planning Time: 0.152 ms
Execution Time: 14.815 ms
深度解读
1) Heap Fetches: 33658:
为什么是 3万多而不是 2万?因为索引中既有指向“旧行”的指针,也有指向“新行”的指针。
对于旧行指针
查 VM -> 脏 -> 回表 -> 发现是死行 -> 丢弃 (产生 Heap Fetch)。
对于新行指针
查 VM -> 脏 -> 回表 -> 发现是活行 -> 返回 (产生 Heap Fetch)。
结论
虽然走了 Index Only Scan,但实际上为了鉴别真伪,PG 疯狂地访问了堆表。
2) Buffers: 27456
这代表访问了 2.7万个页面(逻辑读)。
这对于仅 2万行数据来说,开销巨大。
场景 B:执行 VACUUM 后(Clean State)
现在,我们手动执行 VACUUM。
它的核心作用之一就是扫描堆表,确认所有事务都已结束,将 Page 标记为 All-Visible,并更新 VM。
VACUUM ios_test;
再次执行相同的查询:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM ios_test WHERE id <= 20000;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Only Scan using idx_ios_id on ios_test (cost=0.29..622.51 rows=20241 width=4) (actual time=0.036..1.934 rows=20000.00 loops=1)
Index Cond: (id <= 20000)
Heap Fetches: 0 <-- 【最佳状态】
Index Searches: 1
Buffers: shared hit=112 <-- 【I/O 暴降】
Planning:
Buffers: shared hit=11
Planning Time: 1.030 ms
Execution Time: 2.518 ms
深度解析
1)Heap Fetches: 0
PG 扫描索引时,查 VM 发现全为 1,直接“闭眼”信任索引数据,零回表。
2. Buffers: 112
从 27456 降至 112。
仅仅读取了索引页,完全没有去读 Data Page。
3)性能对比
执行时间从 14.8ms 缩短至 2.5ms,提升近 6 倍。
04
总结与最佳实践
核心结论
Index Only Scan 是有条件的
它依赖于 Visibility Map 的状态。
Index Only Scan 不等于不回表
如果 VM 标记为脏,IOS 甚至可能比普通的 Index Scan 更慢(因为索引可能比堆表更膨胀)。
VACUUM 是性能的关键
VACUUM 不仅仅回收空间,更是维护 VM、保证 IOS 高效运行的基石。
给 DBA 的建议
在生产环境中,如果你发现大量的 Slow Query 显示为 IOS 但性能不佳:
1)关注 Heap Fetches 指标
这是判断 IOS 有效性的金标准。
2)调优 Autovacuum
对于更新频繁(Update-Heavy)的表,Visibility Map 会迅速变脏。
考虑降低 autovacuum_vacuum_scale_factor(例如从默认 0.2 降至 0.05),让 Autovacuum 跑得更勤快,确保 VM 始终保持“新鲜”。
3)手动 VACUUM 策略
在批量数据导入或大规模更新后,不要等待 Autovacuum 触发,立即手动执行 VACUUM (Analyze),立竿见影地提升 IOS 性能。
写在最后
Index Only Scan 从来不是一个“打开即满血”的优化选项。
在 PostgreSQL 中,它是一种建立在信任之上的扫描方式:
信任 Visibility Map 是干净的
信任索引指向的行对当前事务可见
一旦这种信任被打破,PostgreSQL 就会毫不犹豫地回表验证,哪怕执行计划上仍然写着 Index Only Scan。
这也是为什么在生产环境中,Heap Fetches 比 Scan 类型更有参考价值。
如果说索引决定了“能不能用 IOS”,那么 VACUUM 和 Autovacuum,决定的就是 IOS 有没有意义。
对 DBA 来说,真正成熟的性能优化,并不是“看到 IOS 就安心”,而是清楚地知道:什么时候 PostgreSQL 会选择相信索引,什么时候不会。
当你理解了这一点,执行计划里的那些“反直觉现象”,反而会变得异常清晰。

作者介绍
大家好,我是刘峰,安丫科技创始人 & 数据库技术高级讲师,专注于 PostgreSQL、国产数据库运维与迁移、数据库性能优化 等方向。
作为 PG中国分会官方授权讲师、PostgreSQL ACE 讲师认证专家,我长期活跃在一线项目实战中,拥有 10年以上大型数据库管理与优化经验,曾深度参与电信、金融、政务等多个行业的数据库性能调优与迁移项目。
欢迎关注我,一起深入探索数据库的无限可能,技术交流不设限!
📌 觉得有收获的话,记得点赞、收藏、转发支持一下哦,别忘了关注我获取更多数据库干货~
安呀智数据坊|我们能做什么
无论你是业务系统的技术负责人,还是数据部门的第一响应人,我们都能为你提供可靠的支持:
数据库类型支持
Oracle MySQL / PostgreSQL / SQL Server 等主流数据库
核心服务内容
性能优化 / 故障处理 / 数据迁移 / 备份恢复 / 版本升级 / 补丁管理
系统性支持
深度巡检 / 高可用架构设计 / 应用层兼容评估 / 运维工具集成
专项能力补充
定制课程培训 / 甲方团队辅导 / 复杂问题协作排查 / 紧急救援支持
📮 如果你有一张删不掉的表、一个跑不动的查询,或者一场说不清的升级风险,欢迎来找我们聊聊。
关键词回复(可见相应文章):
oracle、mysql、pg、postgresql、sql、性能优化、故障处理、数据迁移、备份恢复、版本升级、补丁管理、深度巡检、解决方案、架构设计......

添好友1对1咨询
\ | /
★
动动你的手指
给【安呀智数据坊】加个星标吧~
这样你就不会丢下我啦~
记得加星标呀!









