Skip to content

WAL 概述(预写式日志) ​

前面几节讲了 PostgreSQL 如何在内存里改数据、如何在磁盘上组织数据、如何用 MVCC 让「写不挡读」。但所有这些讨论都建立在一个前提上:数据能在崩溃后不丢。支撑这个前提的,就是 WAL(Write-Ahead Log,预写式日志)。

一句话概括 WAL 的核心思想:任何数据页的修改,都必须先把「改了什么」写进日志并落盘,然后才允许数据页本身落盘。这个看似简单的顺序约束,同时解决了三件大事——崩溃恢复、时间点恢复(PITR)、流复制。本节先建立 WAL 的整体图景:它是什么、为什么这样设计、写到哪里、有哪些形态。

前置知识

阅读本节前,建议先了解:MVCC 的优缺点

基础概念 ​

WAL 是 PostgreSQL 数据持久性的唯一基石。理解它之前,先统一术语:

术语英文说明
WALWrite-Ahead Log预写式日志,记录「数据页将被如何修改」的物理日志
WAL 记录WAL Record日志的最小单位,描述一次页修改或一次事务事件
预写原则Write-Ahead Rule日志必须先于数据页落盘,否则崩溃后无法恢复
WAL 缓冲区WAL Buffer共享内存中暂存 WAL 记录的环形缓冲区
WAL 段文件WAL SegmentWAL 在磁盘上的物理文件,默认 16MB 一个
LSNLog Sequence Number日志序列号,WAL 中每个字节位置的全局唯一地址
重做Redo崩溃恢复时按 WAL 重新施加修改
检查点Checkpoint把内存脏页刷盘并记录「此后从此处开始重做」的位点
FPIFull Page Image全页镜像,检查点后首次修改某页时记录整页内容
归档Archiving把写满的 WAL 段文件复制到外部存储,用于 PITR
复制Replication把 WAL 流式传给备库,备库持续重放
时间线Timeline分叉后的历史分支编号,每次恢复提升后递增

WAL 解决的三件事 ​

WAL 不是「额外的日志」,而是数据库的唯一真相来源。它同时承担三个职责:

职责依赖的能力典型场景
崩溃恢复(Crash Recovery)重放 WAL 中检查点之后的部分断电、kill -9 后自动恢复
时间点恢复(PITR)基础备份 + 归档 WAL 持续重放误删数据后恢复到 10:05 的状态
流复制(Streaming Replication)把 WAL 实时传给备库并重放主备高可用、读写分离

三者的底层机制完全相同:都是「从某个起点开始顺序重放 WAL」。区别只在于起点的来源和 WAL 的来源。

为什么一份日志能干三件事

因为 WAL 记录的是物理层面的页修改,而不是业务语义。只要数据目录的初始状态一致(基础备份),再按同样的顺序施加同样的页修改,就一定能得到完全一致的状态。这个「确定性重放」性质,是崩溃恢复、PITR、复制能共用一套机制的根本原因。

预写原则:为什么必须「先日志后数据」 ​

假设没有 WAL,事务提交时直接把数据页刷盘。一次多页修改的事务在第 3 个页刷完时断电——前两个页已改,后面没改,数据处于逻辑上不可能存在的中间状态。关系数据库无法接受这种状态。

WAL 把「随机写多个页」拆成了两步:

text
无 WAL(提交时直接刷数据页)
  事务修改 5 个页 ──► 随机写 5 个页 ──► 中途断电 = 部分页已改,数据损坏 ✘

有 WAL(先顺序写日志)
  事务修改 5 个页 ──► 顺序写 1 段 WAL ──► fsync 日志成功 = 事务已持久化 ✔
                              │
                              └─ 数据页可以晚点再刷;中途断电也能用 WAL 重做

两个关键收益:

  • 顺序写替代随机写:提交只需一次顺序 I/O(写 WAL),不用等 5 个随机页刷盘,延迟大幅下降。
  • 原子性落到段级:WAL 段是顺序追加的,一段日志要么完整落盘要么不落,不存在「半条记录」的中间状态。恢复时靠记录里的 CRC 校验就能判断到哪里为止。

WAL 记录长什么样 ​

WAL 记录是自包含的:每条记录都带「要改哪个块、改成什么、属于哪个事务」的完整信息,恢复时不需要任何额外上下文。

text
┌────────────────────────────────────────────────────────────┐
│ 通用头部(XLogRecord)                                      │
│  total_len │ xid │ prev │ info │ rmgr │ crc                │
├────────────────────────────────────────────────────────────┤
│ 各资源管理器(rmgr)自定义的数据体                           │
│  如 Heap:块引用 blkref + 元组数据 + 页内偏移                │
│  如 Btree:分裂/插入/删除的索引操作细节                       │
│  如 Transaction:COMMIT / ABORT / 提交时间戳                 │
└────────────────────────────────────────────────────────────┘
  • xid 是产生该记录的事务 ID,恢复时据此判定事务是否已提交。
  • prev 指同事务上一条记录的 LSN,把一次事务的所有记录串成链。
  • rmgr 是资源管理器编号(Heap / Btree / Transaction / Standby 等),决定数据体如何解析。
  • crc 是整条记录的校验值,断电写坏的最后一条记录会被识别并丢弃。

核心机制 ​

WAL 在系统中的位置 ​

WAL 贯穿内存与磁盘,是连接「已提交」与「已落盘」的桥梁:

text
                        ┌─────────────── 共享内存 ───────────────┐
  事务修改数据          │  shared_buffers     wal_buffers        │
      │                 │  (数据页,变脏)   (WAL 记录,顺序追加)│
      ▼                 └────────┬───────────────────┬──────────┘
 ① 生成 WAL 记录 ────────────────┼───────────────────┘
      │                          │                    │
      │                          │              ② 提交时 fsync
      │                          │                    ▼
      │                          │            ┌──────────────┐
      │                          │            │    pg_wal    │  ← 顺序写
      │                          │            │  16MB 段文件  │
      │                          │            └──────┬───────┘
      │                          │                   │ ③ 归档 / 流复制
      │                  ④ 脏页延迟刷盘               ▼
      │                          ▼            ┌──────────────┐
      └────────────────►  ┌──────────────┐    │ 归档存储/备库 │
                          │  base/ 数据文件│    └──────────────┘
                          └──────────────┘  ← 随机写

三个必须记住的顺序关系:

  1. WAL 记录先进 wal_buffers,提交时刷到 pg_wal。
  2. 数据页在 shared_buffers 里变脏,由后台进程择机刷盘。
  3. 数据页落盘前,对应的 WAL 必须先落盘(这条由 checkpointer 的 redo 位点保证,见后文「WAL 与检查点」)。

一次写入的完整链路 ​

以一条 UPDATE 提交为例,WAL 侧发生了什么:

text
BEGIN;
  UPDATE users SET name = 'bob' WHERE id = 1;
      │
      ├─ 后端进程在 shared_buffers 找到目标页,写入新元组版本
      │
      ├─ 为该页修改生成 WAL 记录(Heap: UPDATE / HOT_UPDATE)
      │      └─ 若该页自上次检查点后首次被改 → 附带 FPI 全页镜像
      │
      ├─ WAL 记录追加到 wal_buffers(共享,需加锁)
      │
COMMIT;
      │
      ├─ 生成 Transaction: COMMIT 记录
      │
      ├─ 提交前,把 wal_buffers 中直到本事务末尾的部分刷到 pg_wal
      │      └─ 受 synchronous_commit 控制:on=等本地 fsync,remote_apply=等备库
      │
      └─ fsync 成功 → 向客户端返回 COMMIT ✔
             (数据页此刻可能还在内存里脏着,完全没问题)

注意最后一行:事务提交成功与数据页落盘无关。这是 PostgreSQL 高并发写入的关键——提交只要等顺序 I/O,不必等同数量的随机 I/O。

WAL 与检查点:redo 位点的作用 ​

WAL 会无限增长吗?不会。检查点(checkpoint)负责「画一条线」,线之前的 WAL 可以被回收:

text
WAL 时间轴 ─────────────────────────────────────────────────►
        │                              │
     redo 位点                    当前写入位点
   (检查点起点)                  (pg_current_wal_lsn)
        │                              │
        └── 这之前不再需要重放 ────────┘
            可被归档/回收

检查点动作:
  ① 把所有脏页刷入数据文件
  ② 记录 redo 位点(= 检查点开始时最早的活跃事务相关位置)
  ③ 此后若崩溃 → 从 redo 位点开始重放即可

所以 pg_wal 目录的大小约等于「两次检查点之间的 WAL 生成量」,这也是 max_wal_size 参数的物理含义:它控制检查点触发频率,间接控制 WAL 占用。

redo 位点与「最早活跃事务」的关系

检查点的 redo 位点不是简单取「检查点开始时的 LSN」,而是取检查点开始时最早的活跃事务的起点。原因是:一个长事务可能很早就改了页但还没提交,如果把它之后的 WAL 都回收掉,崩溃恢复时无法重放它已做的修改。所以只要还有长事务活着,它钉住的那段 WAL 就不能回收——这又是一个「长事务有害」的实例。

wal_level:写多少进去 ​

wal_level 决定 WAL 中记录多少信息,直接决定你能用哪些能力:

级别记录内容支持的恢复能力适用场景
minimal仅崩溃恢复所需,跳过部分索引与批量插入的 WAL只能崩溃恢复批量导入中间态(不可用于生产)
replica(默认)足够完整,可支撑基础备份与物理复制崩溃恢复 + PITR + 流复制绝大多数生产库
logical额外记录逻辑解码所需信息(旧元组值等)上述全部 + 逻辑复制/CDC逻辑复制、Debezium、审计
text
wal_level 提升的代价:
minimal  ──►  replica  ──►  logical
   │            │              │
   └ WAL 量最小  └ WAL 量+索引   └ WAL 量再+旧值/额外标识
                 可以 PITR       可以逻辑解码

WAL 的三条流向 ​

同一份 WAL 有三个消费者,互不干扰:

text
                       pg_wal(本地段文件)
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
   ① 崩溃恢复             ② 归档                ③ 流复制
   startup 进程读到         archiver 进程         walsender 进程
   检查点 redo 位点后        按 archive_command    按复制槽/复制连接
   重放到最后一个有效记录     复制到外部存储        推送给备库
        │                     │                     │
   本地数据文件            归档目录(长期保留)      备库数据目录
  • 崩溃恢复:实例启动时由 startup 进程完成,自动进行,无外部依赖。
  • 归档:由 archiver 进程完成,需要 archive_mode = on 与 archive_command。
  • 流复制:由 walsender 进程完成,需要 wal_level >= replica 且备库连接。

相关参数 ​

参数默认值生效方式说明
wal_levelreplica重启WAL 信息量:minimal / replica / logical
fsyncon重启是否把 WAL/数据刷到存储设备;生产环境绝不要关
synchronous_commitonreload + 会话提交时等多少 WAL:off / local / on / remote_write / remote_apply
wal_buffers-1(自动)重启WAL 共享缓冲区大小,自动约 shared_buffers/32,上限 16MB
wal_writer_delay200msreloadWAL 写入进程刷盘间隔
wal_writer_flush_after1MBreload累积该量后强制 flush,避免数据滞留内核页缓存
commit_delay0reload提交前人为等待,让更多事务合并一次 fsync(组提交)
commit_siblings5reload活跃事务数达到该值才考虑 commit_delay
min_wal_size80MBreloadpg_wal 保留下限
max_wal_size1GBreload触发检查点的 WAL 累积量参考
wal_keep_size0reload为备库额外保留的 WAL
wal_compressionoffreloadFPI 压缩:pglz / lz4 / zstd
ini
# postgresql.conf
# —— 可用性与恢复能力 ——
wal_level = replica                  # 生产基线;需要逻辑复制时改 logical 并重启
fsync = on                           # 绝不要为了"性能"关掉它,等于放弃持久性
synchronous_commit = on              # 单机用 on;有同步备库时可按需用 remote_write

# —— 写入吞吐调优 ——
wal_buffers = -1                     # 交给系统按 shared_buffers 推导
wal_writer_delay = 200ms
wal_writer_flush_after = 1MB         # 这个值对提交延迟影响很大,可显式设为 1MB

# —— 组提交:高并发小事务场景有收益 ——
commit_delay = 0                     # 默认关;实测有收益再开(单位微秒)
commit_siblings = 5

# —— 容量与检查点 ——
min_wal_size = 256MB
max_wal_size = 4GB
wal_compression = lz4                # PG15+,显著减少 FPI 带来的 WAL 膨胀

详细说明 ​

查看 WAL 的整体状况 ​

三条查询就能建立起对当前实例 WAL 状态的完整认知:

sql
-- 1) 当前写入位点与所在段文件
SELECT pg_current_wal_lsn() AS current_lsn,
       pg_walfile_name(pg_current_wal_lsn()) AS current_segment;

-- 2) 最近一次检查点的 redo 位点与发生时间
SELECT redo_lsn,
       pg_walfile_name(redo_lsn) AS redo_segment,
       redo_wal_file,
       checkpoint_time,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), redo_lsn)
       ) AS wal_since_checkpoint
FROM pg_control_checkpoint();

-- 3) WAL 生成速率统计(PG14+)
SELECT wal_records, wal_fpi, wal_bytes,
       pg_size_pretty(wal_bytes) AS wal_bytes_pretty,
       wal_buffers_full
FROM pg_stat_wal;
text
 current_lsn |        current_segment
-------------+-------------------------------
 0/2A4F1B8   | 000000010000000000000002

 redo_lsn |        redo_segment        | wal_since_checkpoint |     checkpoint_time
----------+---------------------------+----------------------+------------------------
 0/1A2B4C8 | 000000010000000000000001  | 256 MB               | 2026-09-20 12:00:11+08

 wal_records | wal_fpi | wal_bytes | wal_bytes_pretty | wal_buffers_full
-------------+---------+-----------+------------------+------------------
      328430 |    2112 |  41274305 | 39 MB            |               61

如何解读:wal_since_checkpoint = 256MB 说明距上次检查点只积累了 256MB WAL,远未到 max_wal_size,检查点压力很小;wal_buffers_full = 61 表示 WAL 缓冲区被写满导致被迫刷盘的次数,若这个值随时间快速增长,说明并发写入压力大或 wal_buffers 偏小。

WAL 相关的系统视图一览 ​

视图/函数作用引入版本
pg_current_wal_lsn()当前 WAL 写入位点PG10+(早期为 pg_current_xlog_location)
pg_walfile_name(lsn)LSN → 段文件名PG10+
pg_walfile_name_offset(lsn)LSN → 文件名 + 段内偏移PG10+
pg_wal_lsn_diff(a, b)两个 LSN 之间的字节距离PG10+
pg_switch_wal()强制切换段文件PG10+
pg_ls_waldir()列出 pg_wal 目录内容PG10+
pg_stat_walWAL 生成统计PG14+
pg_stat_archiver归档进程统计PG9.4+
pg_control_checkpoint()控制文件中的检查点信息PG10+
sql
-- 一次查全:WAL 生成量、归档进度、缓冲区健康度
SELECT (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS wal_dir_size,
       (SELECT wal_buffers_full FROM pg_stat_wal)             AS buffers_full,
       (SELECT archived_count FROM pg_stat_archiver)          AS archived,
       (SELECT failed_count FROM pg_stat_archiver)            AS failed,
       (SELECT count(*) FROM pg_replication_slots
        WHERE NOT active)                                     AS inactive_slots;
text
 wal_dir_size | buffers_full | archived | failed | inactive_slots
--------------+--------------+----------+--------+----------------
 1024 MB      |           61 |      186 |      2 |              0

四个指标一起看才能判断健康度:wal_dir_size 是否失控、buffers_full 是否快速增长、failed 是否在涨、有没有僵尸复制槽。

WAL 与数据文件的持久性关系 ​

「WAL 比数据文件新」是正常运行状态的标志,可以用 LSN 直接验证:

sql
-- 取表所在文件的"最后一次落盘"位置:用 pg_stat_file 看修改时间
SELECT pg_relation_filepath('users') AS relpath,
       (SELECT modification FROM pg_stat_file(
          current_setting('data_directory') || '/' || pg_relation_filepath('users'))
       ) AS data_file_mtime,
       (SELECT checkpoint_time FROM pg_control_checkpoint()) AS last_checkpoint;
text
 relpath    |     data_file_mtime      |     last_checkpoint
------------+--------------------------+------------------------
 base/16384/16385 | 2026-09-20 11:58:02+08 | 2026-09-20 12:00:11+08

数据文件的修改时间通常早于最近检查点时间(脏页还没刷完),而 WAL 已经写到了检查点之后的位点——这正是「日志领先于数据」的直接体现。

关闭 WAL 的代价:从 fsync = off 说起 ​

网上常见「关掉 fsync 能提升几倍写入性能」的说法,代价必须说清楚:

text
fsync = on(默认)
  提交 ──► 等 WAL 真正落到磁盘(或掉电不丢的缓存) ──► 返回成功
  断电 ──► 重启重放 WAL ──► 已提交事务全部保留 ✔

fsync = off(危险)
  提交 ──► WAL 只写到内核页缓存就返回成功 ──► 快
  断电 ──► 内核页缓存全丢 ──► WAL 断裂 ──► 数据目录损坏 ✘
ini
# postgresql.conf
# 只在"可随时全量重建的测试库"上临时使用,生产环境严禁
# fsync = off
# synchronous_commit = off
# full_page_writes = off

比 fsync = off 更安全的一个折中是 synchronous_commit = off:WAL 仍会写盘,只是提交不等 fsync。掉电最多丢失「最近几百毫秒内提交的事务」,但不会损坏数据。这是可接受的取舍,而 fsync = off 不是。

实战示例 ​

示例 1:量化一次事务产生的 WAL 量 ​

用一个完整序列比较三种操作的 WAL 成本,理解「什么操作贵」:

sql
-- 0) 准备:建表并插入基础数据
DROP TABLE IF EXISTS wal_cost;
CREATE TABLE wal_cost (
  id    bigserial PRIMARY KEY,
  payload text
);

-- 用一个函数封装"取 WAL 差值"的重复逻辑:直接手写差值更直观
-- 1) 测 INSERT 5000 行的 WAL 量
SELECT pg_current_wal_lsn() AS lsn_insert_begin \gset

INSERT INTO wal_cost (payload)
SELECT repeat('x', 100) FROM generate_series(1, 5000);

SELECT pg_current_wal_lsn() AS lsn_insert_end \gset
SELECT pg_size_pretty(
         pg_wal_lsn_diff(:'lsn_insert_end', :'lsn_insert_begin')
       ) AS wal_for_insert;

-- 2) 测 UPDATE 非索引列的 WAL 量(可走 HOT)
SELECT pg_current_wal_lsn() AS lsn_hot_begin \gset

UPDATE wal_cost SET payload = payload || 'y';

SELECT pg_current_wal_lsn() AS lsn_hot_end \gset
SELECT pg_size_pretty(
         pg_wal_lsn_diff(:'lsn_hot_end', :'lsn_hot_begin')
       ) AS wal_for_hot_update;

-- 3) 测 UPDATE 主键列的 WAL 量(必须改索引,无法 HOT)
SELECT pg_current_wal_lsn() AS lsn_idx_begin \gset

UPDATE wal_cost SET id = id + 100000;

SELECT pg_current_wal_lsn() AS lsn_idx_end \gset
SELECT pg_size_pretty(
         pg_wal_lsn_diff(:'lsn_idx_end', :'lsn_idx_begin')
       ) AS wal_for_indexed_update;
text
 wal_for_insert        | wal_for_hot_update | wal_for_indexed_update
-----------------------+--------------------+------------------------
 1904 kB               | 912 kB             | 4384 kB

结果解读:

  • INSERT 5000 行产生约 1.9MB WAL,主要是元组数据加上 B 树索引的插入记录。
  • UPDATE 非索引列只产生 912kB,明显小于插入量:因为走了 HOT 更新,新版本留在同一页,索引完全不动,每条记录只需描述页内槽位变化。
  • UPDATE 主键列产生 4384kB,是 HOT 更新的 4.8 倍:主键有 B 树索引,每行更新都要在索引里「删旧条目 + 插新条目」,而索引页修改可能触发页分裂(此时会写 FPI),WAL 量急剧上升。
  • 这个对比给出了一个可操作的优化方向:高频更新的列尽量不要建立索引。如果业务必须更新索引列,考虑把该列拆到独立的小表。

示例 2:观察检查点如何回收 WAL ​

用一个完整序列验证「检查点会推进 redo 位点,从而释放 WAL」:

sql
-- 1) 记录基线:当前 LSN、redo 位点、WAL 目录占用
SELECT pg_current_wal_lsn() AS cur_lsn,
       (SELECT redo_lsn FROM pg_control_checkpoint()) AS redo_lsn,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(),
                        (SELECT redo_lsn FROM pg_control_checkpoint()))
       ) AS pending_replay,
       (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS wal_dir;

-- 2) 制造足够多的 WAL(写入量大 → 接近 max_wal_size)
INSERT INTO wal_cost (payload)
SELECT repeat('z', 200) FROM generate_series(1, 200000);

-- 3) 再测一次
SELECT pg_current_wal_lsn() AS cur_lsn,
       (SELECT redo_lsn FROM pg_control_checkpoint()) AS redo_lsn,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(),
                        (SELECT redo_lsn FROM pg_control_checkpoint()))
       ) AS pending_replay,
       (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS wal_dir;

-- 4) 主动触发一次检查点,再观察 redo 位点的推进
CHECKPOINT;

SELECT pg_current_wal_lsn() AS cur_lsn,
       (SELECT redo_lsn FROM pg_control_checkpoint()) AS redo_lsn,
       (SELECT checkpoint_time FROM pg_control_checkpoint()) AS checkpoint_time,
       (SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir()) AS wal_dir;
text
-- 步骤 1
 cur_lsn   | redo_lsn  | pending_replay | wal_dir
-----------+-----------+----------------+---------
 0/2A4F1B8 | 0/1A2B4C8 | 256 MB         | 1024 MB

-- 步骤 3(大量写入后,尚未触发检查点)
 cur_lsn   | redo_lsn  | pending_replay | wal_dir
-----------+-----------+----------------+---------
 0/8C4D220 | 0/1A2B4C8 | 1760 MB        | 1856 MB

-- 步骤 4(CHECKPOINT 之后)
 cur_lsn   | redo_lsn  |     checkpoint_time      | wal_dir
-----------+-----------+--------------------------+---------
 0/8C4D9A0 | 0/8C4D220 | 2026-09-20 12:41:07+08    | 576 MB

结果解读:

  • 步骤 3 中 pending_replay 涨到 1760MB,超过 max_wal_size(默认 1GB)——这不等系统出错:检查点是异步触发的,短时间内的大批量写入可以让 WAL 暂时超过该值,检查点随后会跟上。
  • 步骤 4 执行 CHECKPOINT 后,redo_lsn 从 0/1A2B4C8 推进到 0/8C4D220,pending_replay 归零。
  • wal_dir 从 1856MB 降到 576MB:redo 位点之前的段文件已不再需要,被回收或删除。这就是「检查点回收 WAL」的实证。
  • 注意 wal_dir 不会降到 0:min_wal_size 决定了保留下限,用于避免突发写入时反复创建文件。

示例 3:验证 WAL 领先于数据文件 ​

用一个受控实验证明「提交成功 ≠ 数据落盘」:

sql
-- 1) 建表并插入数据(此时数据页大概率只在内存里脏着)
DROP TABLE IF EXISTS wal_lead;
CREATE TABLE wal_lead (id serial PRIMARY KEY, note text);
INSERT INTO wal_lead (note) SELECT 'row ' || g FROM generate_series(1, 1000) AS g;

-- 2) 记录当前 WAL 位点
SELECT pg_current_wal_lsn() AS lsn_after_insert;

-- 3) 立刻查看该表文件的大小与修改时间(文件可能还没被扩展)
SELECT pg_relation_filepath('wal_lead') AS relpath,
       pg_size_pretty(pg_relation_size('wal_lead')) AS reported_size,
       (SELECT modification FROM pg_stat_file(
          current_setting('data_directory') || '/' || pg_relation_filepath('wal_lead'))
       ) AS mtime;

-- 4) 强制刷盘后再对比
CHECKPOINT;
SELECT pg_relation_filepath('wal_lead') AS relpath,
       pg_size_pretty(pg_relation_size('wal_lead')) AS reported_size,
       (SELECT modification FROM pg_stat_file(
          current_setting('data_directory') || '/' || pg_relation_filepath('wal_lead'))
       ) AS mtime;
text
-- 步骤 3(提交后,未刷数据页)
     relpath        | reported_size |         mtime
--------------------+---------------+------------------------
 base/16384/16731   | 8192 kB       | 2026-09-20 12:38:11+08

-- 步骤 4(CHECKPOINT 之后)
     relpath        | reported_size |         mtime
--------------------+---------------+------------------------
 base/16384/16731   | 8192 kB       | 2026-09-20 12:41:07+08

结果解读:

  • 两个步骤的文件大小相同,但修改时间从 12:38:11 变成了 12:41:07(正好是 checkpoint 时间)。这证明:数据在 12:38:11 提交时只写进了内存与 WAL,数据文件本身是在检查点时才真正被刷新的。
  • 如果此刻断电:WAL 中 lsn_after_insert 之前的记录都在,重启后重放即可恢复所有已提交数据。数据文件「落后」完全不影响正确性。
  • 反过来,如果 WAL 丢了而数据文件是新的,那就彻底无法恢复——这就是为什么备份 WAL 目录比备份数据文件更关键,以及为什么绝不能手工删除 pg_wal 里的文件。

注意事项 ​

  • fsync = off 等于放弃持久性。它能让写入快很多,但断电会导致数据目录物理损坏,且无法通过重放恢复。仅在可随时重建的测试环境使用,且要明确知道自己在做什么。
  • synchronous_commit = off 与 fsync = off 有本质区别:前者只可能丢失最近提交的少量事务,不会损坏数据;后者会损坏数据。要「降级」时选前者。
sql
-- 只对当前会话降级:适合批量导入的中间过程
SET LOCAL synchronous_commit = off;   -- 事务结束自动失效,不影响其他会话
ini
# postgresql.conf
# 全局降级:可接受少量事务丢失的日志类写入场景
synchronous_commit = off             # 注意:不是 fsync = off
  • wal_level 是重启参数,从 minimal 提到 replica / logical 需要重启实例,且提升后 WAL 量会上升。做容量规划时必须把这个增量算进去。
  • wal_buffers 自动值约 shared_buffers/32,上限 16MB。WAL 缓冲区太小会导致 wal_buffers_full 频繁增长,增大它需要重启。
  • 不要手工删除 pg_wal 下的文件。即使磁盘打满也要先查清原因(复制槽、归档失败、长事务),删文件会导致实例无法启动。
bash
# 磁盘告急时的正确处置顺序
# ① 查清是什么占着 WAL(最常见是未推进的复制槽)
# ② 查归档是否卡在 .ready
# ③ 查是否有超长事务钉住 redo 位点
# ④ 增磁盘 / 删僵尸复制槽 / 修复归档命令
  • max_wal_size 不是硬上限。长事务、不推进的复制槽、归档失败都会让 pg_wal 远超它。
  • 批量导入时不要用 wal_level = minimal 图快。它会跳过部分 WAL,导致无法 PITR,且改成 replica 需要重启;宁可忍受 wal_level = replica 的额外 WAL 量。
  • full_page_writes 默认开启,不要关。它保证了「页被写坏后仍能恢复」,关掉它在某些场景下能减少 WAL 量,但文件系统层面的部分页写入会让恢复失败(详见 Full Page Writes 一节)。

最佳实践 ​

  • 生产库固定 wal_level = replica,需要逻辑复制/CDC 时再升到 logical,绝不用 minimal。
  • 永远保持 fsync = on。需要降低写入延迟时,只调 synchronous_commit,并且只在可接受少量丢失的场景下调。
  • 开启 wal_compression = lz4(PG15+):FPI 通常占 WAL 的大头,压缩它以几乎为零的 CPU 成本换取显著的空间与网络收益。
  • 按 max_wal_size 的 2~3 倍预留 pg_wal 磁盘空间,并把这个目录纳入容量告警。
  • 为 pg_wal 与数据目录分盘,顺序写和随机写互不争抢 I/O。
  • 把 pg_stat_wal.wal_bytes 的增长率作为核心监控指标:它是写入压力最直接的信号,突增往往意味着索引膨胀、批量任务或 FPI 异常。
  • 归档必须开启并校验:没有归档就没有 PITR,failed_count 持续增长意味着归档链路已断。
  • 用 pg_waldump 做终极审计:当需要回答「这条数据什么时候被改的」时,WAL 是唯一可信的账本。
  • 避免长事务:长事务会钉住 redo 位点,让 WAL 无法回收、VACUUM 无法推进。这是 WAL 膨胀与 MVCC 膨胀的共同根因。
  • 批量操作分批提交,避免单事务产生海量 WAL 导致 pg_wal 瞬间膨胀。

下一节

继续学习:LSN(日志序列号)

参考链接 ​