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?
- 计算存储分离:pageserver 不运行 PG 内核,它是一个专用的存储引擎
- 多版本支持 :把 WAL 解码集中在 safekeeper 侧(
postgres_fficrate),避免了在每个 pageserver 上部署多版本 PG - 加速恢复:解读后的数据可以直接写入 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,而是:
- 找出哪些块被修改了 (提取
RelFileNode+blkno→Key) - 区分 FPI 和增量(FPI 直接存页面,增量存原始 WAL 留待回放)
- 提取元数据(事务 commit/abort、DDL 等)
- 按 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 直接追加到存储层。