WAL 概述(预写式日志)
前面几节讲了 PostgreSQL 如何在内存里改数据、如何在磁盘上组织数据、如何用 MVCC 让「写不挡读」。但所有这些讨论都建立在一个前提上:数据能在崩溃后不丢。支撑这个前提的,就是 WAL(Write-Ahead Log,预写式日志)。
一句话概括 WAL 的核心思想:任何数据页的修改,都必须先把「改了什么」写进日志并落盘,然后才允许数据页本身落盘。这个看似简单的顺序约束,同时解决了三件大事——崩溃恢复、时间点恢复(PITR)、流复制。本节先建立 WAL 的整体图景:它是什么、为什么这样设计、写到哪里、有哪些形态。
前置知识
阅读本节前,建议先了解:MVCC 的优缺点
基础概念
WAL 是 PostgreSQL 数据持久性的唯一基石。理解它之前,先统一术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| WAL | Write-Ahead Log | 预写式日志,记录「数据页将被如何修改」的物理日志 |
| WAL 记录 | WAL Record | 日志的最小单位,描述一次页修改或一次事务事件 |
| 预写原则 | Write-Ahead Rule | 日志必须先于数据页落盘,否则崩溃后无法恢复 |
| WAL 缓冲区 | WAL Buffer | 共享内存中暂存 WAL 记录的环形缓冲区 |
| WAL 段文件 | WAL Segment | WAL 在磁盘上的物理文件,默认 16MB 一个 |
| LSN | Log Sequence Number | 日志序列号,WAL 中每个字节位置的全局唯一地址 |
| 重做 | Redo | 崩溃恢复时按 WAL 重新施加修改 |
| 检查点 | Checkpoint | 把内存脏页刷盘并记录「此后从此处开始重做」的位点 |
| FPI | Full 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 把「随机写多个页」拆成了两步:
无 WAL(提交时直接刷数据页)
事务修改 5 个页 ──► 随机写 5 个页 ──► 中途断电 = 部分页已改,数据损坏 ✘
有 WAL(先顺序写日志)
事务修改 5 个页 ──► 顺序写 1 段 WAL ──► fsync 日志成功 = 事务已持久化 ✔
│
└─ 数据页可以晚点再刷;中途断电也能用 WAL 重做两个关键收益:
- 顺序写替代随机写:提交只需一次顺序 I/O(写 WAL),不用等 5 个随机页刷盘,延迟大幅下降。
- 原子性落到段级:WAL 段是顺序追加的,一段日志要么完整落盘要么不落,不存在「半条记录」的中间状态。恢复时靠记录里的 CRC 校验就能判断到哪里为止。
WAL 记录长什么样
WAL 记录是自包含的:每条记录都带「要改哪个块、改成什么、属于哪个事务」的完整信息,恢复时不需要任何额外上下文。
┌────────────────────────────────────────────────────────────┐
│ 通用头部(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 贯穿内存与磁盘,是连接「已提交」与「已落盘」的桥梁:
┌─────────────── 共享内存 ───────────────┐
事务修改数据 │ shared_buffers wal_buffers │
│ │ (数据页,变脏) (WAL 记录,顺序追加)│
▼ └────────┬───────────────────┬──────────┘
① 生成 WAL 记录 ────────────────┼───────────────────┘
│ │ │
│ │ ② 提交时 fsync
│ │ ▼
│ │ ┌──────────────┐
│ │ │ pg_wal │ ← 顺序写
│ │ │ 16MB 段文件 │
│ │ └──────┬───────┘
│ │ │ ③ 归档 / 流复制
│ ④ 脏页延迟刷盘 ▼
│ ▼ ┌──────────────┐
└────────────────► ┌──────────────┐ │ 归档存储/备库 │
│ base/ 数据文件│ └──────────────┘
└──────────────┘ ← 随机写三个必须记住的顺序关系:
- WAL 记录先进
wal_buffers,提交时刷到pg_wal。 - 数据页在 shared_buffers 里变脏,由后台进程择机刷盘。
- 数据页落盘前,对应的 WAL 必须先落盘(这条由 checkpointer 的
redo位点保证,见后文「WAL 与检查点」)。
一次写入的完整链路
以一条 UPDATE 提交为例,WAL 侧发生了什么:
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 可以被回收:
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、审计 |
wal_level 提升的代价:
minimal ──► replica ──► logical
│ │ │
└ WAL 量最小 └ WAL 量+索引 └ WAL 量再+旧值/额外标识
可以 PITR 可以逻辑解码WAL 的三条流向
同一份 WAL 有三个消费者,互不干扰:
pg_wal(本地段文件)
│
┌─────────────────────┼─────────────────────┐
│ │ │
① 崩溃恢复 ② 归档 ③ 流复制
startup 进程读到 archiver 进程 walsender 进程
检查点 redo 位点后 按 archive_command 按复制槽/复制连接
重放到最后一个有效记录 复制到外部存储 推送给备库
│ │ │
本地数据文件 归档目录(长期保留) 备库数据目录- 崩溃恢复:实例启动时由 startup 进程完成,自动进行,无外部依赖。
- 归档:由 archiver 进程完成,需要
archive_mode = on与archive_command。 - 流复制:由 walsender 进程完成,需要
wal_level >= replica且备库连接。
相关参数
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
wal_level | replica | 重启 | WAL 信息量:minimal / replica / logical |
fsync | on | 重启 | 是否把 WAL/数据刷到存储设备;生产环境绝不要关 |
synchronous_commit | on | reload + 会话 | 提交时等多少 WAL:off / local / on / remote_write / remote_apply |
wal_buffers | -1(自动) | 重启 | WAL 共享缓冲区大小,自动约 shared_buffers/32,上限 16MB |
wal_writer_delay | 200ms | reload | WAL 写入进程刷盘间隔 |
wal_writer_flush_after | 1MB | reload | 累积该量后强制 flush,避免数据滞留内核页缓存 |
commit_delay | 0 | reload | 提交前人为等待,让更多事务合并一次 fsync(组提交) |
commit_siblings | 5 | reload | 活跃事务数达到该值才考虑 commit_delay |
min_wal_size | 80MB | reload | pg_wal 保留下限 |
max_wal_size | 1GB | reload | 触发检查点的 WAL 累积量参考 |
wal_keep_size | 0 | reload | 为备库额外保留的 WAL |
wal_compression | off | reload | FPI 压缩:pglz / lz4 / zstd |
# 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 状态的完整认知:
-- 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; 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_wal | WAL 生成统计 | PG14+ |
pg_stat_archiver | 归档进程统计 | PG9.4+ |
pg_control_checkpoint() | 控制文件中的检查点信息 | PG10+ |
-- 一次查全: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; wal_dir_size | buffers_full | archived | failed | inactive_slots
--------------+--------------+----------+--------+----------------
1024 MB | 61 | 186 | 2 | 0四个指标一起看才能判断健康度:wal_dir_size 是否失控、buffers_full 是否快速增长、failed 是否在涨、有没有僵尸复制槽。
WAL 与数据文件的持久性关系
「WAL 比数据文件新」是正常运行状态的标志,可以用 LSN 直接验证:
-- 取表所在文件的"最后一次落盘"位置:用 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; 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 能提升几倍写入性能」的说法,代价必须说清楚:
fsync = on(默认)
提交 ──► 等 WAL 真正落到磁盘(或掉电不丢的缓存) ──► 返回成功
断电 ──► 重启重放 WAL ──► 已提交事务全部保留 ✔
fsync = off(危险)
提交 ──► WAL 只写到内核页缓存就返回成功 ──► 快
断电 ──► 内核页缓存全丢 ──► WAL 断裂 ──► 数据目录损坏 ✘# postgresql.conf
# 只在"可随时全量重建的测试库"上临时使用,生产环境严禁
# fsync = off
# synchronous_commit = off
# full_page_writes = off比 fsync = off 更安全的一个折中是 synchronous_commit = off:WAL 仍会写盘,只是提交不等 fsync。掉电最多丢失「最近几百毫秒内提交的事务」,但不会损坏数据。这是可接受的取舍,而 fsync = off 不是。
实战示例
示例 1:量化一次事务产生的 WAL 量
用一个完整序列比较三种操作的 WAL 成本,理解「什么操作贵」:
-- 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; wal_for_insert | wal_for_hot_update | wal_for_indexed_update
-----------------------+--------------------+------------------------
1904 kB | 912 kB | 4384 kB结果解读:
INSERT5000 行产生约 1.9MB WAL,主要是元组数据加上 B 树索引的插入记录。UPDATE非索引列只产生 912kB,明显小于插入量:因为走了 HOT 更新,新版本留在同一页,索引完全不动,每条记录只需描述页内槽位变化。UPDATE主键列产生 4384kB,是 HOT 更新的 4.8 倍:主键有 B 树索引,每行更新都要在索引里「删旧条目 + 插新条目」,而索引页修改可能触发页分裂(此时会写 FPI),WAL 量急剧上升。- 这个对比给出了一个可操作的优化方向:高频更新的列尽量不要建立索引。如果业务必须更新索引列,考虑把该列拆到独立的小表。
示例 2:观察检查点如何回收 WAL
用一个完整序列验证「检查点会推进 redo 位点,从而释放 WAL」:
-- 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;-- 步骤 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 领先于数据文件
用一个受控实验证明「提交成功 ≠ 数据落盘」:
-- 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;-- 步骤 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有本质区别:前者只可能丢失最近提交的少量事务,不会损坏数据;后者会损坏数据。要「降级」时选前者。
-- 只对当前会话降级:适合批量导入的中间过程
SET LOCAL synchronous_commit = off; -- 事务结束自动失效,不影响其他会话# postgresql.conf
# 全局降级:可接受少量事务丢失的日志类写入场景
synchronous_commit = off # 注意:不是 fsync = offwal_level是重启参数,从minimal提到replica/logical需要重启实例,且提升后 WAL 量会上升。做容量规划时必须把这个增量算进去。wal_buffers自动值约shared_buffers/32,上限 16MB。WAL 缓冲区太小会导致wal_buffers_full频繁增长,增大它需要重启。- 不要手工删除
pg_wal下的文件。即使磁盘打满也要先查清原因(复制槽、归档失败、长事务),删文件会导致实例无法启动。
# 磁盘告急时的正确处置顺序
# ① 查清是什么占着 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(日志序列号)