可见性映射(VM)
在 MVCC 中,一行的可见性要根据快照逐行判断。如果一个页里所有行对所有事务都可见,那么逐行判断就是纯粹的浪费。可见性映射(VM)就是为避免这种浪费而存在的:它用两个比特位,把"整页的可见性结论"缓存下来,从而让 Index Only Scan 和 VACUUM 都能跳过大量无用工作。
前置知识
阅读本节前,建议先了解:空闲空间映射(FSM)
基础概念
可见性映射(VM, Visibility Map) 是每张表旁边的一个辅助文件 _vm,为每一个数据页记录两个标志位:
| 标志位 | 英文 | 含义 |
|---|---|---|
| 全可见位 | All-Visible | 该页中的所有元组对所有事务都可见(页内无死元组、无未提交版本) |
| 全冻结位 | All-Frozen | 该页中的所有元组都已被冻结(XID 已替换为 FrozenTransactionId),不再需要为防回卷而清理 |
一个字节可以记录 4 个页的标志位(每页 2 位)。所以 VM 文件极小:一张 1TB 的表,VM 大约只有 4MB。
| 术语 | 英文 | 说明 |
|---|---|---|
| 可见性映射 | VM, Visibility Map | _vm 文件,记录页级可见性标志 |
| 全可见 | All-Visible | 页内所有元组对所有事务可见,可安全跳过可见性检查 |
| 全冻结 | All-Frozen | 页内所有元组的 XID 已冻结,VACUUM 可整页跳过 |
| 死元组 | Dead Tuple | 对任何活跃事务都不可见的旧版本元组 |
| 冻结 | Freeze | 把元组的 xmin 标记为永久有效,避免事务 ID 回卷 |
| 仅索引扫描 | Index-Only Scan | 只读索引即可回答查询,无需回表读堆页 |
| 回表 | Heap Fetch | 从索引拿到 ctid 后再去数据页读整个元组 |
VM 的两个标志位解决两个不同问题,不要混淆:
All-Visible 位 —— 为「读」服务
· Index-Only Scan 看到该页全可见 → 不需要回表读堆页
· VACUUM 看到该页全可见 → 可跳过,无需检查死元组
对应收益:查询变快(少读文件)
All-Frozen 位 —— 为「防回卷」服务
· VACUUM 看到该页全冻结 → 整页跳过,绝不重新检查 XID
· 是 aggressive VACUUM(防事务 ID 回卷)的主要加速手段
对应收益:VACUUM 变快,回卷风险降低核心机制
VM 的二进制布局
VM 文件结构(每个字节记录 4 个数据页,每页 2 个比特)
字节偏移 0 ─────────────────────────────────────────────────────
┌────────┬────────┬────────┬────────┐
│ 页 0 │ 页 1 │ 页 2 │ 页 3 │
│ bit0-1 │ bit2-3 │ bit4-5 │ bit6-7 │
└────────┴────────┴────────┴────────┘
│
└─► 每页的 2 位含义
bit 0 : ALL_VISIBLE (1 = 全可见)
bit 1 : ALL_FROZEN (1 = 全冻结)
其余 6 位保留
字节偏移 1 ─────────────────────────────────────────────────────
┌────────┬────────┬────────┬────────┐
│ 页 4 │ 页 5 │ 页 6 │ 页 7 │
└────────┴────────┴────────┴────────┘
由此可得:
· VM 文件大小 ≈ ceil(数据页数 / 4) 字节
· 8192 字节的一个 VM 页可覆盖 32768 个数据页(约 256MB 数据)
· 缺失的 VM 字节一律按「不全可见、不全冻结」处理(保守但正确)VACUUM 如何维护 VM
VM 的唯一维护者就是 VACUUM。它不能在 INSERT/UPDATE/DELETE 时维护,因为写入会让页立刻"不再全可见"。
VACUUM 扫描每一页
│
├─ 发现死元组 → 清理 → 该页不可能全可见 → 清除 All-Visible 位
│
├─ 扫描完成,该页无死元组,且所有元组的 xmin 都对所有事务可见
│ → 设置 All-Visible 位
│
├─ 进一步:该页所有元组都足够老(可安全冻结)
│ → 设置 All-Frozen 位(暗示也设置 All-Visible 位)
│
└─ 该页无法完全清理(有长事务挡住)→ 不设置任何位
写入操作(INSERT/UPDATE/DELETE)对 VM 的影响:
→ 立即清除该页的两个位(页已"脏",不再是全可见)关键点:任何写入都会立刻清掉 VM 位,所以 VM 的覆盖情况直接反映"这张表有多久没被稳定读取过了"。高频写入的表 VM 覆盖率通常很低。
两个位的设置条件
| 位 | 设置条件 | 设置者 | 是否要求"无死元组" |
|---|---|---|---|
| All-Visible | 页内所有元组对所有未结束事务及未来事务都可见 | VACUUM | 是 |
| All-Frozen | 在上条基础上,所有元组的 XID 都老到可以安全冻结 | VACUUM | 是(且额外要求 XID 足够老) |
注意 All-Frozen 蕴含 All-Visible,反之不成立。
-- 用 pg_visibility 扩展直接观察 VM
CREATE EXTENSION IF NOT EXISTS pg_visibility;
-- 建立演示表
DROP TABLE IF EXISTS vm_demo;
CREATE TABLE vm_demo (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
note TEXT NOT NULL
);
INSERT INTO vm_demo (note)
SELECT '备注-' || g FROM generate_series(1, 5000);
-- 插入后立即观察 VM:此时还没 VACUUM,应该几乎全为 false
SELECT
count(*) FILTER (WHERE all_visible) AS 全可见页数,
count(*) FILTER (WHERE all_frozen) AS 全冻结页数,
count(*) AS 总页数
FROM pg_visibility_map('vm_demo'); 全可见页数 | 全冻结页数 | 总页数
------------+------------+--------
0 | 0 | 18
(1 row)刚插入的数据,页都不是全可见的 —— 因为 VACUUM 还没跑过。
-- 执行 VACUUM 后再看
VACUUM vm_demo;
SELECT
count(*) FILTER (WHERE all_visible) AS 全可见页数,
count(*) FILTER (WHERE all_frozen) AS 全冻结页数,
count(*) AS 总页数,
round(100.0 * count(*) FILTER (WHERE all_visible) / count(*), 1) AS 全可见覆盖率百分比
FROM pg_visibility_map('vm_demo'); 全可见页数 | 全冻结页数 | 总页数 | 全可见覆盖率百分比
------------+------------+--------+--------------------
18 | 0 | 18 | 100.0
(1 row)VACUUM 之后全部页都是全可见了,但全冻结仍为 0 —— 因为刚插入的元组 xmin 还很新,未达到冻结条件。
-- 强制冻结(仅演示,生产慎用)
VACUUM (FREEZE) vm_demo;
SELECT
count(*) FILTER (WHERE all_visible) AS 全可见页数,
count(*) FILTER (WHERE all_frozen) AS 全冻结页数,
count(*) AS 总页数
FROM pg_visibility_map('vm_demo'); 全可见页数 | 全冻结页数 | 总页数
------------+------------+--------
18 | 18 | 18
(1 row)相关参数
| 参数 | 默认值 | 作用域 | 说明 |
|---|---|---|---|
vacuum_freeze_min_age | 50000000 | 实例/表级 | 元组 xmin 至少"老"多少事务才可冻结 |
vacuum_freeze_table_age | 150000000 | 实例/表级 | 表有多"老"就触发一次全表扫描式 VACUUM(更新 VM) |
vacuum_freeze_max_age | 200000000 | 实例级 | 超过即强制 aggressive VACUUM,防止事务 ID 回卷 |
enable_indexonlyscan | on | 会话 | 是否允许优化器选择 Index-Only Scan |
autovacuum_freeze_max_age | 200000000 | 实例级 | autovacuum 触发抗回卷清理的阈值 |
vacuum_truncate | on | 表级 | VACUUM 是否截断末尾全空页(影响 VM 与文件大小) |
-- 查看冻结与仅索引扫描相关的参数
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name IN (
'vacuum_freeze_min_age',
'vacuum_freeze_table_age',
'vacuum_freeze_max_age',
'enable_indexonlyscan'
)
ORDER BY name; name | setting | unit | short_desc
-------------------------+-----------+------+-----------------------------------------------------------
enable_indexonlyscan | on | | Enables the planner's use of index-only-scan plans.
vacuum_freeze_max_age | 200000000 | | Age at which to autovacuum a table to prevent transaction ID
| | | wraparound.
vacuum_freeze_min_age | 50000000 | | Minimum age at which VACUUM should freeze a table row.
vacuum_freeze_table_age | 150000000 | | Age at which VACUUM should scan whole table to freeze tuples.
(4 rows)vacuum_freeze_table_age 与 VM 的关系
这个参数的含义是:"当表的事务年龄(age(relfrozenxid))超过此值时,VACUUM 不再只扫描'可能脏'的页,而是扫描全表并重建 VM"。
这解释了为什么 VM 覆盖率会周期性跳回 100%:一旦表变"老",VACUUM 就会做一次全表扫描,把所有页重新标记为全可见/全冻结。平时 VACUUM 为了省时间只扫描"可能脏"的页(依赖 VM 和 FSM 判断),所以覆盖率会逐渐下降。
详细说明
Index-Only Scan 如何依赖 VM
这是 VM 最有价值的应用场景。普通索引扫描需要"两步走",而 Index-Only Scan 只有"一步"——前提是 VM 说这些页全可见:
普通索引扫描(Index Scan)
1. 遍历索引,拿到 ctid(页号 + 行号)
2. 对每个 ctid:读数据页 → 检查可见性 → 返回需要的列
⇒ 索引命中的每一行都要付一次随机堆页 I/O
仅索引扫描(Index-Only Scan)
1. 遍历索引,索引里已包含查询所需全部列
2. 检查该 ctid 所在页的 VM 位
├─ All-Visible = 1 → 直接返回索引中的值,绝不读堆页 ✔
└─ All-Visible = 0 → 必须回表,检查可见性(退化为普通扫描)
⇒ VM 覆盖率高时,堆页 I/O 几乎降为 0
VACUUM 未跟上的代价:
⇒ 每次 Index-Only Scan 都退化成 Index Scan
⇒ 查询计划显示 "Heap Fetches" 数值巨大-- 制造数据并为索引列建立覆盖索引
DROP TABLE IF EXISTS idx_demo;
CREATE TABLE idx_demo (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO idx_demo (user_id, amount, created_at)
SELECT
(random() * 10000)::bigint,
(random() * 1000)::numeric(12, 2),
now() - (random() * interval '365 days')
FROM generate_series(1, 500000);
-- 覆盖索引:包含查询需要的全部列
CREATE INDEX idx_demo_user_amount ON idx_demo (user_id) INCLUDE (amount);
-- 让统计信息与 VM 都跟上
VACUUM ANALYZE idx_demo;
-- 观察 Index-Only Scan 的效果
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, amount
FROM idx_demo
WHERE user_id = 1234; QUERY PLAN
----------------------------------------------------------------------------------
Index Only Scan using idx_demo_user_amount on idx_demo (cost=0.42..24.15 rows=48 width=12)
(actual time=0.028..0.075 rows=49 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 0
Buffers: shared hit=4
Planning Time: 0.128 ms
Execution Time: 0.098 ms
(6 rows)Heap Fetches: 0 是关键 —— 一次堆页都没有读,索引就回答了整个查询。这正是 VM 全可见带来的收益。
-- 现在做一批更新,破坏 VM 覆盖
UPDATE idx_demo SET amount = amount + 1 WHERE user_id < 5000;
-- 不 VACUUM,直接看计划的变化
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, amount
FROM idx_demo
WHERE user_id = 1234; QUERY PLAN
----------------------------------------------------------------------------------
Index Only Scan using idx_demo_user_amount on idx_demo (cost=0.42..26.30 rows=48 width=12)
(actual time=0.031..0.412 rows=49 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 49
Buffers: shared hit=12
Planning Time: 0.121 ms
Execution Time: 0.436 msHeap Fetches 从 0 变成 49(正好等于返回行数)—— Index-Only Scan 已经完全退化成普通索引扫描。执行时间也涨了 4 倍多。修好它只需一次 VACUUM:
VACUUM idx_demo;
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, amount
FROM idx_demo
WHERE user_id = 1234; QUERY PLAN
----------------------------------------------------------------------------------
Index Only Scan using idx_demo_user_amount on idx_demo (cost=0.42..24.15 rows=48 width=12)
(actual time=0.026..0.070 rows=49 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 0
Buffers: shared hit=4
Planning Time: 0.114 ms
Execution Time: 0.092 ms
(6 rows)Heap Fetches 回到 0。这就是"VACUUM 跟得上"对读性能的直接影响,也是运维中"查询忽然变慢"最常见的隐藏原因之一。
用 pg_visibility 深入观察
-- 查看整张表的 VM 概况(区分三个函数)
SELECT
'pg_visibility_map' AS 函数,
count(*) FILTER (WHERE all_visible) AS 全可见页数,
count(*) FILTER (WHERE all_frozen) AS 全冻结页数,
count(*) AS 总页数
FROM pg_visibility_map('idx_demo')
UNION ALL
SELECT
'all_visible / all_frozen 汇总',
count(*) FILTER (WHERE all_visible),
count(*) FILTER (WHERE all_frozen),
count(*)
FROM pg_visibility_map_summary('idx_demo'); 函数 | 全可见页数 | 全冻结页数 | 总页数
-------------------------------+------------+------------+--------
pg_visibility_map | 10297 | 0 | 10297
all_visible / all_frozen 汇总 | 10297 | 0 | 10297
(2 rows)-- 查看单页的 VM 与可见性明细
SELECT
blkno AS 页号,
all_visible AS 全可见,
all_frozen AS 全冻结,
pd_lower, pd_upper,
pd_upper - pd_lower AS 页内空闲字节
FROM pg_visibility('idx_demo', 0) v
CROSS JOIN LATERAL page_header(get_raw_page('idx_demo', 0))
LIMIT 1; 页号 | 全可见 | 全冻结 | pd_lower | pd_upper | 页内空闲字节
------+--------+--------+----------+----------+--------------
0 | t | f | 156 | 6992 | 6836
(1 row)-- 找出「应该有数据但没有全可见标记」的页(即需要 VACUUM 的页)
SELECT
count(*) AS 非全可见页数
FROM pg_visibility_map('idx_demo')
WHERE NOT all_visible; 非全可见页数
--------------
0
(1 row)观察写入立刻清除 VM 位
-- 记录更新前的 VM 状态
SELECT count(*) FILTER (WHERE all_visible) AS 更新前全可见页数
FROM pg_visibility_map('vm_demo');
-- 只更新一行
UPDATE vm_demo SET note = '被改过' WHERE id = 1;
-- 再观察:注意具体是哪些页失去了标记
SELECT
blkno AS 页号,
all_visible AS 全可见
FROM pg_visibility_map('vm_demo')
WHERE NOT all_visible
ORDER BY blkno; 更新前全可见页数
------------------
18
(1 row)
页号 | 全可见
------+--------
0 | f
(1 row)只更新了一行,就清掉了整页的 All-Visible 位。这是 VM 的粗粒度本质:它只有"整页"这一档,没有"部分可见"的中间状态。一行变了,整页就得当作"不确定"来对待。
这个特性直接推导出一条重要实践:把只读历史数据与高频写入数据分开存放(分区表、冷热分离),这样只读分区能保持长时间全可见,Index-Only Scan 才能稳定生效。
实战示例
场景:诊断 "Index-Only Scan 为什么不生效"
某个报表查询本来是毫秒级,突然涨到几百毫秒。计划中仍然显示 Index Only Scan,但 Heap Fetches 高达 20 万。
-- ---------- 第 1 步:确认计划与 Heap Fetches ----------
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, sum(amount) AS 总金额, count(*) AS 单数
FROM idx_demo
WHERE created_at >= now() - interval '30 days'
GROUP BY user_id
HAVING count(*) > 1
ORDER BY 总金额 DESC
LIMIT 20; QUERY PLAN
----------------------------------------------------------------------------------------------------
Limit (cost=58234.11..58234.16 rows=20 width=24) (actual time=412.338..412.341 rows=20 loops=1)
-> Sort (cost=58234.11..58236.61 rows=1000 width=24) (actual time=412.330..412.334 rows=20 loops=1)
Sort Key: (sum(amount)) DESC
Sort Method: top-N heapsort Memory: 26kB
-> GroupAggregate (cost=0.42..58184.11 rows=1000 width=24) (actual time=0.075..411.892 rows=4821 loops=1)
Group Key: user_id
Filter: (count(*) > 1)
Rows Removed by Filter: 5183
-> Index Only Scan using idx_demo_user_amount on idx_demo (cost=0.42..54198.20 rows=10000 width=20)
(actual time=0.062..376.512 rows=200000 loops=1)
Filter: (created_at >= (now() - '30 days'::interval))
Rows Removed by Filter: 300000
Heap Fetches: 203417 ← 问题在这里
Buffers: shared hit=41203 read=1208
Planning Time: 0.372 ms
Execution Time: 412.684 ms-- ---------- 第 2 步:检查 VM 覆盖率 ----------
SELECT
c.relname AS 表名,
count(*) FILTER (WHERE v.all_visible) AS 全可见页数,
count(*) AS 总页数,
round(100.0 * count(*) FILTER (WHERE v.all_visible) / count(*), 1) AS 全可见覆盖率百分比
FROM pg_visibility_map('idx_demo') v
CROSS JOIN pg_class c
WHERE c.oid = 'idx_demo'::regclass
GROUP BY c.relname; 表名 | 全可见页数 | 总页数 | 全可见覆盖率百分比
----------+------------+--------+--------------------
idx_demo | 4200 | 10297 | 40.8
(1 row)覆盖率只有 40.8% —— 有一半多的页在 VM 中没有标记,因此索引命中的每一行都得回表检查可见性。
-- ---------- 第 3 步:查看是什么在持续破坏 VM ----------
SELECT
relname AS 表名,
n_tup_ins AS 插入数,
n_tup_upd AS 更新数,
n_tup_del AS 删除数,
n_tup_hot_upd AS HOT更新数,
n_dead_tup AS 死元组数,
n_mod_since_analyze AS 上次分析后修改数,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'idx_demo'; 表名 | 插入数 | 更新数 | 删除数 | HOT更新数 | 死元组数 | 上次分析后修改数 | last_vacuum | last_autovacuum
----------+--------+--------+--------+-----------+----------+------------------+-------------+---------------------
idx_demo | 600000 | 300000 | 0 | 300000 | 300000 | 300000 | | 2026-09-22 06:00:12
(1 row)诊断清晰:表有 30 万次更新、30 万死元组,但 last_autovacuum 是几小时前 —— autovacuum 的节奏(默认 scale_factor = 0.2)跟不上写入速度,VM 覆盖率因此长期偏低。
-- ---------- 第 4 步:调优并验证 ----------
-- 让 autovacuum 对这个表更积极
ALTER TABLE idx_demo SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.01
);
-- 手工先整理一次,立即恢复 VM
VACUUM (ANALYZE, VERBOSE) idx_demo;INFO: vacuuming "public.idx_demo"
INFO: "idx_demo": found 300000 removable, 300000 nonremovable row versions in 10297 pages
DETAIL: 0 dead row versions cannot be removed yet.
INFO: "idx_demo": removed 300000 dead row versions in 3612 pages
INFO: "idx_demo": index scan bypassed: 6685 pages from table (64.92% of total) have 300000 dead item identifiers
INFO: "idx_demo": index "idx_demo_user_amount" now contains 600000 row versions in 3450 pages
INFO: "idx_demo": removed 300000 index row versions in 3450 pages
INFO: analyzing "public.idx_demo"-- ---------- 第 5 步:确认 VM 恢复,Heap Fetches 归零 ----------
SELECT
count(*) FILTER (WHERE all_visible) AS 全可见页数,
count(*) AS 总页数,
round(100.0 * count(*) FILTER (WHERE all_visible) / count(*), 1) AS 全可见覆盖率百分比
FROM pg_visibility_map('idx_demo');
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM idx_demo WHERE user_id = 1234; 全可见页数 | 总页数 | 全可见覆盖率百分比
------------+--------+--------------------
10297 | 10297 | 100.0
(1 row)
QUERY PLAN
---------------------------------------------------------------------------------------------
Aggregate (cost=24.23..24.24 rows=1 width=8) (actual time=0.048..0.049 rows=1 loops=1)
-> Index Only Scan using idx_demo_user_amount on idx_demo (cost=0.42..24.15 rows=48 width=0)
(actual time=0.026..0.040 rows=49 loops=1)
Index Cond: (user_id = 1234)
Heap Fetches: 0 ← 恢复正常
Buffers: shared hit=4
Planning Time: 0.110 ms
Execution Time: 0.075 ms
(7 rows)注意事项
- VM 只由
VACUUM维护,ANALYZE不更新 VM,INSERT/UPDATE/DELETE只会清除 VM 位。 - VM 只有"整页"粒度,改一行会让整页失去全可见标记。这是它的粗粒度本质,不是 bug。
Heap Fetches是判断 VM 是否有效的唯一可靠指标,不要只看计划里有没有Index Only Scan字样。_vm文件不存在或为 0 字节不是错误,只意味着从未VACUUM过,Index-Only Scan 会全部退化为回表。- 不要手工创建、修改或删除
_vm文件。
# 不推荐:手工干预 VM 文件
# rm -f $PGDATA/base/16384/16385_vm # 不推荐:VM 由 VACUUM 负责,手工操作会破坏一致性VACUUM (FREEZE)不宜在生产随意全表执行,它会强制冻结并可能引发大量 I/O;应让 autovacuum 按vacuum_freeze_*参数自然推进。
-- 不推荐:对大表手工全量冻结
-- VACUUM (FREEZE) huge_table; -- 不推荐:会长时间高强度 I/O,应交给 autovacuum- 高频更新的表 VM 覆盖率必然低,这是物理规律。想提升只能靠减少更新或做冷热分离。
- 分区是提升 VM 覆盖率最有效的手段:只读的历史分区能长期保持 100% 全可见。
VACUUM FULL会重建表与索引,同时重建 VM(新表所有页都会重算),但代价是排他锁与全表重写。
最佳实践
- 把"VACUUM 是否跟得上"作为一级监控指标,对高频写表单独设置更激进的 autovacuum 参数。
- 用覆盖索引(
INCLUDE)配合 VM 追求 Index-Only Scan,把查询所需列放进索引,才能真正避免回表。 - 定期用
pg_visibility_map_summary检查关键表的全可见覆盖率,低于 80% 就该排查 autovacuum 节奏。 - 关键查询上线前先看
Heap Fetches,它是"Index-Only Scan 是否真生效"的唯一证据。 - 冷热数据用分区隔离,只读分区让 VM 长期全可见,报表查询收益显著。
- 不要指望 VM 解决一切读放大,它只对"全列都在索引里"的查询有效。
- 批量更新后立即
VACUUM,这是让 VM 快速恢复的标准动作。 - 避免无意义的全表
UPDATE(如"刷新所有行的更新时间戳"),它不是只破坏 VM,还会制造大量死元组与 WAL。 - 在 SSD/NVMe 上把 autovacuum 调得更激进(缩短
naptime、提高cost_limit),VM 恢复更快。 - 理解 All-Visible 与 All-Frozen 的差异,All-Frozen 主要服务抗回卷,别指望它对查询有额外收益。
下一节
继续学习:WAL 文件组织