Skip to content

PostgreSQL 中的 WAL — 2. 预写式日志(Write-Ahead Log)

原文:https://habr.com/en/companies/postgrespro/articles/494246/ (作者 Egor Rogov,PostgresPro)

在上一篇里我们知道了:数据修改先发生在共享缓冲区缓存的内存副本上,"脏页"要等到某个合适的时机才会被刷回磁盘。这一篇要回答的问题是:既然脏页随时可能因为崩溃而丢失,PostgreSQL 是怎么保证数据不丢、磁盘状态最终能恢复一致的?答案就是预写式日志(Write-Ahead Log,WAL)。

为什么要有日志:一条铁律

WAL 机制建立在一条几乎可以称为铁律的原则之上:描述某个改动的日志记录,必须先于对应的脏页落盘之前,被写到磁盘上。反过来说,只要日志已经安全落盘,即便对应的脏页还留在内存里没来得及写,系统崩溃后也能凭借这条日志把这次修改"重放"出来,恢复到崩溃前的状态。

为什么不干脆让每次修改都直接同步写回对应的数据页,而要绕一道弯先写日志呢?原文给出了几个理由:

  • 日志记录通常远小于整页数据(一次小改动可能只产生几十上百字节的日志,而一个页面是 8KB),只写日志比每次都重写整页要省得多;
  • 日志是顺序追加写入的,即便在机械硬盘上,顺序写的效率也远高于随机写,这天然适合日志这种"只增不改"的写入模式;
  • 有了日志兜底,数据页什么时候落盘就变成了一个可以灵活调度、批量合并的性能问题,而不再是必须时刻保证磁盘绝对一致的正确性问题;
  • 这份日志顺带还能被用来做时间点恢复、物理备份、流复制等功能——日志本身就是一份完整的"变更流水账"。

需要写日志的操作包括:对缓冲区缓存里页面(表数据页、索引页等)的修改、事务的提交与回滚状态变化、以及文件级别的操作(比如创建、删除文件)。相对地,有几类对象不写 WAL 或者豁免:不打日志的表(unlogged table)、生命周期仅限于当前会话的临时表,以及 PostgreSQL 10 之前的哈希索引(早期哈希索引不写 WAL,因此不能被复制,也没有崩溃保护)。

日志的逻辑结构

WAL 在逻辑上可以看成一条由变长记录首尾相连组成的序列,如同一条不断增长的流水账。每条记录大致由两部分组成:一个格式统一的头部,加上具体操作对应的数据内容。头部里携带的信息至少包括:

  • 产生这条记录的事务 ID;
  • 负责解释这条记录具体内容的"资源管理器"(resource manager)标识;
  • 一个 CRC 校验和,用于发现记录本身是否损坏;
  • 记录长度,以及指向前一条记录的链接。

这里"资源管理器"是个值得展开的概念:WAL 里记录的改动来自各种不同的对象——堆表、不同类型的索引(B-树、GiST、GIN……)、事务状态变化等等,它们各自的记录内容格式完全不同。PostgreSQL 用"资源管理器"这个抽象把解析、重放某一类记录的逻辑封装起来,每种资源管理器负责认识和处理自己那一类记录。想知道系统里都注册了哪些资源管理器,可以用:

bash
pg_waldump -r list

日志的物理结构

逻辑上是一条连续流水账,物理上 WAL 被切分成一个个固定大小的文件,存放在 $PGDATA/pg_wal 目录下,默认每个文件 16MB。这个文件大小在 PostgreSQL 11 之前只能在编译时指定;从 11 开始,可以在用 initdb 初始化集群时通过 --wal-segsize 选项来设置。

写入时,日志记录并不会直接同步写文件,而是先进入共享内存里专门划出的一块区域——WAL 缓冲区(WAL buffers),大小由参数 wal_buffers 控制(默认是自动设置,通常取共享缓冲区缓存大小的 1/32)。这块内存可以看成一个环形缓冲区:新产生的记录不断在"头部"追加进来,已经写好的部分从"尾部"被真正落盘。这里有两个关键位置:

  • "头部"位置:当前已经写入内存但尚未必然落盘的最新记录末尾,通过 pg_current_wal_insert_lsn() 获取;
  • "尾部"位置:已经确保写到磁盘的位置,通过 pg_current_wal_lsn() 获取。
sql
SELECT pg_current_wal_lsn(), pg_current_wal_insert_lsn();

这两个位置都用一种叫 LSN(Log Sequence Number,日志序列号)的量表示。LSN 本质上是一个 64 位整数,代表从 WAL 起始处算起的字节偏移量,但为了可读性,它被打印成两个用斜杠分隔的 32 位十六进制数(例如 0/331E4E64)。既然 LSN 就是字节偏移,两个 LSN 相减就能直接得到这两个位置之间产生了多少字节的日志——这是一个非常实用的度量 WAL 生成速率的方法:

sql
SELECT '0/331F37E8'::pg_lsn - '0/331F377C'::pg_lsn;

给定一个 LSN,还能反查它落在哪个物理文件、文件内哪个偏移:

sql
SELECT file_name, upper(to_hex(file_offset)) file_offset
FROM pg_walfile_name_offset('0/331E4E64');

WAL 文件名本身的构造也是有规律的:文件名由若干十六进制数字组成,其中最高位的 8 个十六进制数字表示"时间线"(timeline)编号(每次做时间点恢复、进行 failover 都会产生新的时间线),其余部分对应 LSN 的高位数字,而 LSN 的低位数字则对应在这个文件内部的具体偏移量。要查看当前 pg_wal 目录里都有哪些文件、各自大小,PostgreSQL 10 起提供了:

sql
SELECT * FROM pg_ls_waldir() WHERE name = '000000010000000000000033';

用一次真实的更新观察 WAL 是怎么写的

文章接下来用一个动手实验,把"页面何时被打上 LSN 标记、提交时又发生了什么"具象化。基本思路是:每个数据页的页头里都记着"最后一次相关改动对应的 WAL 记录的 LSN",这个页面 LSN 永远不可能超过当前 WAL 已经插入的最新位置——这正是恢复算法判断一条日志记录是否已经被应用过的依据(下一节详述)。

sql
CREATE TABLE wal(id integer);
INSERT INTO wal VALUES (1);
CREATE EXTENSION pageinspect;

BEGIN;
SELECT pg_current_wal_insert_lsn();
UPDATE wal set id = id + 1;
SELECT pg_current_wal_insert_lsn();
SELECT lsn FROM page_header(get_raw_page('wal',0));
COMMIT;
SELECT pg_current_wal_insert_lsn();

可以观察到:UPDATE 语句执行完之后,插入位置往前走了一段(产生了描述这次修改的记录),页面头部的 LSN 也随之更新为这条记录的 LSN;而 COMMIT 之后插入位置又往前走了一截——这是因为提交本身也要写一条记录到事务状态区。

值得一提的是,事务提交/回滚状态本身也需要持久化并参与恢复,PostgreSQL 用一块专门的、大小为 128 页的共享内存区域(俗称 XACT/CLOG,存储各事务的提交状态)来维护这份信息,它有自己独立的 LSN 追踪逻辑,与普通数据页共用同一套日志机制但分开管理。

文章给出的一个具体例子:一次 UPDATE 加上随后的 COMMIT,一共只产生了 108 字节的 WAL——这说明日志记录确实非常紧凑,远小于一整个数据页。

用 pg_waldump 查看日志记录的真实内容

pg_waldump 是分析 WAL 内容的命令行工具,需要以运行数据库的操作系统用户(通常是 postgres)身份执行,支持通过 -s-e 指定要查看的 LSN 范围,也支持按事务号过滤:

bash
pg_waldump -p /var/lib/postgresql/11/main/pg_wal -s 0/331F377C -e 0/331F37E8 000000010000000000000033

它的输出会把每条记录的资源管理器类型(rmgr)、记录长度、所属事务号、自身 LSN、前一条记录的 LSN、具体操作描述(比如某种 HOT_UPDATE)、以及涉及哪些数据块(block reference)都列出来,是排查问题、理解具体某次操作在日志层面留下了什么痕迹的利器。

崩溃恢复是怎么进行的

数据库启动时,由 postmaster 拉起一个专门的 startup 进程来判断上次是否正常关闭、是否需要走恢复流程。判断依据是 $PGDATA/global/pg_control 这个控制文件里记录的集群状态标志,它要么是"生产中"(意味着上次没有正常关闭,需要恢复),要么是"已正常关闭"。查看这个状态可以用:

bash
pg_controldata -D /var/lib/postgresql/11/main | grep state

如果需要恢复,大致流程是:

  1. startup 进程从上一个检查点记录的位置开始,按顺序依次读取 WAL 中的记录;
  2. 对每一条记录,去检查它所涉及的数据页,比较页面头部里已记录的 LSN 和这条记录自身的 LSN:如果页面 LSN 已经大于或等于这条记录的 LSN,说明这个改动早在崩溃之前就已经真正落盘过了,无需重复应用;只有页面 LSN 落后于记录 LSN 时,才需要把这条记录里描述的改动重新应用一遍;
  3. 记录必须严格按照顺序依次重放,不能跳跃或者乱序;
  4. 有两类例外情形会打破"看 LSN 决定是否应用"的普通规则:一是全页镜像(Full Page Image,FPI)记录——不管页面当前状态如何,FPI 都会直接、完整地覆盖整个页面内容(这是为了对抗断电导致的"部分页写"问题,详见系列第四篇);二是事务状态(XACT)变化的应用不受具体某个数据页版本的约束,可以应用于任何版本的页面。
  5. 重放过程中如果发现某个应该存在的文件缺失(比如崩溃发生在文件刚创建、内容还没完全落盘的窗口期),会依据日志记录里的操作类型把它重新创建出来;对于不记录 WAL 的表(unlogged table),恢复时不会去重放它们的内容,而是直接用初始化分支(init fork)把它们清空,因为这类表本来就不承诺崩溃后的数据完整性。

有意思的一点是:PostgreSQL 的崩溃恢复只需要"前滚"(redo,把日志里记录但尚未落盘的改动重新应用一遍)这一个阶段,并不需要传统意义上的"回滚"(undo)阶段。原因在于 PostgreSQL 使用多版本并发控制(MVCC):一个事务如果没有提交,它写下的那些行版本本身在可见性判断上就不会被认为有效(因为 XACT 里没有留下这个事务的提交标记),所以物理上根本不需要主动把这些行删除或者还原——它们天然就是"不可见"的垃圾,之后会被 VACUUM 之类的机制自然清理掉。

小结

这一篇建立了 WAL 的核心心智模型:一切对缓冲区页面的修改,必须先以紧凑的日志记录形式安全落盘,之后脏页何时真正写回磁盘就成了一个可以灵活调度的性能问题。WAL 逻辑上是一条由资源管理器负责解释的变长记录流,物理上被切成默认 16MB 一个的文件,写入过程要先经过内存里的 WAL 缓冲区(wal_buffers)。LSN 作为字节偏移量贯穿整个体系:既标记每条日志记录的位置,也被记录在每个数据页头部,用来在恢复时判断这条记录是否已经生效。恢复时只需要从上一个检查点开始顺序前滚,靠比较页面 LSN 和记录 LSN 决定要不要重放,FPI 和事务状态变化是两个特殊处理的例外;而得益于 MVCC,PostgreSQL 完全不需要传统的回滚阶段。下一篇会深入"检查点"(checkpoint)——也就是恢复起点是怎么产生和推进的。