从 8KB 页面看懂 PostgreSQL VACUUM:死元组、索引清理与可见性地图

2026-07-05 41 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

PostgreSQL 的 VACUUM 经常被简单理解成“清理死数据”。但真正落到一个 8KB heap page 上,它做的事情更细:什么时候只改 t_xmax,什么时候回收 tuple 字节,什么时候还不能释放 line pointer,什么时候更新 FSM 和 visibility map。理解这些细节,能解释很多生产现象:为什么表删了一半文件还不变小,为什么 index bloat 会堆起来,为什么 index-only scan 有时突然变快。

DELETE 不是清空间,只是给 tuple 盖章

在 PostgreSQL 里,普通 DELETE 不会立刻把 tuple 从页面里抹掉。它主要做一件事:把被删 tuple 的 t_xmax 写成删除事务的 XID。

页面上的 line pointer 仍然是 LP_NORMALlp_offlp_len 也还在;pd_lowerpd_upper 不会因为 DELETE 改变。换句话说,页面结构上看起来仍然“塞着东西”,只是 MVCC 可见性规则告诉后续事务:这行已经死了。

可以这样实践,用 pageinspect 直接看页面变化:

CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_visibility;
CREATE EXTENSION IF NOT EXISTS pg_freespacemap;

DROP TABLE IF EXISTS vacuum_demo;
CREATE TABLE vacuum_demo (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category text NOT NULL,
  payload text
);

INSERT INTO vacuum_demo (category, payload)
SELECT 'cat_' || (i % 5), repeat('x', 100)
FROM generate_series(1, 50) AS i;

VACUUM vacuum_demo;

SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('vacuum_demo', 0));

SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('vacuum_demo', 0))
LIMIT 10;

DELETE FROM vacuum_demo WHERE id % 3 = 0;

SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('vacuum_demo', 0))
LIMIT 10;

运行时需要注意:get_raw_page() 读取的是物理页面,实验最好放在本地测试库,不要在生产库随手跑。不同 PostgreSQL 版本、事务号和页面布局细节会让数字不同,但状态变化方向是一致的。

你会看到被删除的行 t_xmax 不再是 0,但 line pointer 仍然是 lp_flags = 1,也就是 LP_NORMAL。这就是死元组变成 bloat 的起点:空间还在页面里占着,直到 pruning 或 VACUUM 真正来处理。

VACUUM 的三段动作:tuple 字节先走,line pointer 后走

有索引的表里,VACUUM 不能一上来就把被删除行的 line pointer 标成空闲。原因很直接:索引条目还拿着 TID 指向这个 line pointer。如果 heap 端把 slot 彻底释放,索引里的旧 TID 就失去安全含义了。

因此普通 VACUUM 可以拆成三段理解:

  1. heap scan 和 pruning:扫描 heap page,移除已经确认死亡的 tuple 数据,整理页面空洞,让 pd_upper 上移;但对有索引的表,相关 line pointer 先变成 LP_DEAD
  2. index cleanup:扫描每个索引,删除指向 dead TID 的 index entry。
  3. heap cleanup:索引清完后,再把 LP_DEAD 变成 LP_UNUSED,这个 line pointer slot 才能被后续 INSERT 复用。

这也是一个容易误解的点:tuple 的实际字节空间是在第一阶段 pruning 时回来的;第三阶段释放的是 4 字节 line pointer slot,不是 tuple payload 本身。

可以用 VACUUM (INDEX_CLEANUP OFF) 人为停在中间状态:

VACUUM (INDEX_CLEANUP OFF) vacuum_demo;

SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('vacuum_demo', 0));

SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('vacuum_demo', 0))
LIMIT 10;

此时你通常会看到:

  • pd_upper 已经上移,说明 tuple 字节空间已经回收。
  • 被删行的 line pointer 变成 lp_flags = 3,也就是 LP_DEAD
  • lp_offlp_len 变成 0,因为 tuple 数据已经不在页面上。

再跑一次普通 VACUUM:

VACUUM vacuum_demo;

SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('vacuum_demo', 0))
LIMIT 10;

这时 LP_DEAD 会变成 LP_UNUSED。页面里的 tuple 字节不会再多回收一次,因为早在 pruning 阶段已经完成;变化的是 line pointer slot 可以重新分配给新 tuple。

line pointer 的状态机比“活/死”更准确

看 heap page 时,不要把 line pointer 简化成“行是否存在”。它只是页面内的一个 4 字节槽位,状态有明确语义:

  • LP_UNUSED (0):空槽。可以被下一次 INSERT 复用。
  • LP_NORMAL (1):指向一个 tuple。这个 tuple 可能活着,也可能已经被 DELETE 标了 t_xmax,是否可见要看 MVCC。
  • LP_REDIRECT (2):HOT pruning 里的跳转槽,用来让索引从旧 TID 找到 HOT 链上的新版本。
  • LP_DEAD (3):tuple 数据已经能被回收,但索引可能还指着这个 TID,所以先保留槽位。

对有索引的普通 DELETE,典型路径是:

LP_NORMAL --DELETE--> LP_NORMAL with t_xmax
LP_NORMAL --VACUUM prune--> LP_DEAD
LP_DEAD --index cleanup + heap cleanup--> LP_UNUSED

没有索引时,路径更短:

LP_NORMAL --VACUUM prune--> LP_UNUSED

HOT 更新还有自己的路径,可能出现 LP_REDIRECT。这也是为什么同样叫“清理死 tuple”,不同 workload 下页面状态会不一样。

FSM 和 visibility map:VACUUM 不只清页面,还更新导航图

VACUUM 回收了页面里的空间,还要把这个事实告诉 PostgreSQL 的其他组件。

Free Space Map(FSM)记录每个 heap page 大概还有多少可用空间。DELETE 之后,死 tuple 虽然逻辑上不可见,但 FSM 不会自动知道这些空间可复用。VACUUM 更新 FSM 后,后续 INSERT 才会优先回到这些有空间的旧页面,而不是直接扩展表文件。

SELECT blkno, avail
FROM pg_freespace('vacuum_demo');

INSERT INTO vacuum_demo (category, payload)
SELECT 'cat_new', repeat('y', 100)
FROM generate_series(1, 10);

SELECT pg_relation_size('vacuum_demo') AS table_bytes;

如果 VACUUM 已经把页面空闲空间登记到 FSM,新增行就可能落回原来的 page,pg_relation_size 不一定增长。反过来,表里明明有很多死数据却长期不 vacuum,插入路径可能看不到内部空洞,表文件就会继续往后长。

Visibility Map(VM)记录每个 heap page 的两个 bit:all_visibleall_frozen。这两个 bit 很小,但影响很大。

VACUUM vacuum_demo;

SELECT blkno, all_visible, all_frozen
FROM pg_visibility('vacuum_demo');

all_visible = true 表示这个 page 上所有 tuple 对所有事务可见。Index-only scan 可以借助它少访问 heap page。all_frozen = true 更进一步,表示页面上的 tuple 已冻结,未来反 wraparound VACUUM 也可能跳过这些页面。

如果对 page 上任意一行执行 DELETE,PostgreSQL 会保守地清掉 all_visible

DELETE FROM vacuum_demo WHERE id = 1;

SELECT blkno, all_visible, all_frozen
FROM pg_visibility('vacuum_demo');

这是必要的保守性。Index-only scan 信任 visibility map,所以这个 bit 不能乐观。

VACUUM FULL 和普通 VACUUM:一个复用空间,一个重写文件

普通 VACUUM 经常被说成“不会缩表文件”,这句话不完整。更准确的说法是:普通 VACUUM 可以截断表尾部完全空掉的页面,但不能把文件中间半空的页面压缩到一起。

可以这样验证:

DROP TABLE IF EXISTS vt;
CREATE TABLE vt (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category text,
  payload text
);

INSERT INTO vt (category, payload)
SELECT 'cat_' || (i % 5), repeat('x', 100)
FROM generate_series(1, 1000) AS i;

SELECT pg_relation_size('vt') AS full_size;

DELETE FROM vt WHERE id > 50;
VACUUM vt;
SELECT pg_relation_size('vt') AS after_tail_delete_vacuum;

如果后面的页面全部空了,普通 VACUUM 的 truncate 阶段可以把尾部空页还给操作系统。

但如果每页都剩一些 live tuple:

DROP TABLE vt;
CREATE TABLE vt (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  category text,
  payload text
);

INSERT INTO vt (category, payload)
SELECT 'cat_' || (i % 5), repeat('x', 100)
FROM generate_series(1, 1000) AS i;

DELETE FROM vt WHERE id % 2 = 0;
VACUUM vt;
SELECT pg_relation_size('vt') AS after_regular_vacuum;

VACUUM FULL vt;
SELECT pg_relation_size('vt') AS after_vacuum_full;

普通 VACUUM 会让这些空洞可复用,但文件大小可能不变;VACUUM FULL 会把整张表重写成一个更紧凑的新文件,所以能消除内部 bloat。

代价也很明确:普通 VACUUM 较轻,不阻塞正常读写;VACUUM FULL 需要 AccessExclusiveLock,会阻塞这张表上的读写。大表在线治理通常要考虑 pg_repackpg_squeeze 这类方案,而不是直接在业务高峰跑 VACUUM FULL

采用建议:看见页面状态,再决定操作

排查 VACUUM 问题时,可以按这个清单收敛:

  • 想知道 DELETE 后为什么空间没回来,看 t_xmaxlp_flagspd_upper
  • 想判断空间是否能被 INSERT 复用,看 pg_freespace()
  • 想解释 index-only scan 是否能少读 heap,看 pg_visibility()
  • 想处理表文件不变小,先判断空闲空间在文件尾部还是分散在内部。
  • 想减少 index bloat,关注 autovacuum 是否能及时完成 index cleanup,而不仅是 heap 上的 dead tuple 数量。

VACUUM 的核心不是“把删除的数据擦掉”这么一句话。它是在 heap 页面、索引、FSM、VM 之间维护一套契约:tuple 字节什么时候可复用,TID 什么时候还必须保留,页面什么时候能被跳过。把这些状态看清楚,调 autovacuum、判断 bloat、安排重写表时就不会只靠感觉。


相关推荐