第 03 章

回收与持久化:VACUUM、冻结、WAL

上一章揭示了死元组和膨胀——这一章讲 PG 怎么回收它们、怎么防止事务 ID 用尽、以及怎么保证崩溃后不丢数据。死元组是 MVCC「写从不原地改」的必然产物,膨胀是它在物理层的账单。本章把这笔账结清:VACUUM 回收空间、冻结防住事务 ID 回卷、WAL 保证一行写下去就再也不丢。

第 2 章留下两个悬而未决的问题:堆里那些再没人能看到的旧版本(死元组)谁来清?以及,xmin/xmax 这些事务 ID 是有限的 32 位整数,用完了会怎样?这一章给出 PG 的两个答案——VACUUM(兼管回收与冻结)和 WAL(兼管持久与恢复)。前者是 MVCC 的后勤部队,把「为并发把清理后移」这笔账按时结掉;后者是另一条独立的主线——它不解决可见性,而是回答「机器断电的那一刻,已提交的数据凭什么还在」。两条线在本章交汇:VACUUM 决定数据库能不能长期健康运行,WAL 决定它能不能在崩溃后原样醒来。

本章你将建立的 schema

  • 普通 VACUUM 原地标记可复用——扫描表,把死元组占的空间在 free space map 里标为「可再用」,不加排他锁、读写照常,但不把文件还给操作系统。
  • VACUUM FULL 重写才还盘给 OS——把整表重写进新文件,加全表排他锁;这是唯一能让磁盘占用真正下降的内置手段。
  • 冻结防 xid 回卷——事务 ID 是 32 位、会绕回原点;VACUUM 把足够老的版本标为「永远可见」,否则回卷后老数据会突然消失。
  • 可见性图同时服务回收与查询——一份 per-page 的元数据,既让 VACUUM 跳过整页全可见的页,又让 index-only scan 免去回堆。
  • WAL 先写日志保证持久与恢复——改数据页之前先把变更顺序写进日志;崩溃后重放日志即可恢复到一致状态。把随机写换成顺序写。

§1普通 VACUUM:原地标记可复用,不还盘

普通 VACUUM 扫描表,把死元组的空间在 free space map 里标为可复用,不加排他锁、不缩小文件。

为什么需要它

第 2 章立下因果链:为让读不阻塞写、写不阻塞读,PG 把旧版本的清理工作后移。这笔被后移的债,由 VACUUM 来还。它必须满足一个苛刻条件——清理本身不能再次阻塞业务,否则就是拆东墙补西墙。所以普通 VACUUM 的全部设计都围绕「在线、不打断读写」展开:它只做最轻的动作(把死元组的空间标记为可复用),而把代价最重的动作(搬数据、缩文件)留给另一条命令。理解了这条边界,就理解了为什么「VACUUM 跑完表却没变小」不是 bug,而是设计。

普通 VACUUM(不带 FULL)做的事,可以拆成三个动作:

  • 找死元组。 扫描表的各个 Page,对每个 tuple 用「最老仍在运行的事务」做判定:若这个版本连最老的事务都看不到了,它就是死元组,可以回收。
  • 标记空间可复用。 死元组占的槽位被释放,其空间登记进该表的 free space map(FSM,空闲空间图)。后续 INSERT/UPDATE 找落脚点时会查 FSM,把新版本塞进这些被腾出的空洞。
  • 顺带维护索引与可见性图。 清理指向死元组的索引项,并更新 可见性图(§4)。

两个关键事实,必须钉死:

其一,普通 VACUUM 不加排他锁。 它只取一个较弱的锁(SHARE UPDATE EXCLUSIVE),允许并发的 SELECT/INSERT/UPDATE/DELETE 继续跑——它和业务读写共存,这正是它能作为日常后台任务(§2 的 autovacuum)不停运行的前提。

其二,普通 VACUUM 不把文件还给操作系统。 死元组的空间被标为「内部可复用」,但表文件的总大小(高水位线)不下降。从操作系统看,这张表占的磁盘一个字节都没少;省下的空间只是被 PG 留作内部周转。

底层机制(比文档深一层)。 为什么普通 VACUUM 宁可不还盘?因为还盘要付两笔它不愿付的代价。第一,把文件尾部缩短需要保证尾部的 Page 全空,这往往要先把存活 tuple 从尾部搬到前面的空洞里——搬数据就得改它们的物理位置,进而要更新所有指向它们的索引,开销直逼重写。第二,绝大多数表的大小是波动的:今天清出 200MB 空闲,明天的写入又会把它填回去;现在费力还给 OS、明天再向 OS 申请,等于做了两趟无用功,中间还会引发文件系统层的碎片。于是 PG 的判断是:把空闲留在表内周转,是比「还盘—再申请」更省的稳态。只有当一张表确实一次性清出了大量、且预期不会再涨回去的空间时,归还才划算——那时才动用 §3 的 VACUUM FULL 或 pg_repack。这条「轻量回收是常态、还盘是例外」的取舍,是理解 PG 运维的第一性原理。

类比 · 图书馆还书车

普通 VACUUM 像图书馆里的「待上架车」:读者还回的书(死元组)不立刻搬回原书架的物理槽,而是先登记到一张「哪些位置空了」的清单(FSM),等下一本同类新书来,直接填进最近的空位。书架本身(表文件)的长度不变。类比边界:图书馆的书是唯一的;而 PG 里同一行会因更新留下多个版本,VACUUM 清的是「再没有任何读者的借阅快照能借到」的那些旧版本——这个「按快照判定能否回收」是普通还书车类比覆盖不到的,正是它必须依赖「最老运行事务」这把尺子的原因(见 §10 的预测题)。

§2autovacuum:什么时候自动触发

后台进程按「死元组数超过阈值 + 比例」自动触发 VACUUM,并按比例顺带触发 ANALYZE。

为什么需要它

VACUUM 既然是日常债务,就不能靠人记着手动还。autovacuum 是 PG 自带的后台守护进程,盯着每张表的死元组累积量,到点自动派 worker 去清理。它存在的意义是把「回收」从一次性的人工操作,变成与写入速率自适应的常态后台过程——写得多就清得勤,写得少就少打扰。绝大多数 PG 实例的健康,取决于 autovacuum 有没有跟上写入节奏。

对一张表,autovacuum 触发 VACUUM 的判据是一个阈值公式。当该表的死元组数(n_dead_tup)超过下面这个动态阈值,就触发:

autovacuum 触发阈值(VACUUM) text
触发阈值 = autovacuum_vacuum_threshold          -- 默认 50
         + autovacuum_vacuum_scale_factor       -- 默认 0.2
         × reltuples                            -- 该表的估算总行数

-- 例:一张 100 万行的表
--   阈值 = 50 + 0.2 × 1,000,000 = 200,050 个死元组

常数项 autovacuum_vacuum_threshold(默认 50)是给小表兜底的下限;比例项 autovacuum_vacuum_scale_factor(默认 0.2)让阈值随表规模线性放大——一张百万行的表,要攒到约 20 万死元组才会被清。这个设计避免了大表上 autovacuum 过于频繁,但也埋了一个尾巴:超大表的 0.2 比例意味着要积累海量死元组才触发,常需手工把这两个参数按表调小。

同一套机制还驱动 ANALYZE。autovacuum 按一个并行的公式(autovacuum_analyze_threshold 默认 50、autovacuum_analyze_scale_factor 默认 0.1,乘以表的变更行数)触发 ANALYZE,重新采样、更新该表的统计信息。统计信息是查询规划器估算行数与代价的依据——它准不准,直接决定规划器选对还是选错执行计划(这条链留到 第 5 章 §统计信息展开)。所以 autovacuum 实际上一岗双责:既维护物理健康(回收),又维护规划健康(统计)。

常见失败模式 · autovacuum 跟不上

当写入速率持续超过 autovacuum 的清理速率,死元组的产生快过回收,表就会单调膨胀——表现为表/索引体积持续上涨、顺序扫描越来越慢。根因通常是默认的 scale_factor=0.2 对大表太宽松,或 autovacuum worker 数量/autovacuum_vacuum_cost_limit 限速太保守。处置方向:对高写入表用 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01) 之类按表调小阈值,并放宽 worker 的成本限速。这不是参数玄学,而是让回收速率重新追上写入速率。

§3VACUUM FULL:重写整表,把空间还给 OS

VACUUM FULL 把整表重写进一个新文件、加全表排他锁,是唯一能把磁盘真正还给操作系统的内置方式。

为什么需要它

普通 VACUUM 永远不缩小文件——可总有需要缩的时候:一张表删掉了 90% 的数据、或经历过一次失控膨胀,几十 GB 的文件里大半是空洞,且预期不会再涨回去。这时「把空闲留在表内周转」的逻辑失效了,那些空间就该真正交还给操作系统。VACUUM FULL 就是为这个例外场景准备的重武器。它存在的全部理由,就是普通 VACUUM 刻意不做的那件事——还盘。

VACUUM FULL 的机制与普通 VACUUM 完全不同:它不是「就地标记」,而是把整张表的存活 tuple 逐个搬进一个全新的文件,新文件紧凑无空洞、索引一并重建,然后用新文件替换旧文件、把旧文件交还操作系统。三个直接后果:

  • 加 ACCESS EXCLUSIVE 锁——全表阻塞。 这是 PG 最强的表级锁,重写期间该表的所有读和写全部被挡在门外。表越大,阻塞越久。
  • 临时占用接近双倍磁盘。 重写过程中新旧两个文件同时存在,峰值磁盘占用约为表体积的两倍;磁盘紧张时这本身就会让操作失败。
  • 结束后磁盘真正下降。 旧文件释放后,操作系统看到的占用随之回落——这是普通 VACUUM 做不到的。
普通 VACUUM 同一文件 · 大小不变 live dead live dead live live 可复用 空洞 live 可复用 空洞 live 文件等长 不还盘 · 不锁表 VACUUM FULL 重写到新文件 · 还盘 live dead live dead live 旧文件释放 重写 live live live 紧凑 · 变小 ACCESS EXCL 锁
图 3.1普通 VACUUM(左)在同一文件内把死元组标为可复用空洞,文件大小不变、不锁表;VACUUM FULL(右)把存活行重写进新文件、旧文件释放还盘给 OS,代价是全程 ACCESS EXCLUSIVE 锁全表阻塞。注意:表「瘦下来还盘」只有 FULL 能做,且重写时新旧文件并存、峰值占用近双倍。

底层机制(比文档深一层)。 VACUUM FULL 与普通 VACUUM 的分野,本质是「就地标记 vs 整体搬迁」两种回收哲学的代价分配。就地标记(普通)把成本压到最低、可在线运行,但永远换不来文件收缩;整体搬迁(FULL)能换来收缩,但搬迁必然要冻结全表、且过程中双份占用。生产环境里 VACUUM FULL 的全表锁往往不可接受,因此社区的事实标准是扩展 pg_repack:它在线重建表(用触发器捕获重写期间的增量改动,最后只在极短的切换瞬间持锁),达到「收缩文件」的效果而几乎不阻塞业务。结论:VACUUM FULL 是「能还盘但要停服」的内置兜底,pg_repack 是「能还盘且基本不停服」的生产首选——但两者都只在「确实需要还盘」这个例外场景出场,日常仍靠 autovacuum。

§4冻结:把老版本标为永远可见

VACUUM 把足够老的版本的 xmin 改写为「已冻结」,使其对所有未来事务永久可见。

为什么需要它

第 2 章的可见性判定,靠的是把 tuple 的 快照里的事务 ID 与 xmin/xmax 比大小。这个「比大小」隐含一个致命前提:事务 ID 单调递增、可比较。可事务 ID 是有限的 32 位整数——它会用完、绕回原点。一旦绕回,「比大小」的语义就崩了:一个很老的 xmin 会显得比当前事务还新。冻结就是为这个绝境准备的:把老到「肯定对所有人可见」的版本,从「按 xid 比大小」的体系里豁免出去,永久标记为可见,于是它的 xmin 数值大小再不参与判定。冻结不是性能优化,是防止数据凭空消失的硬约束。

机制本身不复杂:VACUUM 在扫描时,对那些 xmin 对应的事务已经老到「早于所有仍在运行的事务」的 tuple,把它的状态标记为 frozen(已冻结)。被冻结的 tuple 在可见性判定时被无条件视为「其创建事务已提交、对所有人可见」,不再去比 xmin 的数值。冻结信息记录在 tuple 头的标志位里(现代 PG 用 xmin 的提交标志位表达,无需真的改写 xmin 的数值,但效果等价于「永久可见」)。

这一节和 §5 是同一个问题的两面:冻结是手段,回卷是它要防的灾难。 先把手段记牢——「老到一定程度的版本会被冻结成永久可见」,§5 解释不冻结会塌方成什么样。

§5事务 ID 回卷:不冻结,老数据会消失

事务 ID 是 32 位、约 21 亿个可见范围后绕回;不及时冻结,回卷会让老行突然不可见。

为什么需要它

理解回卷,才理解 PG 为什么宁可停止写入也要逼着 VACUUM 跑。这是 PG 运维里最硬的一条红线:可以容忍膨胀、容忍慢查询,但绝不能容忍事务 ID 用尽。因为前两者是性能问题,后者是正确性问题——数据会看起来凭空消失。

事务 ID(txid)是 32 位无符号整数。但 PG 的可见性判定不是用整个 2³² 空间,而是用环形比较:对任意一个事务,「过去」是它之前的约 2³¹(约 21 亿)个 ID,「未来」是它之后的约 2³¹ 个 ID。整个 ID 空间被想象成一个环,每个事务站在环上某点,一半环是它的过去、一半是未来。

问题来了:随着新事务不断分配,这个「当前点」沿环移动。一个 tuple 的 xmin 固定不动,但「当前点」在追它。当当前点绕环走了超过 2³¹,那个老 xmin 就从「在当前点过去 21 亿以内」滑进了「在当前点未来一侧」——它会突然显得像是「一个尚未发生的未来事务创建的」,于是对当前所有事务都不可见。那一行数据明明还在磁盘上,却再也查不出来,看起来像凭空消失。

当前 xid 环上不断前移 过去 2³¹ 内 危险区 看似在未来 0 / 2³² 回卷点 老行 xmin frozen 不参与比大小 永久可见 不冻结则滑入
图 3.2事务 ID 回卷环。当前 xid 沿环不断前移,老行的 xmin 相对地「被追上」;一旦当前点绕过 2³¹,老 xmin 就从「过去」滑进「看似在未来」的危险区而不可见。冻结把老行移出这套比大小的体系、永久可见。注意:冻结不是优化,是防数据消失的硬要求。

底层机制(比文档深一层)。 PG 用两道防线把回卷挡在门外,且越逼近上限手段越激进。第一道:每张表的 pg_class 里记着一个 relfrozenxid——「这张表里最老的、尚未冻结的 xmin」。一个数据库的回卷风险,由全库最老的那个 relfrozenxid 决定,用 age(datfrozenxid) 度量(这个 age 离 2³¹ 越近越危险,§9 会亲手查它)。第二道:当某张表的年龄超过 autovacuum_freeze_max_age(默认 2 亿)时,PG 会对它强制发起一次 anti-wraparound(防回卷)autovacuum——这种 VACUUM 即使你关闭了 autovacuum 也照常运行,专门去推进 relfrozenxid、冻结老行。若连这道防线都被无视,年龄逼近硬上限时,PG 会进入自保模式:停止接受新的写入事务(只读、报错要求手动 VACUUM),以阻止年龄继续增长、用拒绝服务来保住数据正确性。结论:「数据库突然只读、报 wraparound 警告」不是故障,是 PG 用停摆换数据不丢的最后手段——而触发它的根因,几乎总是 autovacuum 长期没跟上。

预测一下

一个一直开着、迟迟不提交的事务(比如某个忘了关的 BEGIN),为什么会同时推高整库的回卷风险,又让 VACUUM 清不动死元组?

展开答案

两个后果同源,都源于这个老事务钉住了「最老仍在运行的事务」这把尺子。

清不动死元组: 普通 VACUUM 判定一个版本能否回收,标准是「连最老的运行事务都看不到它」。只要那个老 BEGIN 还开着,它的快照就仍有权看到这些旧版本,于是所有晚于它的死元组都不能被回收——VACUUM 照常跑,却几乎清不掉东西,表持续膨胀。

推高回卷风险: 同一把尺子也卡住了冻结。VACUUM 不能把「比最老运行事务还新」的版本冻结掉,而那个老事务把这条线死死压在过去——于是 relfrozenxid 推不动,全库年龄持续逼近 autovacuum_freeze_max_age,回卷风险一路上涨。

结论: 长事务是 PG 头号隐患——它一个人就能同时瘫痪回收与冻结两条命脉。监控 pg_stat_activity 里的长事务、设 idle_in_transaction_session_timeout,是比调任何 VACUUM 参数都更治本的手段。

§6可见性图:回收与查询共用的枢纽

可见性图给每个 Page 两位,标记 all-visible / all-frozen;VACUUM 据它跳页,index-only scan 据它免回堆。

为什么需要它

VACUUM 要扫全表很贵,可一张大表里大部分 Page 在两次 VACUUM 之间根本没被改过——重复扫它们是纯浪费。需要一份「哪些 Page 自上次清理后就没动过」的备忘,让 VACUUM 跳过它们。这份备忘一旦存在,就发现它还能解决另一个看似无关的问题:索引扫描时,怎么不回堆就确认一行可见。同一份元数据,两头都要用——这正是可见性图被设计出来的原因。

可见性图(Visibility Map,VM)是每张表附带的一个紧凑位图,给表的每个 Page 分配两个 bit:

  • all-visible 位: 置位表示「这个 Page 里的所有 tuple,对所有当前事务都可见」——即页内没有任何死元组、也没有任何正在被某事务改动的版本。
  • all-frozen 位: 置位表示「这个 Page 里的所有 tuple 都已冻结」——这个 Page 在下一轮防回卷 VACUUM 里可以被直接跳过,无须再检查冻结。

VACUUM 一边清理一边维护这两个位:清完一个全是存活、无人改动的 Page,就给它置上 all-visible;若页内全部冻结,再置上 all-frozen。反过来,下一次 VACUUM 扫描时,遇到 all-visible 的 Page 直接跳过——因为它保证没有死元组可清。这就是为什么「第二次 VACUUM 比第一次快得多」:大量页被 VM 短路掉了。任何对某 Page 的写入(INSERT/UPDATE/DELETE)会清掉该页的 all-visible 位,标记它「又脏了,下次得重新检查」。

底层机制(比文档深一层)。 VM 的精妙在于它是连接「回收」与「查询加速」两条主线的同一个枢纽。回收侧的用法上面讲过;查询侧的用法是 index-only scan(仅索引扫描):当一个查询要的列全在某索引里,规划器本可以只读索引、不回堆——但 MVCC 的麻烦在于,索引项不携带可见性信息,光看索引无法判断这一行对当前事务是否可见,似乎不得不回堆查 xmin/xmax。VM 一举解决了它:扫到一个索引项,先查它指向的 Page 在 VM 里是否 all-visible——若是,则整页都可见,这一行必然可见,于是跳过回堆,直接用索引里的值返回。一份为回收而生的元数据,反手成了查询免回堆的关键。这条「VM → index-only scan」的链,是第 4 章索引性能的核心前提,留到 第 4 章 §仅索引扫描详述。结论:VACUUM 不只是在「打扫卫生」——它每清干净一个 Page 并置上 all-visible,就同时为查询省下了一次回堆。回收做得勤,查询也跟着快,这是同一份 VM 的两头红利。

§7WAL:先写日志,再改数据

改任何数据页之前,先把该变更的 redo 记录顺序写入 WAL;崩溃后重放 WAL 即可恢复——这就是持久性的来源。

为什么需要它

前六节都在管「数据库长期健康」;WAL 管的是另一件正交的事——崩溃那一刻已提交的数据凭什么不丢。难点在于:数据页在内存(shared_buffers)里被改完后,刷回磁盘是异步的、会拖很久;若此时断电,内存里的改动就没了。而事务一旦 COMMIT 返回成功,就向用户承诺了「这笔写入永久生效」(持久性,ACID 的 D)。WAL 是兑现这个承诺的机制:它让「承诺持久」和「数据页真正落盘」解耦——只要变更的日志落了盘,数据页晚点落都不丢。

WAL(Write-Ahead Logging,预写日志)的规则只有一句,却是整个持久性的地基:在修改任何数据 Page 之前,必须先把描述这次修改的 redo 记录,顺序追加写入 WAL,并保证 WAL 已落盘。 数据页本身可以稍后再异步刷盘;但只要 WAL 里记下了「这个 Page 该怎么改」,就够了。崩溃后,PG 从某个已知一致点开始,把 WAL 里的 redo 记录逐条重放(redo / roll-forward),把所有「日志记了、但数据页还没落盘」的改动重新施加一遍,数据库就恢复到崩溃前的一致状态。

① 事务改页 在 shared_buffers ② 先写 WAL 顺序 append 改页前 ③ COMMIT fsync WAL 落盘 WAL 文件 磁盘 · 顺序日志 ④ 数据页 稍后异步刷盘 不阻塞 commit 崩溃后恢复 从 checkpoint 起 读 WAL 重放 redo 到一致状态
图 3.3WAL 写入与崩溃恢复。事务改页前先把 redo 记录顺序 append 进 WAL;COMMIT 时 fsync WAL 落盘即返回,数据页稍后异步刷。崩溃后从最近 checkpoint 起重放 WAL 到末尾。注意:COMMIT 只等 WAL 落盘,不等数据页落盘——这是「持久」与「刷盘」解耦的关键。

底层机制(比文档深一层)。 WAL 的全部威力,来自一次「随机写换顺序写」的交易。数据页散布在表文件各处,刷它们是随机 I/O——磁头乱跳、SSD 写放大,又慢又贵。WAL 则是一个只追加的顺序日志,写它是顺序 I/O——机械盘上是连续磁道、SSD 上也对写入友好,快上一两个数量级。WAL 协议把这两者的代价做了置换:把昂贵的随机写(刷数据页)推迟、批量、异步化,只要求廉价的顺序写(append WAL)在 COMMIT 前同步落盘。于是 COMMIT 的延迟只取决于一次顺序 fsync,而非一堆随机刷盘。换句话说,持久性的成本被从「每次提交刷一堆随机页」压缩成了「每次提交追加一段顺序日志」——这是 WAL 既保证不丢、又不拖垮吞吐的根本原因,也是几乎所有现代数据库(含 InnoDB 的 redo log)共用的设计。

§8检查点:把脏页刷干净、缩短恢复距离

检查点把截至某 WAL 位置的脏页全部刷盘,之后该位置前的 WAL 可回收,恢复也只需从此点重放。

为什么需要它

WAL 让数据页可以无限推迟刷盘,但这带来两个新问题:WAL 会无限增长(每条都得留着,万一恢复要用),且崩溃恢复要从「最早一条还没落盘的改动」重放,距离会极长、恢复极慢。检查点是这两个问题的解药——它定期划一条线:「线之前的所有改动,对应的数据页都已确实落盘」。有了这条线,线之前的 WAL 就能丢弃,恢复也只需从这条线往后重放。

一次检查点(checkpoint)做两件事:把当前所有脏页(已被改、还没刷盘的 Page)刷到数据文件,并在 WAL 里记下一条检查点记录、标明「截至这个 WAL 位置(LSN)的改动均已落盘」。完成后两个收益立刻兑现:

  • WAL 可回收。 这个检查点位置之前的 WAL 段不再为「崩溃恢复」所需(仍会为复制/归档保留),可以被回收或归档,WAL 占用不再无限增长。
  • 恢复距离缩短。 下次崩溃恢复只需从最近一个检查点开始重放,而非从远古的某条记录——检查点越频繁,恢复越快。

检查点由时间(checkpoint_timeout)或 WAL 量(max_wal_size)触发,二者先到先发。这里有个张力:检查点太稀,恢复慢、WAL 堆积;太频,则刷盘 I/O 尖峰频繁、且推高下面 §9 全页写的开销。checkpoint_completion_target 把一次检查点的刷盘摊薄到整个区间里、削平 I/O 尖峰,是这个张力的主要调节钮。

§9全页写:防住撕裂的页

每个检查点后某页第一次被改时,把整页镜像写进 WAL,以防操作系统只写了半个 8KB 页(torn page)。

为什么需要它

WAL 的 redo 记录通常是「增量」——比如「把第 3 槽的某字段从 100 改成 80」。重放它的前提是:磁盘上那个数据页本身是完整且一致的,增量才能正确地叠加上去。但操作系统刷一个 8KB 的 PG 页时,底层会拆成多个更小的扇区分别写——若断电正好发生在写到一半,磁盘上就留下一个半新半旧的撕裂页(torn page)。对着这样一个本身就损坏的页重放增量,结果是错上加错。全页写就是堵这个洞的。

全页写(full-page writes,FPW)的规则:在每次检查点之后,某个 Page 第一次被修改时,PG 不只写增量 redo 记录,而是把这一整个 8KB Page 的镜像完整写进 WAL。同一个检查点周期内,这个 Page 后续再被改,就只写增量了(因为镜像已经在 WAL 里有一份)。

恢复时这样用:重放 WAL 遇到某个 Page 的全页镜像,就用这个完整镜像直接覆盖磁盘上那个或已撕裂的页——无论磁盘上的版本是否半写损坏,都被一个已知完整的镜像替换掉,撕裂被抹平;之后的增量记录再安全地叠加上去。代价很直接:每次检查点之后的一段时间,WAL 量会显著上升(每个被首次触碰的 Page 都要塞进一整页 8KB),这也是检查点不能太频繁的原因之一——检查点越密,「检查点后首次修改」发生得越频繁,全页写的 WAL 膨胀就越严重。

把回收与持久串起来

到此两条主线都已落地。回收线:死元组 → 普通 VACUUM 标记可复用(§1)→ autovacuum 自动触发(§2)→ 真要还盘才用 VACUUM FULL(§3)→ 冻结防回卷是不可省的硬约束(§4–5)→ VM 让回收与查询共享一份元数据(§6)。持久线:WAL 先写日志、把随机写换顺序写(§7)→ 检查点定期刷脏页、缩短恢复并回收 WAL(§8)→ 全页写堵住撕裂页(§9)。下一节把回收线拧成一个可观察的实验。

§10崩溃恢复与 WAL 的其它去处

崩溃恢复 = 从最近检查点重放 WAL 到末尾;同一份 WAL 还驱动流复制与逻辑解码。

为什么需要它

WAL 一旦把「所有数据变更」都顺序记了下来,它就不止能用于崩溃恢复——任何想「跟上这台数据库全部改动」的需求,都能复用这条日志流。理解这一点,才能把 WAL 从「崩溃保险」升级为「PG 复制与 CDC 的统一底座」这一更大的图景。

崩溃恢复是 WAL 的本职:进程重启后,PG 找到最近的检查点记录,从那个 WAL 位置开始顺序重放每一条 redo 记录直到 WAL 末尾,途中用全页镜像修复撕裂页、用增量记录重做改动,最终把数据库带回崩溃前的一致状态。已提交的事务(WAL 已落盘)必然被恢复,未提交的事务的改动则不会出现在最终状态里。

同一份 WAL 流还有两个重要去处,二者由 wal_level 参数控制粒度:

  • 物理流复制(streaming replication)。 主库把 WAL 实时发给一个或多个 standby,standby 持续重放这份 WAL,从而在物理层面(同样的 Page、同样的字节)紧紧跟随主库。这就是 PG 高可用的主力——standby 随时能接替主库,因为它一直在重放主库的 WAL。
  • 逻辑解码(logical decoding)。 当 wal_level = logical 时,WAL 里被额外记入足够的信息,使其可以被解码成「逻辑层面的行变更」(哪张表、哪行、INSERT/UPDATE/DELETE 成什么),而非物理字节。这就是 PG 做 CDC(变更数据捕获)和逻辑复制的基础——下游可以只订阅自己关心的表、甚至跨大版本复制。

从崩溃恢复,到物理高可用,再到逻辑 CDC,同一条 WAL 日志流服务了三层完全不同的需求——这正是「先写日志」这条朴素规则被反复复用的回报。

§11动手观察:从膨胀到回收,再看回卷年龄

把回收线拧成一个可观察的实验:制造膨胀、量它、跑普通 VACUUM 看磁盘没变小、再跑 VACUUM FULL 看文件缩小,最后查全库的回卷年龄。下面的输出未在本机执行(具体数值因实例与版本而异),用于演示该看哪些字段、它们如何变化。

观察膨胀、回收与回卷年龄 sql
-- 0) 造一张表,灌 100 万行
CREATE TABLE t (id int PRIMARY KEY, v int);
INSERT INTO t SELECT g, g FROM generate_series(1, 1000000) g;

-- 1) 大量更新:每行都被改一次 → 产生 100 万个旧版本(死元组)
UPDATE t SET v = v + 1;

-- 2) 量死元组与膨胀
SELECT n_dead_tup, n_live_tup
  FROM pg_stat_user_tables WHERE relname = 't';
--  n_dead_tup | n_live_tup
-- ------------+------------
--    1000000  |   1000000        <- 未在本机执行:死活元组各 100 万

SELECT pg_size_pretty(pg_relation_size('t'));
--  pg_size_pretty
-- ----------------
--  69 MB                          <- 未在本机执行:约翻倍(本应 ~35MB)

-- 3) 普通 VACUUM:标记死元组空间可复用,但不还盘
VACUUM (VERBOSE) t;
--  ... tuples: 1000000 removed, 1000000 remain ...   <- 死元组被标可复用

-- 4) 再看大小 —— 没变!空间被留作内部周转,未还给 OS
SELECT pg_size_pretty(pg_relation_size('t'));
--  pg_size_pretty
-- ----------------
--  69 MB                          <- 未在本机执行:仍占盘,符合预期

-- 5) VACUUM FULL:重写整表到新文件 → 文件真正缩小(全表锁)
VACUUM FULL t;
SELECT pg_size_pretty(pg_relation_size('t'));
--  pg_size_pretty
-- ----------------
--  35 MB                          <- 未在本机执行:还盘后回落

-- 6) 全库回卷年龄:age 越大越逼近 2^31 危险线
SELECT datname, age(datfrozenxid) AS xid_age
  FROM pg_database ORDER BY xid_age DESC;
--   datname  | xid_age
-- -----------+----------
--  appdb     | 48213004              <- 未在本机执行:离 ~21 亿尚远
--  postgres  |   312900

逐行解读,把每个观察值映射回概念:

  • 步骤 1 UPDATE 全表一条 UPDATE 改了全部 100 万行——按第 2 章的因果,这不是原地改,而是写了 100 万个新版本,留下 100 万个旧版本即死元组。膨胀就此产生。
  • 步骤 2 n_dead_tuppg_stat_user_tables 的 n_dead_tup 直接量出死元组数。它正是 §2 autovacuum 阈值公式的输入——这个值越过阈值,autovacuum 就会来清。
  • 步骤 2 表约翻倍pg_relation_size 显示约 69MB——本应约 35MB 的表因死元组占用而近乎翻倍。这就是膨胀在磁盘上的样子。
  • 步骤 3–4 VACUUM 后大小不变普通 VACUUM 报告「1000000 removed」——死元组确实被处理了(空间进入 FSM 可复用),但第 4 步的 pg_relation_size 仍是 69MB。这是 §1 的核心结论的现场:标记可复用 ≠ 还盘。
  • 步骤 5 VACUUM FULL 后缩小VACUUM FULL 把表重写进新文件,磁盘回落到约 35MB——磁盘真正下降。代价是这条命令期间全表被 ACCESS EXCLUSIVE 锁挡住(§3)。
  • 步骤 6 age(datfrozenxid)age(datfrozenxid) 是每个数据库距「最老未冻结事务」的年龄,越大越逼近 2³¹ 的回卷红线(§5)。日常该监控全库最大的这个 age;它若持续上涨且接近 autovacuum_freeze_max_age,说明冻结没跟上。
预测一下

VACUUM 跑完,\dt+ 显示表大小一点没变小,为什么?这是 bug 吗?要让它变小该怎么做?

展开答案

不是 bug,是设计。 普通 VACUUM 只把死元组占的空间在 free space map 里标为「内部可复用」,让后续 INSERT/UPDATE 填进这些空洞;它从不缩短表文件、不把磁盘还给操作系统(§1)。所以 \dt+ 看到的文件大小(高水位线)纹丝不动——省下的空间只是被 PG 留作内部周转。

为什么这样设计: 缩文件要先把尾部存活行搬到前面、再更新所有相关索引,开销近似重写;而表通常会再涨回来,归还+再申请等于做两趟无用功(§1 的底层机制)。

要真缩小: 用 VACUUM FULL(重写整表、还盘,但全表 ACCESS EXCLUSIVE 锁、临时双倍磁盘,见 §3),或生产环境用 pg_repack 在线重建以避免长时间锁表。日常不需要缩——让 autovacuum 维持稳态即可。

自测

  1. 普通 VACUUM 和 VACUUM FULL,哪个会把磁盘空间还给操作系统?哪个会全表加排他锁阻塞读写?
  2. 事务 ID 是多少位?为什么 PG 必须「冻结」足够老的 tuple——不冻结会发生什么?
  3. WAL 协议的核心规则是什么?为什么一个事务 COMMIT 时只需等 WAL 落盘、而不必等它改过的数据页落盘?
  4. 可见性图(VM)的 all-visible 位,分别被「回收」和「查询」两条路怎么用?
查看参考答案

1. 只有 VACUUM FULL 把空间还给 OS——它把整表重写进新文件、释放旧文件,磁盘真正下降;普通 VACUUM 只把死元组空间标为内部可复用,文件大小不变。加全表 ACCESS EXCLUSIVE 锁、阻塞所有读写的是 VACUUM FULL;普通 VACUUM 只取弱锁、与读写共存。

2. 事务 ID 是 32 位,可见性用环形比较、过去/未来各约 2³¹(约 21 亿)。冻结把足够老的 tuple 标为「永久可见」、移出「按 xid 比大小」的体系。不冻结,当当前 xid 绕环超过 2³¹,老 xmin 会从「过去」滑进「看似在未来」,导致那些行突然对所有事务不可见——数据看似凭空消失。这是正确性问题,所以 PG 会在逼近上限时强制防回卷 VACUUM、乃至停止接受写入。

3. 规则:修改任何数据 Page 之前,先把该变更的 redo 记录顺序写入 WAL 并保证其落盘。COMMIT 只等 WAL 落盘,是因为持久性已由 WAL 兑现——即便此刻断电、数据页还没刷,崩溃恢复也能从 WAL 重放出这次改动。本质是把昂贵的随机写(刷数据页)推迟、异步化,只为廉价的顺序写(append WAL)付同步落盘的代价。

4. 回收路:VACUUM 扫描时遇到 all-visible 的 Page 直接跳过(保证无死元组可清),这是「第二次 VACUUM 快得多」的原因。查询路:index-only scan 扫到索引项后,查它指向的 Page 是否 all-visible——若是则整页可见、这一行必然可见,于是免去回堆、直接用索引值返回。同一份 VM、两头复用。

进阶挑战

用一个故意开着的长事务,亲手卡住 VACUUM 的回收

开两个会话证明长事务如何阻断回收。会话 A:BEGIN; SELECT 1; 然后挂着不提交。会话 B:对前面那张 t 表 UPDATE t SET v = v + 1; 制造一批死元组,再 VACUUM (VERBOSE) t;。观察 VERBOSE 输出里「removed」与「dead but not yet removable」的数字——证明大量死元组无法被回收。然后提交会话 A,再 VACUUM (VERBOSE) t;,看死元组这次被清掉。把这个现象与 §5 的预测题对上。

提示

关键看 VERBOSE 里的 tuples: N removed, M remain 以及 M dead but not yet removable 这一行——会话 A 挂着时,not yet removable 会很大,因为这些死元组比「最老仍在运行的事务」(即会话 A)新,VACUUM 不敢回收。用 SELECT backend_xmin FROM pg_stat_activity WHERE pid = ...; 能看到会话 A 钉住的那把尺子。提交 A 后这把尺子前移,再跑 VACUUM,not yet removable 归零、死元组被真正回收。结论:卡住回收的从来不是 VACUUM 不努力,而是有一个老快照不让它清——这正是「监控并杀掉长事务比调 VACUUM 参数更治本」的现场证据。