PostgreSQL 中的 xlog,也就是今天更常说的 WAL(write-ahead log),是数据库事务持久性(D)的基础保证。

简单说,就是在数据页上的修改被认为“可持久”之前,相关变更必须先记录到 WAL 中(除非这些变更可以从其他数据源快速恢复)。这样数据库系统即使崩溃,也能在重启后通过 WAL 把状态修复回来。

xlog 最主要的两个使用场景是:

  • 崩溃恢复:数据库崩溃后,recovery 进程会根据 xlog 记录重建丢失的变更,确保数据一致性。
  • 主从复制:在主从复制环境中,从库通过重放主库的 xlog 来保持状态同步。

入门介绍可以先参考 PG-JP book 的 WAL 章节。

基础格式

xlog 文件位于磁盘上的 pg_wal 目录,结构大致如下:

pg_wal/
 ├── segment file (16MB) // 文件名一般是000000010000XXXXXXXX
 │    ├── page (8KB)
 │    │    └── XLogPageHeader + XLogRecord(s)
 │    │           └── rmgr-specific record

其中 header 对应 struct XLogPageHeaderData,保存 page 级元数据;record 对应 struct XLogRecord。可以粗略理解为:

//chatgpt给的XLogRecord结构图
┌──────────────────────────────────────────────┐
│                XLogRecord(变长)             │
├──────────────────────────────────────────────┤
│  [Header] -> 对应XLogRecord结构,record头信息   |
│   - xl_crc       → CRC32 校验和               │
│   - xl_prev      → 上一条 WAL 的 LSN          │
│   - xl_xid       → 事务 ID                    │
│   - xl_info      → 操作类型辅助信息             │
│   - xl_rmid      → Resource Manager ID       │
│   - xl_tot_len   → 本条记录总长度(含头部)      │
├──────────────────────────────────────────────┤
│  [Block Reference(s)] -> 紧邻XLogRecord后边的连续空间,和buffer/block相关的数据
│   - 通过XLogRegisterBuffer/Block()来注册       |
├──────────────────────────────────────────────┤
│  [Main Data] -> 其他的补充数据                  │
│   - 通过XLogRegisterData()来注册               │
└──────────────────────────────────────────────┘

wal_level 共有 3 个级别,默认是 replica

  • minimal:仍然支持本地崩溃恢复,但不包含支持 PITR、归档和流复制所需的完整信息,日志量最小。
  • replica:默认级别,支持主备复制、PITR 等。
  • logical:在 replica 基础上进一步支持逻辑复制,便于外部 CDC 模块同步变更。

通过 pg_waldump 可以把 record 的逻辑内容解析出来。例如下面这段由 insert 产生的 xlog:

rmgr: Heap        len (rec/tot):     54/   166, tx:        849, 
    lsn: 0/0182D740, prev 0/0182D708, desc: INSERT off 2 flags 0x00, blkref #0: rel 1663/16384/65539 blk 0 FPW

rmgr: Transaction len (rec/tot):     34/    34, tx:        849, 
    lsn: 0/0182D7E8, prev 0/0182D740, desc: COMMIT 2025-05-07 02:51:37.928532 UTC

可以这样理解:

  • rmgr:资源管理器类型。
  • lenrec(record)表示这条记录的有效负载长度;tot(total)表示它在 WAL 文件中实际占用的总字节数。
  • tx:事务 ID。
  • lsn/prev:本条和上一条记录的 LSN,通过它们可以串起整个 WAL 链。

    例如示例中:0x0182D740 + 168166 需要按 8 字节对齐到 168)=0x0182D7E8

  • desc:由 rm_desc() 回调生成的简要描述信息。

    这里的 heap insert 描述表示:向 1663/16384/65539 这个对象的第 0 页插入了一个元组,槽位是 offset 2,并且记录了该页的 Full Page Image(FPI/FPW,全页镜像)。

由于 xlog 需要记录页面变化,特别是 FPI/FPW,因此其磁盘 I/O 和空间占用都很可观。PG 支持通过 wal_compression 对 full-page image 做压缩,但它不是默认开启的。

像上面的示例里,如果记录里带了 FPI,那么压缩前可能接近 8KB,而压缩后会显著变小。

基础操作

对于某一类资源,和 xlog 相关的操作主要分两类:注册数据并写入 xlog,以及在 recovery 阶段根据 xlog 做 redo。

★注册写入

这类逻辑通常散落在各资源对象自己的写路径中,最终统一落到 XLogRegisterBuffer/Data/... 这一组接口上。

Register 系列函数之间有一些细微区别:

  • Buffer / BufData:前者注册一个 buffer 的元数据,后者注册这个 buffer 对应的业务数据。heap 相关的 xlog 操作里有很多例子。
  • Data:注册和 buffer 无关的补充数据。多次注册的数据会连续放在 [Main Data] 区域,边界需要业务自己处理。sequence 的 xlog 操作里有例子。
  • 另外新版本代码中也可以看到更灵活的 block 注册方式,它不一定依赖已有 buffer,适合结合具体代码理解。

以 insert 为例,heap_insert() 末尾就是典型的 xlog 注册逻辑:

heap_insert(){
    buffer = RelationGetBufferForTuple(relation, ...);  // get buffer并加锁
    ... // 开启CRIT_SECTION,并将插入内容写入buffer
    MarkBufferDirty(buffer);    // 标脏buffer

    // xlog注册数据
    XLogBeginInsert();
    XLogRegisterData((char *) &xlrec, sizeof(xlrec));   // 操作描述,struct xl_heap_insert
    XLogRegisterBuffer(0, buffer, REGBUF_STANDARD);     // buf元数据
    XLogRegisterBufData(0, tuple_data, tuple_len);      // tuple修改数据
    lsn = XLogInsert(RM_HEAP_ID, XLOG_HEAP_INSERT);     // 插入xlog到全局写缓存
    PageSetLSN(page, lsn);  // 设置页面的lsn

    ... // 结束CRIT_SECTION并放锁
}

★重做 redo

redo 操作则统一挂在对应的 RM 接口 struct RmgrData 上,见 src/include/access/rmgrlist.h,例如:

PG_RMGR(RM_HEAP_ID, "Heap", heap_redo, heap_desc, heap_identify, NULL, NULL, heap_mask, heap_decode)

其中核心逻辑在各自的 xxx_redo() 中。比如 heap_redo() 最终会调用 heap_xlog_insert() 完成重做,简化如下:

xlrec = (xl_heap_insert *) XLogRecGetData(record);          // 提取maindata处的描述元信息
data = XLogRecGetBlockData(record, 0, &datalen);            // 提取位于block 0的tuple数据
PageAddItem(page, (Item) htup, newlen, xlrec->offnum, ...); // 根据元信息的插入位置插入到page中
MarkBufferDirty(buffer);	// 插入后,标脏buffer

这部分逻辑是在 Startup 进程的 recovery 阶段执行的,调用链大致如下:

StartupXLOG() → ReadRecord() → XLogReadRecord() → DecodeXLogRecord() 
    → RmgrTable[record->xl_rmid].rm_redo(record) 
        → heap_redo(XLogReaderState *record) → heap_xlog_insert()

★并发控制

XLogInsert() 会把 xlog 记录插入共享内存中的全局 WAL buffers。它本身也是一个环形缓冲区:

                        WAL Buffers
    +----------+----------+----------+----------+----------+
    | Page 0   | Page 1   | Page 2   | ...      | Page N-1 |
    +----------+----------+----------+----------+----------+
                    ↑                                ↑        
                FlushPtr(已刷盘位置)          WritePtr(当前写入位置)

除了 WAL buffer 本身之外,还配套有一个控制结构 XLogCtlData

struct XLogCtlData {
    XLogCtlInsert InsertCtl;    // 插入控制结构(锁)

    XLogRecPtr  WriteRqstPtr;   // 后端写入请求位置
    XLogRecPtr  Insert;         // 当前写入位置
    XLogRecPtr  Write;          // 已写入磁盘的位置
    XLogRecPtr  Flush;          // 已 fsync 的位置
    XLogwrtRqst LogwrtResult;   // 当前写入状态

    char        *pages;         // WAL buffer 基地址
}

记录插入 buffer 后,后续写入 / 刷入磁盘既可能由后端同步触发,也可能由 WAL writer 等后台进程异步完成。这个话题适合以后放到“后端写进程”那篇文章里统一展开。

另外,WAL buffer 还需要并发控制。可以粗略理解成“先互斥预留空间,再并发写入内容”。这里主要涉及三个 LWLock:

  • xlogctl->Insert.insertpos_lck:用于预留空间,基本是全互斥的。
  • WALInsertLock:控制 insert 阶段的并发度。多个后端虽然可以并发写,但写入区间不会重叠,因为预留空间阶段已经分好了。
  • WALWriteLock:保护把 WAL buffer 内容写到磁盘的过程。

显而易见,WAL buffers 是高并发热点,因此也是非常值得优化的一层。树杰《事务处理》里有专门章节详细介绍这部分。

复制流

除了做 recovery 之外,xlog 作为复制流(例如主从复制)也是非常重要的应用场景。几个基础点如下:

  • 从库的基线数据通常来自主库的 pg_basebackup,之后再持续接收 WAL。
  • 复制进程上,主库的 walsender 负责发送 WAL,从库的 walreceiver 负责接收,而从库的 startup 进程负责回放。
  • synchronous_standby_names 用来指定哪些从库参与同步复制;在启用了同步复制的前提下,synchronous_commit 决定主库事务等待到什么级别,例如 remote_write / on(等待远端 flush)/ remote_apply 等。一般来说,等待到从库 flush 往往是个不错的折中点。

    GP 的 Primary/Mirror 场景里如果默认采用异步模式,数据安全性就会相对弱一些。

  • 主从复制里还会遇到很多复杂情况,核心在于主从两边都要感知彼此状态,并传输必要信息。

    这部分可以顺着 errdetail_recovery_conflict() 里的各种冲突类型继续看。

从库里一个非常重要的场景是 hot standby:也就是备库对外提供只读查询服务。这会引入更多复杂情况。

例如:PROCSIG_RECOVERY_CONFLICT_SNAPSHOT,表示主库上的 vacuum 已经清理掉某些旧版本,而备库上的查询还需要访问这些版本。

这部分可以参考树杰的《事务处理》一书中章节以及代码细节。

Discussion

xlog 是数据库系统区别于很多其他数据系统的重要特性之一。它本身有点枯燥,但作用极其关键,因为它直接关系到崩溃恢复、复制以及数据不丢失。同时,写 log 也会带来大量写 I/O,因此它天然也是性能优化重点。

近期有一个很好的 xlog 应用案例:Neon DB

  • 其 Compute 模块本质上就是改造过的 hot standby 实例。
  • 其 PageServer 模块则把相关 redo 逻辑服务化:可以根据历史 xlog 回放出任意 LSN 点对应的页面数据。

很值得深入做代码学习,后续有机会再单独写文章分享。

Questions:

  1. 为什么需要引入FPW,为了解决什么问题?
  2. 关键区START_CRIT_SECTION(),为了解决什么问题?

    核心点是不能中断这个过程,必须保证原子性的完成整个过程

  3. 数据库系统crash时尚未提交的事务,在重启后redo的过程中如何处理?

    in-doubt list

  4. 之前文章提到过select时可能修改hint bits,那么它需要写log吗?

    XLOG_FPI_FOR_HINT

  5. We must mark the buffer dirty before doing XLogInsert(),why?

    mark dirty放到insert后是不是也行?我也不是完全弄清楚这个