当前位置: 首页 > postgresql, WAL日志 > 正文

一: 结构:

WAL日志的结构:

================================================================================
【 宏观容器 】 16MB WAL Segment File (例: 000000010000000000000001)
================================================================================

├─【 切片 0 】 WAL Page 0 (严格对齐的 8192 Bytes / 8KB)
│ │
│ ├─ [ 1. 页头防伪区 ] XLogPageHeaderData (校验当前 8KB 页面的合法性)
│ │ ├─ xlp_magic : 0xD097 (版本魔法数字,不对直接 PANIC)
│ │ ├─ xlp_info : 标志位 (例如:说明下面是否有上一页被截断的残骸)
│ │ ├─ xlp_tli : 1 (时间线 ID,平行宇宙编号)
│ │ ├─ xlp_pageaddr : 0/1000000 (当前这 8KB 物理起点的绝对 LSN 坐标)
│ │ └─ xlp_rem_len : 0 (跨页拼接长度)
│ │
│ ├─ [ 2. 录像带 1 ] XLogRecord 1 (比如:你的那句 INSERT 动作)
│ │ │
│ │ ├─ [ A. 动作中枢 ] XLogRecord (通用头部)
│ │ │ ├─ xl_tot_len : 128 Bytes (这条录像带的总长度)
│ │ │ ├─ xl_xid : 847291 (执行这个动作的事务 ID / xmin)
│ │ │ ├─ xl_prev : 0/0FFFF80 (铁链:指向上一个属于该事务日志的 LSN)
│ │ │ ├─ xl_info : XLOG_HEAP_INSERT (微指令:堆表插入)
│ │ │ ├─ xl_rmid : RM_HEAP_ID (分发器:交给 Heap 堆表引擎重放)
│ │ │ └─ xl_crc : 0xA1B2C3D4 (CRC32C:物理防线的最后一道校验码)
│ │ │
│ │ ├─ [ B. 物理坐标 ] XLogRecordBlockHeader (指明要修改谁?)
│ │ │ └─ 定位符 : RelFileNode (哪个表文件) + BlockNumber (文件的第几个 8KB 块)
│ │ │
│ │ └─ [ C. 幽灵载荷 ] Payload / Data (真正的修改内容)
│ │ └─ 载荷内容 : Tuple Data (新插入的那行真实二进制数据)
│ │ 或者 FPW (如果触发全页写,这里塞入一整个 8KB 旧页镜像)
│ │
│ ├─ [ 3. 录像带 2 ] XLogRecord 2 (另一个并发 UPDATE 动作...)
│ │ └─ (结构同上,首尾相接,毫无缝隙地紧紧挤在一起)
│ │
│ └─ [ 4. 留白区 ] Free Space (如果这个 8KB 还没写满,剩下的 0x00 空白)

├─【 切片 1 】 WAL Page 1 (严格对齐的 8192 Bytes / 8KB)
│ ├─ [ 1. 页头防伪区 ] XLogPageHeaderData
│ ├─ [ 2. 录像带 3 ] XLogRecord 3
│ └─ ...

... (一直切割下去)

└─【 切片 2047 】 WAL Page 2047 (这 16MB 文件的最后一个 8KB 页)
================================================================================



设计逻辑,从宏观到纳米级,划分为四大物理推导层:


第一层推导:为什么不能是一根“无限长的面条”?(宏观容器层)

你的面临的灾难:如果你把数据库里成千上万个并发事务的修改动作,像挤牙膏一样连续不断地写进一个巨大的文件里。突然,机房停电了。硬盘在写入最后一秒时发生了物理颤抖,导致最后几十个字节变成了乱码(0x00)。当你重启去读这个文件时,因为没有物理边界,整个日志文件全部报废,全库数据陪葬。

你的底层解法:物理切割与 8KB 封窗 你极其冷酷地做出了第一个架构决定:绝不能连续写入!必须像造高铁一样,一节车厢一节车厢地造。

  • 列车(Segment File):你规定,硬盘上的每个物理文件最大只能是 16MB。这样即使一个文件彻底损毁,最多只丢失 16MB 的录像,把爆炸半径控制到了极限。
  • 车厢(WAL Page):在这 16MB 内部,你挥动电锯,将其极其精准地切分为 2048 个标准的 8192 字节(8KB)块
    • 白话推导:为什么偏偏是 8KB?因为底层 Linux 操作系统和固态硬盘的 I/O 调度,天生就是以块(Block)为单位的。8KB 的绝对对齐,保证了日志写入网卡和磁盘时,能够以最高效的“整块吞吐”滑入物理扇区。

第二层推导:如何防止“幽灵车厢”?(页头校验层)

你的面临的灾难:既然切成了 2048 个 8KB 的车厢,假如硬盘发生坏道,中间的第 500 个车厢完全变成了乱码。数据库重启后(Redo 引擎)顺着轨道往前开,开进这节乱码车厢时,怎么才能立刻发现不对劲并踩死刹车,而不是把乱码当成真实数据写进数据库?

你的底层解法:给每一节车厢焊死一个“基因身份证” 你决定,在每一个 8KB 车厢的最前面,强行塞入一个极其严格的 C 语言结构体:XLogPageHeaderData

  • 白话逻辑推演
    • xlp_magic(防走火锁):比如固定值 0xD097。Redo 引擎读进来第一眼先看这个,如果不是这个数,说明这根本不是 PG 15 的日志(可能是老版本残留,或者是硬盘坏道乱码)。内核瞬间触发 PANIC(宕机自保),绝对不允许脏东西污染全库。
    • xlp_pageaddr(GPS 定位):Redo 引擎读到这个 8KB 时,可以立刻核对:“我本来想读 LSN 为 1000 的日志,你这个页头写着你是 LSN 5000,位置错乱了!”立刻拦截。
    • xlp_rem_len(车厢缝合术):如果一条修改记录极其庞大(比如一个全页写入 FPW),一个 8KB 车厢装不下,必须跨越到下一个 8KB 车厢。这个字段就是告诉引擎:“别急着解析下一页的开头,前一页还有 200 个字节的‘屁股’落在这个车厢的头部了,你先把它们拼起来。”

第三层推导:录像带的核心内脏(真正的日志包 XLogRecord

越过车厢的头部安检区,往后看,就是密密麻麻、首尾相接的“修改动作录像带”了。 你的一句 UPDATE users SET age = 30 WHERE id = 1,在这里被极其暴力地压缩成了一个纳秒级的 C 语言结构体:XLogRecord

这就是原厂面试中最核心的深水区。你必须推导出这 6 个字段的生死必然性:

  • 白话逻辑推演
    1. xl_tot_len(切割刀):因为每个动作大小不同(改 1 个字母和改 1 万个字母),Redo 引擎必须知道这条日志有多长,才能精准地用指针“跳”到下一条日志的开头。
    2. xl_xid(责任人):如果事务突然报错需要回滚(Rollback),内核必须知道这条物理修改是哪个哥们干的。
    3. xl_prev(绝杀级设计:逆向幽灵铁链):在极高并发下,各个事务的日志是像大杂烩一样混在一起写入的。如果事务 847 失败了,内核怎么把事务 847 的所有动作全找出来?解法:每条日志的 xl_prev 都死死指向自己这个事务上一条日志的绝对 LSN 坐标。这就等于在杂乱的物理硬盘上,用一根不可见的铁链,把同一个事务的操作从后往前串了起来。
    4. xl_rmidxl_info(路由分发中枢):WAL 日志极其底层,它根本听不懂 SQL 语句。rmid(资源管理器 ID)就像快递分拣中心,一看是 RM_HEAP_ID,就一把将这段日志甩给处理数据表的 C 函数;一看是 RM_BTREE_ID,就甩给处理索引的函数。而 xl_info 则告诉接手的人:“这是一次 INSERT 动作,还是一次 PAGE_SPLIT(页分裂)动作”。
    5. xl_crc(防篡改核按钮):在这条日志落盘前,内核利用 CPU 算力对整条日志生成一个 CRC32C 校验码。恢复时,Redo 引擎再算一遍。哪怕这中间有 1 个晶体管发生了电磁干扰导致 1 个比特位翻转(0 变成 1),两次计算的 CRC 将对不上。内核立刻判定日志物理损坏,拔刀自杀(停止恢复),绝对不把错误数据写进硬盘!

第四层推导:导弹的最终制导系统(物理载荷 Payload)

有了动作(XLogRecord),但这个动作到底要作用于数据库里哪张表的哪一行? 紧跟在 XLogRecord 结构体后面的,就是它的**“制导坐标”和“核弹头”**。

  1. 制导坐标(XLogRecordBlockHeader
    • RelFileNode:三个数字(表空间 OID,数据库 OID,表 OID)。这三个数字拼在一起,就是这张表在 Linux 底层的真实文件路径(比如 base/16384/24576)。
    • BlockNumber:告诉引擎,不要全表扫描,直接把这个文件的第 8547 个 8KB 数据块给我从硬盘抽到内存里。
  2. 核弹头(真正的 Payload 数据)
    • 常规状态:这里装的就是那句 UPDATE 产生的几个字节的新数据(Tuple Data)。
    • 极端防御状态(FPW 全页写入):我们在之前推演过的最高防线。如果是 Checkpoint 后的第一次修改,这里装的根本不是几个字节的增量,而是极其狂暴的一整个 8192 字节(8KB)的旧数据页完整物理镜像! 只要系统发生断电(部分写失效),重启时,直接把这个 8KB 镜像“啪”地一下完整盖在损坏的磁盘数据上,瞬间完成物理层面的时光倒流修复。

架构师的最终定性与物理闭环

现在,把你的显微镜拉远,重新审视这套系统:

当你敲下一句极其简单的 SQL 时,在底层的微秒级世界里: C 语言进程先构建好你要修改的内容(Payload),装上制导坐标(BlockHeader),然后为其生成动作大脑(XLogRecord,打上 CRC 校验和 LSN 铁链)。最后,将这一整串精密的二进制乐高块,狠狠地塞进 8KB 的车厢(带有 PageHeader 防伪),拼装进 16MB 的列车(Segment File),最终伴随着操作系统的 fsync() 怒吼,死死地砸进底层的硅基硬盘中。




终极补充一:C 语言与 CPU 的物理妥协 —— MAXALIGN (字节对齐补齐)

  • 你的底层破绽:你以为一条日志的总长度(xl_tot_len)就是头部结构体加上载荷数据的精确字节数吗?比如头部 24 字节 + 数据 13 字节 = 37 字节?
  • 面试官的硬件级绞杀:错!在物理内存和物理硬盘里,它绝对不是 37 字节,它可能是 40 字节
  • 你的白话推演(硬件架构的反杀): “在 PostgreSQL 的 C 语言宏定义里,有一个极其冷酷的规则叫做 MAXALIGN(最大对齐)。通常是 8 字节对齐。 物理原因:现代 CPU 从内存读取数据时,根本不是一个字节一个字节读的,而是按 Cache Line(缓存行,通常 64 字节) 成块读取。如果一条 WAL 日志的长度不是 8 的倍数,CPU 在寻址时就会发生跨缓存行的‘未对齐物理惩罚’,导致处理速度暴跌数十倍。 底层真相:所以,当日志打包好只有 37 字节时,PostgreSQL 内核会极其奢侈地在末尾强行塞入 3 个字节的 0x00(空白废料),把它凑成 40 字节(8 的倍数)。 你看到的 WAL 日志,其实是一列塞满了透明填充物的防撞车厢。这是纯粹的软件向底层 CPU 硬件架构做出的极限性能妥协!”

终极补充二:通往 MVCC 幽灵宇宙的钥匙 —— RM_XACT_ID (事务提交日志)

这是 WAL 体系真正的绝对终点,也是你下一秒即将跨入的 MVCC 大门的唯一钥匙。

  • 你的底层困惑:我们前面推演的 XLogRecord,都是用来记录“修改了哪张表的哪行数据”(RM_HEAP_ID)。那当我敲下 COMMIT;(提交事务)的那一瞬间,系统明明没有修改任何表的数据,WAL 里记录了什么?
  • 你的白话推演(状态机的时空跃迁): 当一个事务(比如 xid = 847291)敲下 COMMIT 时,内核会生成一条极其特殊的 WAL 日志。
    • 它的分发器不是堆表,而是 RM_XACT_ID(事务状态管理器)
    • 它的载荷里没有任何业务数据,只有极其冰冷的几个字:“宣告 xid = 847291 已成功提交”。
  • 架构级核爆(衔接下一个模块): 这条 COMMIT 日志落盘的这一微秒,就是这个事务从“物理世界”跃迁到“逻辑可见世界”的唯一奇点。 此时,后台会有一个极其隐蔽的机制,顺着这条 WAL 日志,去修改内存中一个叫做 CLOG(Commit Log,提交日志) 的位图(Bitmap)。在 CLOG 里,把代表 847291 的那 2 个比特位,从 IN_PROGRESS(进行中)强行翻转为 COMMITTED(已提交)。



第一层包装:8KB 物理页的守护者 (XLogPageHeaderData)

千万别忘了,16MB 的 WAL 文件在底层是被强制切分成 2048 个 8KB(8192 字节)的逻辑页的。无论你的日志记录有多长或多短,每一个 8KB 页面的最开头,必须是这个结构体。它负责守护物理边界。

C

typedef struct XLogPageHeaderData
{
    uint16      xlp_magic;        /* 魔法校验码:标明 WAL 版本号 */
    uint16      xlp_info;         /* 标志位:跨页状态等 */
    TimeLineID  xlp_tli;          /* 时间线 ID:防脑裂的宇宙编号 */
    XLogRecPtr  xlp_pageaddr;     /* 绝对物理坐标:这个 8KB 页面在整个系统中的 LSN */
    uint32      xlp_rem_len;      /* 跨页残骸长度:如果上一条记录太长,这里说明剩下多少字节 */
} XLogPageHeaderData;
  • 架构师物理推演(绝杀考点 xlp_pageaddr:为什么每个 8KB 开头都要存自己的 LSN 地址?因为如果发生操作系统级的“页撕裂(Torn Page)”,写入的 8KB 乱序了或者只写了一半。Redo 引擎读到这个页时,拿它头部的 xlp_pageaddr 和当前读取的物理偏移量一比对,瞬间就能发现损坏,直接触发报错,绝对不把错误数据砸进数据库!

第二层包装:通用录像带头部 (XLogRecord)

跨过 8KB 的页头,紧接着的就是我们真正业务产生的日志序列。每一条独立的日志(不论是 INSERT 还是 UPDATE),都必须由这个 24 字节的定长结构体领头。

C

typedef struct XLogRecord
{
    uint32      xl_tot_len;   /* 这条日志的总长度(Header + 坐标 + 载荷) */
    TransactionId xl_xid;     /* 事务 ID(xmin/xmax 的源头) */
    XLogRecPtr  xl_prev;      /* 上一条日志的 LSN 物理指针(单向回溯链表) */
    uint8       xl_info;      /* 动作子类型(比如是 INSERT 还是 UPDATE) */
    RmgrId      xl_rmid;      /* 资源管理器 ID(路由分发:交给哪个 C 函数处理) */
    /* 2 个字节的 padding 内存对齐占位符 */
    pg_crc32c   xl_crc;       /* 这条日志的 CRC 校验码(防比特位翻转) */
} XLogRecord;
  • 架构师物理推演(解耦与路由):这个结构体极其冷酷,它完全不关心你改了哪张表。它只负责三件事:是谁干的(xl_xid)、上一个动作在哪(xl_prev)、归谁管(xl_rmid)。这就是内核设计的最高境界:元数据与业务逻辑绝对物理隔离。

第三层包装:动态寻址制导系统 (XLogRecordBlockHeader)

紧跟在 XLogRecord 屁股后面的,是 0 到 N 个块级寻址结构体。因为一个事务可能同时修改了表数据页和索引页,所以这里会有一系列的“坐标”。

C

typedef struct XLogRecordBlockHeader
{
    uint8       id;             /* 块的内部引用 ID(0, 1, 2...) */
    uint8       fork_flags;     /* 标志位:有没有镜像?是不是同一个文件? */
    uint16      data_length;    /* 紧跟在后面的 Diffs 数据载荷长度 */
    
    /* 以下内容根据 fork_flags 动态存在(极其极限的 C 语言内存压缩机制) */
    /* 如果没有 BKPBLOCK_SAME_REL 标志,才会有这个文件路由节点: */
    RelFileNode rnode;          /* 表空间OID, 库OID, 表OID */
    /* 必然存在的块号: */
    BlockNumber blkno;          /* 目标 8KB 数据页的编号 */
} XLogRecordBlockHeader;
  • 架构师物理推演(内存对齐与极致压缩):在 C 语言层面,这个结构体的大小是动态变化的!如果连续修改了同一张表的不同块,内核会极其抠门地把 rnode 省略掉,只留下 blkno。在高并发下,这种字节级别的压缩,为网卡和磁盘节省了海量的带宽。

第四层包装:物理核弹载荷 (RMGR-Specific Structs / Payload)

穿过层层防御和寻址,最后才是真正的物理血肉。这里的结构体,完全由第一层里那个 xl_rmid 决定。假设我们执行的是向 Heap 表里 INSERT 一行数据(RM_HEAP_ID),那么这里躺着的就是:

C

/* 位于 src/include/access/htup_details.h */
typedef struct xl_heap_insert
{
    OffsetNumber offnum;      /* 命中!在 8KB 页面内的第几个坑位? */
    uint8        flags;       /* 包含全部字段?还是有变长字段? */
    
    /* 紧接着的,就是 23 字节的 HeapTupleHeader 
       以及实际的用户数据("John", 25 等)的纯二进制流 */
} xl_heap_insert;

终极物理组装:内存里的异构贪吃蛇

当我们在脑海中把这四个层级首尾相连,一条极其壮观的 WAL 物理字节流就诞生了:

[XLogPageHeaderData (守护 8KB 边界)][XLogRecord (24字节,事务ID与路由)][XLogRecordBlockHeader (锁死表OID与块号)][XLogRecordBlockHeader (如果有同时修改索引,锁定索引块号)][xl_heap_insert (页内偏移量)] + [实际数据 Diffs/FPW 镜像]


了解 www.876873.xyz 的更多信息

订阅后即可通过电子邮件收到最新文章。

WAL日志的结构,深入结构体分析:等您坐沙发呢!

发表评论