Neon wal日志处理流程2

Neon WAL 解码与传输机制详解

**📋 文章摘要:**本文深入解析 Neon 数据库中 WAL 的解码与传输机制,涵盖 safekeeper 的两种协议模式、解码层次、存储链路以及 logical_message 的特殊处理流程。通过对比传统 PostgreSQL 与 Neon 的架构差异,揭示计算存储分离下的 WAL 处理优化。

📁 关键文件索引

文件 核心职责
流解码 waldecoder_handler.rs WAL page header 校验、记录重组、CRC
内容解析 walrecord.rs decode_wal_record() 块级解析
语义解码 decoder.rs InterpretedWalRecord 元数据提取 + 页面序列化
SK 写入 wal_storage.rs 写磁盘 + 流解码
SK 转发 send_interpreted_wal.rs 读磁盘 → 深度解码 → 发送
PS 接收 walreceiver_connection.rs 连接 safekeeper、收 WAL
PS 摄入 walingest.rs record 分发 + 写入 dispatch
PS 存储 inmemory_layer.rs 最终落盘 + 索引

🔍 两种解码层次

层次 函数 输入 输出 调用位置
L1 流解码 poll_decode() 原始 WAL 字节流 (Lsn, Bytes) record 边界 safekeeper 写入时、转发时
L2 内容解码 decode_wal_record() WAL record bytes DecodedWALRecord (blocks) safekeeper 转发时(解读模式)
L3 语义解码 from_bytes_filtered() DecodedWALRecord InterpretedWalRecord safekeeper 转发时(解读模式)

💡 核心要点: 是的,safekeeper 支持 两种协议模式,但 pageserver 只用解读模式。

🔄 两种模式

Vanilla 模式(原始 WAL 字节流)

send_wal.rs:542-558 --- WalSender 直接从磁盘读原始字节,通过标准 PG 复制协议 XLogData 消息发送:

复制代码
// send_wal.rs:848
let msg = BeMessage::XLogData(XLogDataBody {
    wal_start: self.start_pos.0,
    wal_end: self.end_pos.0,
    timestamp: get_current_timestamp(),
    data: send_buf,   // ← 原始 WAL 字节
});

这个模式 不给 pageserver 用,而是用于:

  • WAL proposer 恢复(safekeeper 间同步)
  • Peer safekeeper 追赶(新加入的 safekeeper 从其他节点拉 WAL)

Interpreted 模式(解读后的结构化 WAL)→ pageserver 专用

send_wal.rs:559-658 --- safekeeper 做深度解码,把 WAL 转成 InterpretedWalRecord 再发送。

Pageserver 不做 WAL Redo

在传统 PostgreSQL 中,standby 收到原始 WAL 后,会启动一个 WAL redo 进程 逐条回放。Neon 不走这条路

walreceiver_connection.rs:290-294

复制代码
PostgresClientProtocol::Vanilla => {
    return Err(WalReceiverError::Other(anyhow!(
        "Vanilla WAL receiver protocol is no longer supported for ingest"
    )));
}

pageserver 直接拒绝 raw WAL 模式。原因很简单------Neon 的架构里 pageserver 不跑 PostgreSQL 进程,没有 WAL redo 能力。它的替代方案是:

传统 PostgreSQL:

raw WAL bytes → WAL redo 进程 → 修改数据页

Neon:

raw WAL bytes → safekeeper 侧解码为 InterpretedWalRecord

→ 网络传输结构化数据

→ pageserver 直接写入 InMemoryLayer(KV 存储)

也就是说 WAL 解码的工作从 pageserver 移到了 safekeeper,pageserver 拿到的是已经解析好的"页面级 KV 操作",直接追加到存储层,不需要理解 WAL 格式,更不需要回放。

为什么不直接在 pageserver 做 WAL redo?

  1. 计算存储分离:pageserver 不运行 PG 内核,它是一个专用的存储引擎
  2. 多版本支持 :把 WAL 解码集中在 safekeeper 侧(postgres_ffi crate),避免了在每个 pageserver 上部署多版本 PG
  3. 加速恢复:解读后的数据可以直接写入 layer 文件,不需要逐条"回放"

原始字节流给谁了?

Vanilla 模式发给两类消费者:

  • walproposer(compute 节点崩溃后恢复 uncommitted WAL)
  • peer safekeeper(新节点追赶)

Pageserver 不用 Vanilla 模式 ,但它在 Interpreted 模式里 也收到了原始 WAL 字节

Pageserver 存储的就是原始 WAL 字节

serialized_batch.rs:226-230 --- 关键逻辑:

rust 复制代码
if blk.has_image {
    // 全页镜像(FPI):直接存重建后的 8KB 页面
    Value::Image(image.freeze())
} else {
    // 增量 WAL:存原始字节!!!
    Value::WalRecord(NeonWalRecord::Postgres {
        will_init: blk.will_init || blk.apply_image,
        rec: decoded.record.clone(), // ← 整个原始 WAL record 原封不动存下来
    })
}

decoded.record 就是 poll_decode() 吐出来的 原始 WAL record 字节流 (含 XLogRecord header 但不含 page header 和 CRC 那层)。

完整的存储 & 回放链路

Safekeeper 侧

text 复制代码
WAL 字节流
→ poll_decode()      找 record 边界 (page header/CRC校验)
→ decode_wal_record() 解析 record 内容 (block信息)
→ 两种产出:
   ├── FPI块 → Value::Image(8KB页面)        ← 不需要redo
   └── 非FPI块 → Value::WalRecord(
        NeonWalRecord::Postgres {
            rec: <原始WAL bytes>            ← ★ 原始字节存下来
        }
      )
→ InterpretedWalRecord → 网络发送

Pageserver 存储

text 复制代码
ingest_record() → ingest_batch()
    → InMemoryLayer::put_batch()
    → 文件追加: [key, lsn, NeonWalRecord序列化字节]

Pageserver 读路径 (页面重建)

text 复制代码
get_page@LSN:
    1. 从各层收集: (base_img, [(Lsn, NeonWalRecord), ...])
    2. request_redo(key, lsn, base_img, records)
        │
        ├── Neon WAL → apply_neon (纯Rust)
        └── PG WAL   → apply_batch_postgres
            │
            ├── 启动 postgres --wal-redo 子进程
            ├── stdin: 旧页面 + NeonWalRecord::Postgres.rec (原始WAL)
            └── stdout: 新页面

所以解读模式的真正意义

Safekeeper 做解释的目的 不是替代原始 WAL,而是:

  1. 找出哪些块被修改了 (提取 RelFileNode + blknoKey
  2. 区分 FPI 和增量(FPI 直接存页面,增量存原始 WAL 留待回放)
  3. 提取元数据(事务 commit/abort、DDL 等)
  4. 按 shard 过滤(只发送属于该 shard 的块)

增量 WAL 的原始字节始终被保留 ,因为 pageserver 在页面重建时必须把它传给 postgres --wal-redo ------ 那个进程只能理解原始 PG WAL 格式。

logical_message 的 WAL 记录根本不走 walredo 路径。

完整流程

1. Safekeeper 解码阶段

++++decoder.rs:920-943++++ --- 直接从 WAL record 里提取出文件路径 + 内容

fn decode_logical_message_record(...) {

let prefix = "neon-file:some/path"; // 从 WAL 中解析前缀

let buf = Bytes::copy_from_slice(...); // 文件内容 (原始字节)

return Ok(MetadataRecord::LogicalMessage(

LogicalMessageRecord::Put(PutLogicalMessage {

path: path.to_string(),

buf, // ← 文件内容原样提取

})

));

}

产出的是 MetadataRecord,不是 NeonWalRecord。

2. Pageserver 摄入阶段

++++walingest.rs:327-330++++ --- 直接写入 AUX 文件存储:

Some(MetadataRecord::LogicalMessage(rec)) => match rec {

LogicalMessageRecord::Put(put) => {

self.ingest_logical_message_put(put, modification, ctx).await?;

}

}

// → modification.put_file(path, &buf, ctx) // 直接存文件

没有经过 NeonWalRecord,不存入 SerializedValueBatch。

3. AUX 文件读取路径

Pageserver 读取 AUX 文件时不走 walredo ,而是直接从 AUX 文件层读取------因为它们存的就是文件内容本身,不需要页面重建。

4. 万一误入 walredo?

++++apply_neon.rs:248-251++++

NeonWalRecord::AuxFile { .. } => {

warn!("AuxFile record should not be created in aux v2"); // 直接 no-op

}

直接打 warning,不做任何操作。这个 variant 是 aux v1 时代的遗留,v2 里已废弃。

总结:logical_message 与其他 WAL 记录的对比

|---------------------------------|-------------------------------------|------------------------------|-------------------------------|
| 类型 | 解码产物 | 存储方式 | 回放方式 |
| Heap WAL (非FPI) | NeonWalRecord::Postgres { rec } | 存原始 WAL 字节到 Value::WalRecord | apply_batch_postgres (PG 子进程) |
| Heap WAL (FPI) | Value::Image | 存 8KB 页面 | 无需回放,直接用 |
| 非关系 WAL (CLOG/MultiXact/VM) | NeonWalRecord::ClogSetCommitted/... | 存结构化 delta | apply_batch_neon (纯 Rust) |
| logical_message | MetadataRecord::LogicalMessage(Put) | 直接存文件 | 不走 walredo |

logical_message 本质上是用 WAL 流捎带传输文件 (如 pg_hba.conf、postgresql.auto.conf 等配置变更),而不是描述页面修改。所以 pageserver 拿到后直接覆盖文件即可,完全不需要回放。

对,全部在 safekeeper 侧区分。核心入口就是 ++++decoder.rs:23++++ from_bytes_filtered(),它调两个子函数分别产出两类数据,填入同一个 InterpretedWalRecord。

区分点一: MetadataRecord::from_decoded_filtered**--- 按 RMGR ID 分流******

++++decoder.rs:89++++ --- 用 decoded.xl_rmid(PG WAL record 的 resource manager ID)来决定产什么元数据:

let metadata_record = match decoded.xl_rmid {

RM_HEAP_ID | RM_HEAP2_ID → decode_heapam_record() // → ClearVmBits

RM_NEON_ID → decode_neonmgr_record() // → ClearVmBits (PG16+)

RM_SMGR_ID → decode_smgr_record() // → Smgr(Create/Truncate)

RM_DBASE_ID → decode_dbase_record() // → Dbase(Create/Drop)

RM_CLOG_ID → decode_clog_record() // → Clog(ZeroPage/Truncate)

RM_XACT_ID → decode_xact_record() // → Xact(Commit/Abort/...)

RM_MULTIXACT_ID → decode_multixact_record() // → MultiXact(ZeroPage/Create/Truncate)

RM_RELMAP_ID → decode_relmap_record() // → Relmap(Update)

RM_XLOG_ID → decode_xlog_record() // → Xlog(Raw: checkpoint等)

RM_LOGICALMSG_ID → decode_logical_message_record() // → LogicalMessage(Put/Failpoint)

RM_STANDBY_ID → decode_standby_record() // → Standby(RunningXacts)

RM_REPLORIGIN_ID → decode_replorigin_record() // → Replorigin(Set/Drop)

};

// 结果写入: record.metadata_record = Some(metadata_record)

区分点二: SerializedValueBatch::from_decoded_filtered**--- 按 block 类型分流******

++++serialized_batch.rs:153++++ --- 遍历 decoded.blocks,对每个 block 判断:

for blk in decoded.blocks.iter() {

let key = rel_block_to_key(rel, blk.blkno);

// ★ 核心分岔点: FPI 还是增量?

let val = if Self::block_is_image(&decoded, blk, pg_version) {

// → Value::Image: 从 WAL 中提取完整 8KB 页面,处理 hole/压缩

Value::Image(image.freeze())

} else {

// → Value::WalRecord(NeonWalRecord::Postgres):

// 存原始 WAL 字节,留待 pageserver 侧 walredo 回放

Value::WalRecord(NeonWalRecord::Postgres {

will_init: blk.will_init || blk.apply_image,

rec: decoded.record.clone(), // ← 整个原始 WAL record

})

};

// 序列化写入 record.batch.raw

}

完整分岔图

safekeeper 收到一条 WAL record 字节

decode_wal_record(buf) → DecodedWALRecord {

xl_rmid, xl_info, ← RMGR ID 决定元数据类型

blocks: Vec<DecodedBkpBlock>, ← block 信息决定 FPI/增量

record: Bytes, ← 原始 WAL 字节

}

InterpretedWalRecord::from_bytes_filtered()

├── MetadataRecord::from_decoded_filtered()

│ │ 按 xl_rmid 分岔:

│ ├── RM_XACT_ID → XactRecord::Commit/Abort ← 事务元数据

│ ├── RM_SMGR_ID → SmgrRecord::Create/Truncate ← 存储管理元数据

│ ├── RM_DBASE_ID → DbaseRecord::Create/Drop ← 数据库元数据

│ ├── RM_CLOG_ID → ClogRecord ← CLOG 元数据

│ ├── RM_LOGICALMSG → LogicalMessageRecord::Put ← 文件传输(不走redo!)

│ ├── ...其他...

│ └── RM_HEAP_ID → HeapamRecord::ClearVmBits ← VM 位清除

│ 结果写入: record.metadata_record

└── SerializedValueBatch::from_decoded_filtered()

│ 遍历每个 block, 按 has_image 分岔:

├── blk.has_image == true

│ → Value::Image(8KB 页面) ← 直接存储, 不需要 redo

└── blk.has_image == false

→ Value::WalRecord(

NeonWalRecord::Postgres {

rec: <原始 WAL bytes> ← 存原始字节, 读时 redo

}

)

结果写入: record.batch

三种最终的 InterpretedWalRecord 形态

一条 WAL record 对应的 InterpretedWalRecord 可以是以下组合:

|---------------------------|----------------------------|------------------------|
| metadata_record | batch | 真实 PG WAL 类型举例 |
| None | 包含 Value::Image | HEAP INSERT 带 FPI |
| None | 包含 NeonWalRecord::Postgres | HEAP INSERT 无 FPI |
| Some(Xact(Commit)) | 通常为空 | XACT_COMMIT |
| Some(Smgr(Create)) | 通常为空 | SMGR_CREATE |
| Some(LogicalMessage(Put)) | 空 | neon-file: 前缀的逻辑消息 |
| Some(Heapam(ClearVmBits)) | 同时包含 VM 页面的 Value | HEAP2 VISIBLE |

所有这些都由 safekeeper 的 from_bytes_filtered一条函数完成分类 ,pageserver 收到时已经是结构化的 InterpretedWalRecord,直接按 metadata_record variant 和 batch 内容分别处理即可------MetadataRecord 走 ingest_record 的各种 ingest_* 分支,batch 走 ingest_batch 直接追加到存储层。

相关推荐
2601_965798473 小时前
Is Hygia Good for Maid & Janitorial Sites? Technical Audit
服务器·网络·数据库
Cloud云卷云舒3 小时前
海山数据库(HaishanDB)面向工业云场景技术方案
数据库·haishandb·工业云·移动云海山数据库·天工云
严同学正在努力6 小时前
从备份到恢复:我用 30 分钟恢复了误删的核心业务表
android·java·数据库·ai
#六脉神剑6 小时前
myBuilder新版本(8月,Office文件预览、Oracle数据库支持)
数据库·oracle·开发平台·数字化工具·mybuilder
花青泽6 小时前
5-数据库-SQL注入-联合查询-AND/OR绕过-day13
数据库·sql
踏着七彩祥云的小丑6 小时前
忘记Redis是否安装过时查看
数据库·redis·缓存
Nontee7 小时前
设计模式:模板方法与策略,从“每个字都认识“到能说清它们在干嘛
java·数据库·设计模式
眞bilibili8 小时前
带团队后的日常思考(十七)
数据库
laboratory agent开发8 小时前
智能体多工具串联执行中途失败,部分写入的数据如何回滚
运维·服务器·数据库