postgres中的xlog即wal(write-ahead log),它是数据库事务中持久性(D)的基础保证。

简单说就是在内存中的用户数据变更之前,都要提前写入磁盘上的xlog(除非这个变更可以从其他数据源快速回复)。从而保证用户数据不会因为数据库系统崩溃而丢失。

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

  • 崩溃恢复: 在数据库崩溃后,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,只支持最基础的崩溃恢复,日志量最小
  • 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:资源类型标识
  • len:rec(record)表示这条记录的实际有效内容长度(不包括padding和metadata);tot(total)表示这条记录在文件中所占用的总字节数
  • tx:事务id
  • lsn/prev:本条和上一条记录的偏移位置(即我们常说的LSN),通过它们可以进行双向遍历

    示例中:0x0182D740 + 168(166需要进行8字节对齐到168) = 0x0182D7E8

  • desc:rm_desc()回调函数生成的资源简易描述信息

    示例中heap insert的desc表明:向1663/16384/65539对象的第0页中插入了一个元组,插入槽位为offset 2,无flag,同时记录了该页面的Full Page Image(FPW,全页镜像)。

由于xlog需要记录页面上的变化(特别是FPW),其磁盘IO和空间占用都是非常大的。因此从pg13开始支持对xlog进行压缩,配置项wal_compression,默认是启用的。

如上示例中的FPW全页大小是8KB,而压缩后只占用了168B。

基础操作

对于某一类资源,其上xlog操作主要分2类:注册数据并写入xlog和recovery时根据xlog进行重做(redo)。

★注册写入

这类操作散放在对应资源的各个写操作中,最终调用XLogRegisterBuffer/Block/Data系列函数。

Register系列函数之间的细微区别:

  • Buffer/BufData:前者注册一个buffer的元数据,后者注册它的业务数据。具体例子可以看heap相关的xlog操作
  • Data:和buffer无关的补充数据,多次注册的数据连续储存在[Main Data]区域,需要业务来自己切分边界。具体例子可以看sequence的xlog操作
  • Block:pg15后引入的新函数,是RegisterBuffer()的更灵活替代,不一定依赖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

重做操作则统一放到了对应的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()中,比如head_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记录插入到shm中的全局wal buffers中,它也是一个环状缓冲区:

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

除了缓冲区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需要进行并发控制,简单说其插入方式是:先互斥预留空间再并发插入。这里主要用到了三个lw锁:

  • xlogctl->insert->insertpos_lck:预留空间用,完全互斥
  • WALInsertLock:控制insert时的并发度(默认8),并发插入的空间一定不重叠,因为之前预留空间时已经确定了
  • WALWriteLock:用于保护将buffer内容写入磁盘的操作

显而易见wal buffers存在大量并发写入,是一个非常值得优化的地方,树杰的《事务处理》一书中有章节详细介绍。

复制流

除了做Recovery之外,作为复制流(比如主从复制)也是xlog的重要应用场景,一些基础:

  • 从库上的基线数据:主库上使用pg_basebackup,然后scp过去(不需要xlog)
  • 主从复制进程:主库的walsender进程给从库的walreceiver进程传输xlog,然后从库的startup进程再进行回放
  • synchronous_commit参数:可以配置那些从库需要同步复制,复制的级别:off/remote_write/flush/apply,一般flush(从库刷盘)是一个不错的折中选择

    gp的Primary和Mirror之间的synchronous_commit默认值是off(异步),因此数据不是那么安全

  • 主从复制中可能出现的各种复杂情况,核心是主从双方都需要感知到对方的存在(并传输必要的数据)

    需要了解errdetail_recovery_conflict()中的各种异常

从库中一个非常重要的使用场景是hot standby模式:即从库可以进行服务读查询。从而会引入更多的复杂情况。

比如:PROCSIG_RECOVERY_CONFLICT_SNAPSHOT -> 主库vacuum清理了某些旧版本数据,而备库上的查询需要访问这些版本。

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

Discussion

xlog是数据库系统区别其他数据系统的一个重要特性,相对比较枯燥,但是功能又特别重要(防止数据丢失/不一致)。同时写log也产生大量写IO,这也是一个重要的性能优化点。

近期正好有一个非常好的xlog应用case:Neon DB

  • 其Compute模块就是进行改造后的hotstandby实例
  • 其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后是不是也行?我也不是完全弄清楚这个