页面级的VACUUM

Hacker News Top 新闻

摘要

本文提供了对PostgreSQL在页面级别的VACUUM过程的逐字节分析,使用pageinspect展示死元组空间是如何被回收的。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/09 13:36

# 页面级别的 VACUUM 来源:https://boringsql.com/posts/vacuum-at-the-page-level/ 在《Postgres 中的 HOT 更新》(https://boringsql.com/posts/hot-updates/)中,我们介绍了页面修剪清理 HOT 链,这是 PostgreSQL 在普通读取时回收死元组空间的一种优雅捷径,无需等待任何后台进程。但修剪确实只是一个捷径:它只在一个页面内生效,且仅对 HOT 更新的元组有效。对于其他所有情况(涉及索引列的冷更新、普通 DELETE、索引条目清理、空闲空间映射注册、可见性映射维护),我们需要 VACUUM。 本文不会重复 VACUUM 的操作内容。《DELETE 很困难》(https://boringsql.com/posts/deletes-are-difficult/)一文涵盖了 autovacuum 调优、工作者分配以及死元组清理的操作层面。而这里,我们将逐字节观察 VACUUM 的工作过程。我们将对每个阶段前后的页面进行快照,精确跟踪页面头部、行指针、元组头部、空闲空间映射和可见性映射的变化。使用的工具和以往一样:`pageinspect`、`pg_visibility` 和 `pg_freespacemap`。 ## 设置https://boringsql.com/posts/vacuum-at-the-page-level/#setup 我们需要一个表,其中包含足够多的行,以便前后对比有意义,同时还需要索引来展示完整的 VACUUM 周期。 ```sql 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; ``` 五十行数据,每行有效负载 100 字节。主键提供了一个索引,这一点很重要:当涉及索引时,VACUUM 的行为会发生变化。先执行一次 VACUUM,这样我们从干净的基线开始: ```sql VACUUM vacuum_demo; ``` ## 删除任何数据前的快照https://boringsql.com/posts/vacuum-at-the-page-level/#snapshot-before-any-deletes 记录页面 0 的基线状态。首先是页面头部: ```sql SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0)); ``` ``` lower | upper | special | pagesize -------+-------+---------+---------- 224 | 1392 | 8192 | 8192 (1 row) ``` pd_lower 为 224:这是 24 字节的页面头部加上 50 个行指针(每个 4 字节,24 + 200 = 224)。pd_upper 为 1392,因此元组占据从 1392 到 8191 的字节。空闲空间为 1392 - 224 = 1168 字节。剩余空间不多了;那些 100 字节的有效负载累积起来不少。 现在查看行指针和元组头部: ```sql 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) (10 rows) ``` `lp_len` 为 135,是元组的实际字节长度,但每个元组在页面上占用一个 MAXALIGN 后的 136 字节槽位;注意 `lp_off` 值每次递减 136。后面的空闲空间计算正是基于这个对齐步长。 所有行指针都是 LP_NORMAL(lp_flags = 1)。所有元组的 `t_xmax = 0`:自插入以来,这些行未被任何操作触及。所有 `t_ctid` 都指向自身。这是一个完全干净的页面。 ## 创建死元组https://boringsql.com/posts/vacuum-at-the-page-level/#create-dead-tuples 现在,让一些行变成死的: ```sql DELETE FROM vacuum_demo WHERE id % 3 = 0; ``` ``` DELETE 16 ``` 这删除了大约三分之一的行:ID 为 3、6、9、12 等。现在有 16 行是死的。在 VACUUM 运行之前,查看页面: ```sql 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) (10 rows) ``` 查看第 3、6 和 9 行。它们的 `t_xmax` 现在为 747,即 DELETE 语句的事务 ID。但其他一切都没有变化。lp_flags 仍为 1(LP_NORMAL)。lp_off 和 lp_len 相同。这些元组仍然物理上驻留在页面上,消耗着空间。页面头部也没有变化: ```sql SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0)); ``` ``` lower | upper | special | pagesize -------+-------+---------+---------- 224 | 1392 | 8192 | 8192 (1 row) ``` pd_lower 和 pd_upper 与 DELETE 之前完全相同。PostgreSQL 将行标记为死(通过标记 t_xmax),但没有回收一个字节。死元组就是膨胀,它们将一直保持这种状态,直到 VACUUM 到来。 ## VACUUM 如何处理表https://boringsql.com/posts/vacuum-at-the-page-level/#how-vacuum-processes-a-table 在运行 VACUUM 并观察页面变化之前,了解*它是如何*运行的,这将解释快照即将展示的一切。VACUUM 分三个阶段完成工作,而它们之间的划分正是已删除元组的存储在一个时刻消失、而其行指针在另一个时刻消失的全部原因。请留意这个间隙:这正是本文其余部分所要展示的内容。 ### 阶段 1:堆扫描 —— 修剪、冻结、收集死 TIDhttps://boringsql.com/posts/vacuum-at-the-page-level/#phase-1-heap-scan-prune-freeze-collect-dead-tids VACUUM 顺序扫描每个堆页面,这第一次遍历所做的远不仅仅是查看。对于每个页面,它会执行页面修剪(与普通读取期间机会性地触发的 `heap_page_prune_and_freeze` 机制相同)。修剪才是字节真正回收的地方:它移除死元组的存储、整理页面碎片,并推进 pd_upper。它还会机会性地冻结那些足够老的元组。 在 PostgreSQL 17 之前,VACUUM 将死 TID 存储在一个从 `maintenance_work_mem` 分配的扁平数组中。如果数组满了,VACUUM 必须暂停,对已收集的批次执行索引和堆清理,然后继续扫描。从 PostgreSQL 17 开始,VACUUM 使用基于基数树的 TID 存储,这种存储方式内存效率高得多,使 `maintenance_work_mem` 不太可能成为瓶颈。 但这里有一个细微之处,决定了本文其余部分的方向。修剪不能直接将被删除元组的行指针标记为 LP_UNUSED,因为索引仍然通过 TID 指向它。因此,对于带有索引的表,死元组的行指针被设置为 **LP_DEAD**:它的存储已消失,但 4 字节的槽位仍然保留,占据 TID 的位置,直到索引条目被移除。这些 LP_DEAD 的 TID 就是 VACUUM 收集到其死 TID 存储中、用于下一阶段的内容。 ### 阶段 2:索引清理https://boringsql.com/posts/vacuum-at-the-page-level/#phase-2-index-cleanup 有了死 TID 列表后,VACUUM 扫描表上的每个索引。对于每个索引,它遍历所有索引条目,并移除指向死 TID 的条目。这是代价高昂的部分。VACUUM 必须读取每个索引页面,即使只需要移除少量条目。 这也是索引膨胀发生的原因。如果 VACUUM 无法完成此阶段(因为长事务阻碍了可见性边界,或者表有很多索引且死 TID 列表超出内存),指向死元组的索引条目就会累积。我们在《VACUUM 是一个谎言》(https://boringsql.com/posts/vacuum-is-lie/)中详细讨论了索引膨胀的影响。 ### 阶段 3:堆清理 —— 释放行指针https://boringsql.com/posts/vacuum-at-the-page-level/#phase-3-heap-cleanup-freeing-the-line-pointers 在索引清理干净后,没有任何索引条目再引用那些死 TID,因此保留的槽位终于可以释放了。VACUUM 重新访问每个包含死元组的页面,并执行阶段 1 无法完成的操作: 1. 将每个已收集的 LP_DEAD 行指针翻转为 LP_UNUSED(0),回收槽位 2. 如果所有剩余的元组对所有人都可见,则设置可见性映射位 一个**没有索引**的表完全跳过这种拆分:由于没有索引条目需要担心,阶段 1 的修剪会在单次堆遍历中直接将死行指针设置为 LP_UNUSED,并且没有阶段 3。这种两步舞蹈(先 LP_DEAD,后 LP_UNUSED)正是因为索引而存在的。 请注意这个列表中*没有*的内容。元组数据已经在阶段 1 的修剪中被移除,页面也已经在那个阶段被碎片整理;正是那时 pd_upper 发生了变化。阶段 3 回收的是行指针槽位,而不是元组字节。如果你只在普通的 `VACUUM` 前后查看,这个区别是不可见的,所以让我们让它变得可见。 我们分两步运行 VACUUM,以捕获中间状态。`VACUUM (INDEX_CLEANUP OFF)` 执行阶段 1 的修剪,但跳过索引清理,因此也跳过了第二次堆遍历;它别无选择,只能将死的行指针保留为 LP_DEAD。 ## 修剪后:LP_DEAD,空间已经回收https://boringsql.com/posts/vacuum-at-the-page-level/#after-the-prune-lp-dead-and-space-already-back 修剪后的页面头部: ```sql SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0)); ``` ``` lower | upper | special | pagesize -------+-------+---------+---------- 224 | 3568 | 8192 | 8192 (1 row) ``` pd_lower 仍然是 224;行指针数组没有缩小。但 pd_upper 从 1392 跃升到 3568。这意味着回收了 2176 字节空间(16 个死元组,每个 136 字节对齐步长 = 2176)。页面上的空闲空间从 1168 变成了 3344 字节。而且我们还没有触及任何索引:这一切都发生在修剪期间,在第一次堆遍历中。 现在查看行指针: ```sql 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 | 3 | 0 | 0 | | | 4 | 1 | 7784 | 135 | 746 | 0 | (0,4) 5 | 1 | 7648 | 135 | 746 | 0 | (0,5) 6 | 3 | 0 | 0 | | | 7 | 1 | 7512 | 135 | 746 | 0 | (0,7) 8 | 1 | 7376 | 135 | 746 | 0 | (0,8) 9 | 3 | 0 | 0 | | | 10 | 1 | 7240 | 135 | 746 | 0 | (0,10) (10 rows) ``` 行指针 3、6 和 9 现在为 **lp_flags = 3 (LP_DEAD)**,而不是 LP_UNUSED。它们的存储已消失(lp_off 和 lp_len 为 0),但槽位仍然存在,保留着那些 TID。它们还不能被释放,因为主键仍然指向它们: ```sql SELECT live_items FROM bt_page_stats('vacuum_demo_pkey', 1); ``` ``` live_items ------------ 50 (1 row) ``` 五十个索引条目,与删除前相同。我们跳过的索引清理正好会移除那十六个过期的条目,而在它运行之前,这些 LP_DEAD 槽位将一直卡住。 ## 完整 VACUUM 之后:LP_UNUSEDhttps://boringsql.com/posts/vacuum-at-the-page-level/#after-the-full-vacuum-lp-unused 现在运行一个普通的 VACUUM 来完成工作: ```sql VACUUM vacuum_demo; SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0)); ``` ``` lower | upper | special | pagesize -------+-------+---------+---------- 224 | 3568 | 8192 | 8192 (1 row) ``` pd_upper 保持不变,仍是 3568。这就是关键:第二次堆遍历没有回收任何元组字节;因为已经没有剩余的元组字节了,修剪已经把它们拿走了。它所做的只是释放行指针槽位,现在索引已经干净了: ```sql 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 | 0 | 0 | 0 | | | 4 | 1 | 7784 | 135 | 746 | 0 | (0,4) 5 | 1 | 7648 | 135 | 746 | 0 | (0,5) 6 | 0 | 0 | 0 | | | 7 | 1 | 7512 | 135 | 746 | 0 | (0,7) 8 | 1 | 7376 | 135 | 746 | 0 | (0,8) 9 | 0 | 0 | 0 | | | 10 | 1 | 7240 | 135 | 746 | 0 | (0,10) (10 rows) ``` 行指针 3、6 和 9 已从 LP_DEAD 变为 **lp_flags = 0 (LP_UNUSED)**。这些槽位现在为空,可供下一个落在这个页面上的 INSERT 重用。并且索引已降至 34 个条目;那十六个过期的条目已经消失: ```sql SELECT live_items FROM bt_page_stats('vacuum_demo_pkey', 1); ``` ``` live_items ------------ 34 (1 row) ``` 幸存的堆元组在修剪时已经被压缩;注意 lp_off 值与 VACUUM 之前的位置相比发生了变化,VACUUM 移动了幸存的元组,以形成一个位于 pd_lower 和 pd_upper 之间的连续空闲块。 **pd_lower 没有变化。**即使行指针 3、6 和 9 现在是 LP_UNUSED,它们仍然占据着数组中的槽位。下一个 INSERT 将重用这些槽位之一,而不是在末尾追加一个新的。PostgreSQL 通过页面头部中的 `PD_HAS_FREE_LINES` 标志来跟踪可重用槽位。 ## 行指针的生命周期,精确说明https://boringsql.com/posts/vacuum-at-the-page-level/#the-line-pointer-lifecycle-precisely 我们来精确说明行指针如何在状态之间转换。这一点很重要,因为不同的代码路径会产生不同的转换: **LP_UNUSED (0)**:槽位为空。要么从未使用过,要么 VACUUM 已完全回收。下一个针对此页面的 INSERT 将获取这个槽位,并分配给一个新元组,将其转换为 LP_NORMAL。 **LP_NORMAL (1)**:槽位指向页面上的一个元组。该元组可能是活的(t_xmax = 0 或 t_xmax 已中止),也可能是死的(t_xmax 已提交且对所有事务不可见)。行指针本身不编码活性;这由元组头部和我们在《PostgreSQL MVCC,逐字节详解》(https://boringsql.com/posts/postgresql-mvcc-byte-by-byte/)中介绍的可见性规则来决定。 **LP_REDIRECT (2)**:槽位指向的不是元组,而是另一个行指针。这是由 HOT 修剪创建的:当 HOT 链的头部被修剪时,它的行指针变成一个重定向,这样索引(仍然引用原始的行指针编号)可以跟随重定向找到当前版本。我们在《Postgres 中的 HOT 更新》(https://boring

相似文章

Postgres中唯一可扩展的删除操作是DROP TABLE

Hacker News Top

本文解释了为什么在Postgres中进行大规模DELETE操作效率低下且会增加额外工作,并建议使用DROP TABLE或TRUNCATE作为批量数据删除的更可扩展的替代方案。

Postgres by Example

Hacker News Top

一份使用带注释的SQL示例的PostgreSQL实践入门,涵盖从基础到高级主题。