页面级真空:数据库清理效率新突破
速览
页面级真空是一种针对数据库页面的高级清理技术,能够有效回收空间并减少性能开销。该技术通过细粒度管理死元组,降低了对全表扫描的依赖。对于需要处理大规模数据的AI系统,这种优化可显著提升数据读取速度。文章还探讨了其在现代数据库架构中的实际应用价值。
AI 深度解读
背景
PostgreSQL 中,当数据行被删除或更新时,旧版本并不会立即释放空间。对于 HOT(Heap-Only Tuple)更新链,PostgreSQL 可以在普通读取操作中通过页面修剪(page pruning)优雅地回收死元组空间,无需等待后台进程。然而,修剪只是捷径:它仅适用于单个页面内的 HOT 更新元组。对于所有其他场景——涉及索引列的非 HOT 更新(cold update)、普通 DELETE、索引条目清理、空闲空间图注册、可见性图维护——都需要 VACUUM。
本文不重复 VACUUM 的操作性指南(如自动清理调优、工作进程分配、死元组清理的操作层面),而是逐字节观察 VACUUM 的工作过程。我们将对每个阶段前后的页面进行快照,精确追踪页面头部、行指针、元组头部、空闲空间图和可见性图的变化。使用的工具同样是 pageinspect、pg_visibility 和 pg_freespacemap。
核心内容
实验设置
为了进行有意义的对比,我们创建一张包含足够行数的表,并加上索引以展示完整的 VACUUM 周期。
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_visibility;
CREATE EXTENSION IF NOT EXISTS pg_freespacemap;
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;
插入 50 行数据,每行 payload 为 100 字节。主键提供了索引——当涉及索引时,VACUUM 的行为会发生变化。先执行一次 VACUUM 以确保干净的基线状态:
VACUUM vacuum_demo;
删除前的快照
记录第 0 页的基线状态。首先是页面头部:
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('vacuum_demo', 0));
结果:
lower | upper | special | pagesize
-------+-------+---------+----------
224 | 1392 | 8192 | 8192
pd_lower 为 224:24 字节页面头部 + 50 个行指针(每个 4 字节)=> 24 + 200 = 224。pd_upper 为 1392,所以元组占用 1392 到 8191 字节。空闲空间为 1392 - 224 = 1168 字节。剩余空间不多;100 字节的 payload 累积起来占用可观。
接下来是行指针和元组头部:
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 | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------+--------
1 | 1 | 8056 | 135 | 746 | 0 | (0,1)
2 | 1 | 7920 | 135 | 746 | 0 | (0,2)
3 | 1 | 7784 | 135 | 746 | 0 | (0,3)
4 | 1 | 7648 | 135 | 746 | 0 | (0,4)
5 | 1 | 7512 | 135 | 746 | 0 | (0,5)
6 | 1 | 7376 | 135 | 746 | 0 | (0,6)
7 | 1 | 7240 | 135 | 746 | 0 | (0,7)
8 | 1 | 7104 | 135 | 746 | 0 | (0,8)
9 | 1 | 6968 | 135 | 746 | 0 | (0,9)
10 | 1 | 6832 | 135 | 746 | 0 | (0,10)
lp_len 为 135(元组实际字节长度),但每个元组在页面上按 MAXALIGN 对齐占用 136 字节;注意 lp_off 按 136 递减。这种对齐步长被用于后续的空闲空间计算。
每个行指针都是 LP_NORMAL(lp_flags = 1)。每个元组的 t_xmax = 0:自插入后无人触碰。每个 t_ctid 指向自身。这是一个完全干净的页面。
制造死元组
现在删除一些行:
DELETE FROM vacuum_demo WHERE id % 3 = 0;
删除了 16 行(ID 为 3,6,9,12 等)。在 VACUUM 执行前查看页面:
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 | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------+--------
1 | 1 | 8056 | 135 | 746 | 0 | (0,1)
2 | 1 | 7920 | 135 | 746 | 0 | (0,2)
3 | 1 | 7784 | 135 | 746 | 747 | (0,3)
4 | 1 | 7648 | 135 | 746 | 0 | (0,4)
5 | 1 | 7512 | 135 | 746 | 0 | (0,5)
6 | 1 | 7376 | 135 | 746 | 747 | (0,6)
7 | 1 | 7240 | 135 | 746 | 0 | (0,7)
8 | 1 | 7104 | 135 | 746 | 0 | (0,8)
9 | 1 | 6968 | 135 | 746 | 747 | (0,9)
10 | 1 | 6832 | 135 | 746 | 0 | (0,10)
观察第 3、6、9 行:它们的 t_xmax 现在为 747(DELETE 语句的事务 ID)。但其他所有内容未变。lp_flags 仍为 1(LP_NORMAL),lp_off 和 lp_len 相同。元组仍然物理存在于页面上,占用空间。页面头部也未改变:
SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('vacuum_demo', 0));
结果不变:
lower | upper | special | pagesize
-------+-------+---------+----------
224 | 1392 | 8192 | 8192
pd_lower 和 pd_upper 与 DELETE 前完全相同。PostgreSQL 通过标记 t_xmax 将行标记为死亡,但未回收任何字节。死元组成为膨胀(bloat),直到 VACUUM 到来。
VACUUM 处理一张表的方式
在运行 VACUUM 并观察页面变化之前,理解 VACUUM 的三个遍历阶段至关重要——正是这些阶段的划分解释了为何被删除元组的存储在一个时刻消失,而其行指针在另一个时刻消失。本文将重点关注这个时间差。
阶段 1:堆扫描——修剪、冻结、收集死 TID
VACUUM 顺序扫描每个堆页面,这一遍所做远不止查看。对于每个页面,它执行页面修剪(与普通读取中触发的 heap_page_prune_and_freeze 机制相同)。修剪是真正回收字节的地方:移除死元组的存储、
