← 返回信息流
AI 资讯Hacker News·3 小时前

页面级真空:数据库清理效率新突破

原标题:Vacuum at the Page Level

速览

页面级真空是一种针对数据库页面的高级清理技术,能够有效回收空间并减少性能开销。该技术通过细粒度管理死元组,降低了对全表扫描的依赖。对于需要处理大规模数据的AI系统,这种优化可显著提升数据读取速度。文章还探讨了其在现代数据库架构中的实际应用价值。

AI 深度解读

背景

PostgreSQL 中,当数据行被删除或更新时,旧版本并不会立即释放空间。对于 HOT(Heap-Only Tuple)更新链,PostgreSQL 可以在普通读取操作中通过页面修剪(page pruning)优雅地回收死元组空间,无需等待后台进程。然而,修剪只是捷径:它仅适用于单个页面内的 HOT 更新元组。对于所有其他场景——涉及索引列的非 HOT 更新(cold update)、普通 DELETE、索引条目清理、空闲空间图注册、可见性图维护——都需要 VACUUM。

本文不重复 VACUUM 的操作性指南(如自动清理调优、工作进程分配、死元组清理的操作层面),而是逐字节观察 VACUUM 的工作过程。我们将对每个阶段前后的页面进行快照,精确追踪页面头部、行指针、元组头部、空闲空间图和可见性图的变化。使用的工具同样是 pageinspectpg_visibilitypg_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_offlp_len 相同。元组仍然物理存在于页面上,占用空间。页面头部也未改变:

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

结果不变:

 lower | upper | special | pagesize
-------+-------+---------+----------
   224 |  1392 |    8192 |     8192

pd_lowerpd_upper 与 DELETE 前完全相同。PostgreSQL 通过标记 t_xmax 将行标记为死亡,但未回收任何字节。死元组成为膨胀(bloat),直到 VACUUM 到来。

VACUUM 处理一张表的方式

在运行 VACUUM 并观察页面变化之前,理解 VACUUM 的三个遍历阶段至关重要——正是这些阶段的划分解释了为何被删除元组的存储在一个时刻消失,而其行指针在另一个时刻消失。本文将重点关注这个时间差。

阶段 1:堆扫描——修剪、冻结、收集死 TID

VACUUM 顺序扫描每个堆页面,这一遍所做远不止查看。对于每个页面,它执行页面修剪(与普通读取中触发的 heap_page_prune_and_freeze 机制相同)。修剪是真正回收字节的地方:移除死元组的存储、

查看原文 →boringsql.com