暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

明明是 Index Only Scan,PostgreSQL 为什么还在疯狂回表?

安呀智数据坊 2026-01-27
66

注: 本文为安丫科技刘峰的原创,请尊重知识产权,转发请注明出处,不接受任何抄袭、演绎和未经注明出处的转载。

在 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 独特的存储架构。


1

根本矛盾:“瘦”索引与 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) 的原始动力。


2

破局者:Visibility Map (VM)

如果每次 IOS 都要回表,那它和普通 Index Scan 就毫无区别了。

为了优化这一过程,PG 引入了 Visibility Map (VM)。

  • 定义

VM 是一个位图文件(后缀 _vm),它不记录具体的行,而是以 Page (数据页,默认 8KB) 为单位进行标记。

  • All-Visible (全可见位):

Bit = 1

该 Page 上所有的元组对当前所有的活跃事务都是可见的。

这意味着该页没有死元组,也没有未提交的事务。

Bit = 0

该 Page 上可能包含死元组,或者刚刚被修改过。


3

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 环境进行对比。


1

环境准备

创建一个表,并关闭自动清理 (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(1100000), 'initial_data';

-- 4. 创建覆盖索引 (只包含 id)
CREATE INDEX idx_ios_id ON ios_test(id);
ANALYZE ios_test;


2

制造“脏”数据

我们更新前 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

 实验结果与深度解读

1

场景 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万行数据来说,开销巨大。


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

 总结与最佳实践

1

核心结论 

Index Only Scan 是有条件的


它依赖于 Visibility Map 的状态。


Index Only Scan 不等于不回表


如果 VM 标记为脏,IOS 甚至可能比普通的 Index Scan 更慢(因为索引可能比堆表更膨胀)。


VACUUM 是性能的关键


VACUUM 不仅仅回收空间,更是维护 VM、保证 IOS 高效运行的基石。


2

给 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 等主流数据库

  • 核心服务内容

    性能优化 / 故障处理 / 数据迁移 / 备份恢复 / 版本升级 / 补丁管理

  • 系统性支持

    深度巡检 / 高可用架构设计 / 应用层兼容评估 / 运维工具集成

  • 专项能力补充

    定制课程培训 / 甲方团队辅导 / 复杂问题协作排查 / 紧急救援支持

📮 如果你有一张删不掉的表、一个跑不动的查询,或者一场说不清的升级风险,欢迎来找我们聊聊。

END

关键词回复(可见相应文章):

oracle、mysql、pg、postgresql、sql、性能优化、故障处理、数据迁移、备份恢复、版本升级、补丁管理、深度巡检、解决方案、架构设计......

添好友1对1咨询

\ | /

动动你的手指

【安呀智数据坊】加个星标吧~

这样你就不会丢下我啦~

记得加星标呀!

文章转载自安呀智数据坊,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论