Xlog基础
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:资源管理器类型。len:rec(record)表示这条记录的有效负载长度;tot(total)表示它在 WAL 文件中实际占用的总字节数。tx:事务 ID。lsn/prev:本条和上一条记录的 LSN,通过它们可以串起整个 WAL 链。例如示例中:
0x0182D740 + 168(166需要按 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:
- 为什么需要引入FPW,为了解决什么问题?
- 关键区
START_CRIT_SECTION(),为了解决什么问题?核心点是不能中断这个过程,必须保证原子性的完成整个过程
- 数据库系统crash时尚未提交的事务,在重启后redo的过程中如何处理?
in-doubt list
- 之前文章提到过select时可能修改hint bits,那么它需要写log吗?
XLOG_FPI_FOR_HINT
- We must mark the buffer dirty before doing XLogInsert(),why?
mark dirty放到insert后是不是也行?我也不是完全弄清楚这个