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_NORMAL,lp_off 和 lp_len 也还在;pd_lower、pd_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 可以拆成三段理解:
- heap scan 和 pruning:扫描 heap page,移除已经确认死亡的 tuple 数据,整理页面空洞,让
pd_upper上移;但对有索引的表,相关 line pointer 先变成LP_DEAD。 - index cleanup:扫描每个索引,删除指向 dead TID 的 index entry。
- 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_off和lp_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_visible 和 all_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_repack 或 pg_squeeze 这类方案,而不是直接在业务高峰跑 VACUUM FULL。
采用建议:看见页面状态,再决定操作
排查 VACUUM 问题时,可以按这个清单收敛:
- 想知道 DELETE 后为什么空间没回来,看
t_xmax、lp_flags、pd_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、安排重写表时就不会只靠感觉。