PostgreSQL码农集散地

PostgreSQL 19 发布vacuum优化补丁, 最多可节省50% WAL

PostgreSQL 19 发布vacuum优化补丁, 最多可节省50% WAL

这可能也是PG 经常被诟病的点之一: WAL日志冗余, 例如在更新操作时, 会写WAL, 在vacuum回收被更新掉的dead tuple时, 又要写wal日志. 更有极端情况, 如果开启了wal checksum或记录hit bit更新的WAL, 在修改完hit bit后, 还会产生wal.

怎么减少WAL呢?

PostgreSQL 19 提供了一个补丁, 在vacuum 第三阶段, 最多可节省50% WAL. 这个补丁不是说让PG不写WAL了, 而是合并了一些内容, 从而减少WAL量.

https://github.com/postgres/postgres/commit/add323da40a6bf9e01cdda510e32ea924c89cd1a

Eliminate XLOG_HEAP2_VISIBLE from vacuum phase III  
Instead of emitting a separate XLOG_HEAP2_VISIBLE WAL record for each  
page that becomes all-visible in vacuum's third phase, specify the VM  
changes in the already emitted XLOG_HEAP2_PRUNE_VACUUM_CLEANUP record.  

Visibility checks are now performed before marking dead items unused.  
This is safe because the heap page is held under exclusive lock for the  
entire operation.  

This reduces the number of WAL records generated by VACUUM phase III by  
up to 50%.  

Author: Melanie Plageman <[email protected]>  
Reviewed-by: Andres Freund <[email protected]>  
Reviewed-by: Robert Haas <[email protected]>  
Reviewed-by: Kirill Reshke <[email protected]>  
Discussion: https://postgr.es/m/flat/CAAKRu_ZMw6Npd_qm2KM%2BFwQ3cMOMx1Dh3VMhp8-V7SOLxdK9-g%40mail.gmail.com  

下面用 github copilot 给出逐步解读:先概述 VACUUM 的几个主要阶段(以 PostgreSQL 中常见的划分来说明),然后着重解释为什么“phase III”会产生大量 WAL,以及这个 patch 如何减少 WAL。

  1. VACUUM 的几个阶段(概念性说明)
  • 阶段 I:扫描堆(scan heap / discover dead tuples)
    • 扫描表的 heap 页面,识别出哪些 tuple 已经不可见/死亡(dead / removable),并收集需要进一步处理的信息(比如哪些行可被回收、哪些页面需要被标记为 all-visible 等)。
  • 阶段 II:冻结与标记(freeze / visibility bookkeeping)
    • 在需要时对老事务的 tuple 做 freeze(把 xmin/xmax 固化为可长期保留的形式),并可能更新可见性相关的数据结构(如 visibility map 的个别 bits)。
  • 阶段 III:修剪与清理(prune / vacuum cleanup)
    • 真正把已死的 tuple 从页面上回收(标记 line pointer 为 reusable / unused)、更新 free space map(FSM)、并在适当的时候把页面标记为 all-visible(通过 visibility map,VM)以便未来的扫描可以跳过该页面。
    • 这一阶段在实现上对每个被处理的页面会做一系列修改:删除 line pointers、可能设置 VM bit、更新页面的空闲空间信息等。

(注:不同文档/代码里对“phase”的命名和细分会有些差别,上面是按常见实现意图的高层划分,方便理解本 patch 的影响。)

  1. 为什么 phase III 会产生大量 WAL
  • WAL 的目的是记录对数据库页和可见性数据结构所做的修改,以便崩溃恢复/流复制能够重演这些修改。
  • 在 phase III 中,VACUUM 对大量页面执行“修剪(prune)”操作并且可能把很多页面标记为 all-visible。每当页面上的可见性状态(VM bit)被设置或页面内容被修改时,都会产生 WAL 记录。
  • 在改动较多的表或在大表上运行 VACUUM 时,phase III 会触及成千上万页。若实现为“对每个页面分别发出独立的 WAL 记录来记录 VM 的变化或页面的可见性变化”,就会导致 WAL 记录数显著增多(每页一条或多条 WAL),从而带来更高的 WAL 写入量和复制流量。
  1. 这个 patch 做了什么(基于 commit message 的要点)
  • 主要改动:在 VACUUM 的第三阶段,取消单独的 XLOG_HEAP2_VISIBLE WAL 记录(即为每个“变为 all-visible 的页面”分别写一条 XLOG_HEAP2_VISIBLE),改为把 VM(visibility map)的修改包含进已经存在的 XLOG_HEAP2_PRUNE_VACUUM_CLEANUP WAL 记录中。
    • 也就是说,原来两个步骤分别写两类 WAL 记录(一个用于 prune/cleanup,另一个单独用于 VM 设置),现在把 VM 的变更合并到 prune/cleanup 的那个 WAL 记录里,避免额外的独立 WAL。
  • 可见性检查(visibility checks)被提前:在把死元组标记为 unused 之前就先进行可见性检查。作者指出这样做是安全的,因为对该 heap 页面在整个操作期间是以独占锁(exclusive lock)持有的,因而不会产生并发可见性变化导致的不一致。
  • 结果:VACUUM phase III 产生的 WAL 记录数可以减少,commit message 中指出可减少“最多约 50%”的 WAL 记录(因为消除了那类按页发出的独立 XLOG_HEAP2_VISIBLE 记录)。
  1. 为什么这样做是安全且有效的
  • 合并 WAL:既然在同一个页面上的 prune/cleanup 操作本来就需要写 WAL(XLOG_HEAP2_PRUNE_VACUUM_CLEANUP),把 VM bit 的变更一并记录在同一条 WAL 中并不会丢失信息,反而减少了重复的 WAL header/metadata 开销与额外的 WAL 写入。
  • 可见性检查提前的安全性依据:在处理单页时 VACUUM 会对该页获取足够强的锁,保证在该页的可见性计算与后续修改之间没有并发干扰,因此可以先判定“该页是否可被标记为 all-visible”,然后在同一次修改中把删除、VM 设置都完成并一次性记录到 WAL。
  • 恢复/重放一致性:在崩溃恢复或流复制场景下,重放 XLOG_HEAP2_PRUNE_VACUUM_CLEANUP 加上 VM 变更信息,能够正确恢复页面的 prune 结果与 VM bit 状态,行为等价于原先先重放 prune 再重放单独的 visible 记录。
  1. 影响与好处(总结)
  • WAL 量减少:对大型表或在 VACUUM 处理大量页面的情况下,WAL 记录数显著下降(commit 估计最多 ~50% 的 WAL 记录减少)。
  • IO 与复制带宽下降:写 WAL 的 IO 负担减轻,流复制/归档的 WAL 量减少,能带来性能/带宽方面的好处。
  • 代码复杂度:实现上需要把 VM 变更的信息编入已有的 prune/cleanup record,并确保恢复逻辑一并处理这些信息;同时调整操作顺序(提前做可见性检查),但作者认为在页级独占锁下这是安全的。
  • 审核与作者:提交者是 Melanie Plageman,提交消息里也列出了几位 reviewer(Andres Freund、Robert Haas、Kirill Reshke),并给出了讨论链接(邮件列表讨论)。
  1. 关键术语速记
  • VM(visibility map):按页记录“该页是否对所有事务都是可见(all-visible)”的位图,允许未来的扫描跳过整个页面。
  • XLOG_HEAP2_VISIBLE:以前用于记录“某个页面变为 all-visible”这一单独事件的 WAL 记录类型。
  • XLOG_HEAP2_PRUNE_VACUUM_CLEANUP:记录 phase III 中 prune/cleanup(回收 dead tuples、更新页面结构、FSM 更新等)相关变更的 WAL 记录类型。patch 将 VM 变更并入该记录。
  1. 一个简单示例说明合并前后 WAL 记录数量的对比估算

下面的计算假设了 VACUUM 在 phase III 处理的页面总数和其中变为 all-visible 的比例,然后分别按“合并前(prune record + 单独 visible record)”和“合并后(把 VM 变化并入 prune record)”计算 WAL 记录数与大致字节量。下面是结果和推导;

假定与符号

  • N = 被 VACUUM phase III 处理的总页数(被“prune/cleanup”访问的页)。
  • V = 其中变为 all-visible 的页数(0 ≤ V ≤ N)。
  • P = 仅被 prune 但不变为 all-visible 的页数 = N - V。
  • 假设合并前每个被 prune 的页面至少产生一个 XLOG_HEAP2_PRUNE_VACUUM_CLEANUP 记录;对于那些变为 all-visible 的页面,额外再产生一个 XLOG_HEAP2_VISIBLE 记录(每页一条)。
  • 合并后:把 VM 改动放进已有的 prune record —— 所以每个被 prune 的页面只产生 1 条 WAL(包含 VM 改动的情况)。

记录数量(按页计)

  • 合并前总 WAL 记录数 = P * 1 + V * (1 + 1) = (N - V) + 2V = N + V
  • 合并后总 WAL 记录数 = N (每页 1 条)
  • 减少的记录数 = (N + V) - N = V
  • 相对减少比例 = V / (N + V)

示例 1(具体数值,典型场景)

  • 设 N = 1000 页,V = 600 页 变为 all-visible(即 60%)。
  • 合并前:记录数 = N + V = 1000 + 600 = 1600 条
  • 合并后:记录数 = N = 1000 条
  • 绝对减少 = 600 条,比例减少 = 600/1600 = 37.5%

示例 2(极端上限)

  • 若每页都变为 all-visible(V = N),则:
    • 合并前:记录数 = N + N = 2N
    • 合并后:记录数 = N
    • 绝对减少 = N,比例减少 = 50%
  • 这就是 commit 说“最多约 50%”的来源(当每个被处理的页都需要单独 visible 记录时)。

示例 3(没有页变为 all-visible)

  • V = 0:
    • 合并前 = N,合并后 = N,没变化(因为没有 XLOG_HEAP2_VISIBLE 可以合并)。

字节量的估算(示例性)

  • 假设平均 WAL record 大小:
    • prune record(XLOG_HEAP2_PRUNE_VACUUM_CLEANUP):约 300 bytes(示例数)
    • visible record(XLOG_HEAP2_VISIBLE):约 120 bytes(示例数)
  • 对示例 1(N=1000, V=600):
    • 合并前字节 = 1000 * 300 + 600 * 120 = 300000 + 72000 = 372000 bytes
    • 合并后字节 = 1000 * 300 = 300000 bytes
    • 节省 ≈ 72000 bytes(约 19% 的字节量减少,注意百分比取决于单条 visible 记录相对大小)
  • 对示例 2(V=N):
    • 合并前字节 = 2N*(相应平均) = N*(300+120)=420*N
    • 合并后字节 = 300*N
    • 节省 = 120*N => 28.6% 的字节减少(按这些假设大小计算)

注意

  • 实际减少量受多种因素影响:有多少页被访问(N)、多少页要设置 VM bit(V)、每种 WAL 记录的实际大小(和是否包含额外元数据)、以及是否存在只需要 VM 改动而不需要 prune 的页面(那种情况下原实现会只有 visible 记录;patch 需要在没有 prune record 时仍能记录 VM 改动 —— 实现细节会决定是否也会生成一个合并后的记录)。
  • 上面数字是示例估算,用来说明原理。真实环境里可以从 pg_stat_* 或 pg_class.reltuples/relpages 等统计推断 N 与 V 的大致值,再按 WAL 平均条目大小做更精确计算。