CLOG Redo: Postgres vs Neon
CLOG / pg_xact 的 redo(事务提交状态位图):
- Postgres 原生实现:
XLOG_XACT_*记录会在 redo 时更新pg_xact(CLOG),RM_CLOG_ID记录负责 ZEROPAGE/TRUNCATE 等维护。 - Neon 实现:pageserver ingest 阶段把 PG 的 xact/clog WAL "翻译"为少量 Neon 自定义记录(
NeonWalRecord::*),然后在 Rust 侧按页回放,避免走 walredo postgres 进程。
1. Postgres(v17) 的 CLOG redo 过程
1.1 XACT rmgr: commit/abort 触发对 pg_xact 的更新
Postgres 在恢复/redo 时,解析 RM_XACT_ID 的 commit record,然后调用 TransactionIdCommitTree/AbortTree 去更新 pg_xact。
来源:xact.c
c
/*
* xact_redo_commit(): redo 一条 XLOG_XACT_COMMIT 记录
* 关键点:最终会把 xid + subxacts 标记到 pg_xact (CLOG) 里。
*/
static void
xact_redo_commit(xl_xact_parsed_commit *parsed,
TransactionId xid,
XLogRecPtr lsn,
RepOriginId origin_id)
{
...
if (standbyState == STANDBY_DISABLED)
{
/*
* 直接把 xid/subxids 标记为 committed(同步提交场景 lsn 可忽略)
*/
TransactionIdCommitTree(xid, parsed->nsubxacts, parsed->subxacts);
}
else
{
/*
* 恢复时走异步协议:需要记录 commit record LSN,
* 以保证后续 hint bits/一致性相关规则不会越界。
*/
TransactionIdAsyncCommitTree(xid, parsed->nsubxacts, parsed->subxacts, lsn);
...
}
...
}
TransactionIdCommitTree/AsyncCommitTree 只是薄封装,本质都是 TransactionIdSetTreeStatus(...)。
来源:transam.c
c
/*
* xid + 子事务树的状态写入 pg_xact。
* 对 commit,最终状态是 TRANSACTION_STATUS_COMMITTED。
*/
void
TransactionIdCommitTree(TransactionId xid, int nxids, TransactionId *xids)
{
TransactionIdSetTreeStatus(xid, nxids, xids,
TRANSACTION_STATUS_COMMITTED,
InvalidXLogRecPtr);
}
void
TransactionIdAsyncCommitTree(TransactionId xid, int nxids, TransactionId *xids,
XLogRecPtr lsn)
{
TransactionIdSetTreeStatus(xid, nxids, xids,
TRANSACTION_STATUS_COMMITTED, lsn);
}
1.2 clog.c: TransactionIdSetTreeStatus 与"跨页原子性"的 subcommitted 设计
Postgres 的 CLOG(pg_xact)每个 xid 占 2bit,多个 xid 落在不同 CLOG page 时,需要用 SUB_COMMITTED 作为中间态来维持"对外观测的原子性"。
来源:clog.c
c
/*
* TransactionIdSetTreeStatus:
* - 如果 xid 和全部 subxids 都落在同一页:一次加锁、一次写完。
* - 如果跨页:
* 1) 先把非首页上的 subxids 标记为 SUB_COMMITTED
* 2) 再把首页(含 top xid)标 committed
* 3) 最后把其余页上的 subxids 从 SUB_COMMITTED 升到 committed
*
* 目的:让并发检查者看到 top xid committed 时,子事务树状态"整体上"不违背语义。
*/
void
TransactionIdSetTreeStatus(TransactionId xid, int nsubxids,
TransactionId *subxids, XidStatus status, XLogRecPtr lsn)
{
int64 pageno = TransactionIdToPage(xid);
int i;
for (i = 0; i < nsubxids; i++)
if (TransactionIdToPage(subxids[i]) != pageno)
break;
if (i == nsubxids)
{
TransactionIdSetPageStatus(xid, nsubxids, subxids, status, lsn, pageno, true);
}
else
{
int nsubxids_on_first_page = i;
if (status == TRANSACTION_STATUS_COMMITTED)
set_status_by_pages(nsubxids - nsubxids_on_first_page,
subxids + nsubxids_on_first_page,
TRANSACTION_STATUS_SUB_COMMITTED, lsn);
TransactionIdSetPageStatus(xid, nsubxids_on_first_page, subxids, status,
lsn, pageno, false);
set_status_by_pages(nsubxids - nsubxids_on_first_page,
subxids + nsubxids_on_first_page,
status, lsn);
}
}
1.3 写入某个 xid 的 2bit:TransactionIdSetStatusBit
真正"改位图"的核心函数,直接在 SLRU 缓冲页内存上修改对应 byte/bit,然后标记 LSN 组信息等。
来源:clog.c
c
/*
* TransactionIdSetStatusBit:
* - 要求调用方已持有 SLRU bank lock(保护共享缓冲池)
* - 根据 xid 计算在 page 内的 byteno/bshift,然后写 2bit 状态
* - 对 async commit,会更新 group_lsn[] 用于 WAL flush 规则
*/
static void
TransactionIdSetStatusBit(TransactionId xid, XidStatus status, XLogRecPtr lsn, int slotno)
{
int byteno = TransactionIdToByte(xid);
int bshift = TransactionIdToBIndex(xid) * CLOG_BITS_PER_XACT;
char *byteptr;
char byteval;
char curval;
byteptr = XactCtl->shared->page_buffer[slotno] + byteno;
curval = (*byteptr >> bshift) & CLOG_XACT_BITMASK;
/* 修改 2bit */
byteval = *byteptr;
byteval &= ~(((1 << CLOG_BITS_PER_XACT) - 1) << bshift);
byteval |= (status << bshift);
*byteptr = byteval;
/* 异步提交下会更新 group_lsn[...],用于推断"flush 到哪个 LSN 才安全" */
if (!XLogRecPtrIsInvalid(lsn))
...
}
1.4 RM_CLOG_ID: ZEROPAGE/TRUNCATE 的 redo
RM_CLOG_ID 只负责 CLOG "页面初始化为 0"以及"截断旧段"。redo 时通过 SimpleLruWritePage/SimpleLruTruncate 操作 CLOG 的 SLRU 文件。
来源:clog.c
c
/*
* clog_redo:
* - CLOG_ZEROPAGE: 创建/清零新 page,并写入对应 SLRU 段文件
* - CLOG_TRUNCATE: 推进 oldestXid 并删除过旧段文件
*/
void
clog_redo(XLogReaderState *record)
{
uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
if (info == CLOG_ZEROPAGE)
{
int64 pageno;
int slotno;
LWLock *lock;
memcpy(&pageno, XLogRecGetData(record), sizeof(pageno));
lock = SimpleLruGetBankLock(XactCtl, pageno);
LWLockAcquire(lock, LW_EXCLUSIVE);
slotno = ZeroCLOGPage(pageno, false);
SimpleLruWritePage(XactCtl, slotno);
LWLockRelease(lock);
}
else if (info == CLOG_TRUNCATE)
{
xl_clog_truncate xlrec;
memcpy(&xlrec, XLogRecGetData(record), sizeof(xl_clog_truncate));
AdvanceOldestClogXid(xlrec.oldestXact);
SimpleLruTruncate(XactCtl, xlrec.pageno);
}
else
elog(PANIC, "clog_redo: unknown op code %u", info);
}
2. Neon 的 CLOG redo 过程(pageserver)
Neon 的关键变化是:不直接把 PG 的 RM_XACT_ID/RM_CLOG_ID 原始 redo 交给 walredo postgres 进程,而是:
- ingest 阶段把"对 pg_xact 的影响"翻译成 Neon 自定义记录(
NeonWalRecord::ClogSetCommitted/Aborted)并写入 pageserver 的 KV; - 读页重建时用 Rust 代码按页回放这些记录("改位图")。
2.1 ingest:把 XLOG_XACT_COMMIT/ABORT 翻译成 ClogSetCommitted/Aborted
来源:walingest.rs
rust
// ingest_xact_record():
// - 解析 commit/abort record 中的 xid + subxacts
// - 按"落在哪个 CLOG page"分组
// - 生成 NeonWalRecord::ClogSetCommitted / ClogSetAborted 写到对应 SLRU page key
//
// 注意:这里只生成 "最终态" committed/aborted,并没有生成 Postgres 那种跨页 subcommitted 中间态。
modification.put_slru_wal_record(
SlruKind::Clog,
segno,
rpageno,
if is_commit {
NeonWalRecord::ClogSetCommitted { xids: page_xids, timestamp: parsed.xact_time }
} else {
NeonWalRecord::ClogSetAborted { xids: page_xids }
},
)?;
2.2 ingest:CLOG_ZEROPAGE 直接写入 ZERO_PAGE(作为 base image)
来源:walingest.rs
rust
// ingest_clog_zero_page():
// Postgres 会把新 CLOG page 清零并写到 pg_xact 段文件。
// Neon 在 pageserver 里则把这一页作为一张"page image"存进 KV(后续回放需要 base image)。
self.put_slru_page_image(
modification,
SlruKind::Clog,
segno,
rpageno,
ZERO_PAGE.clone(),
ctx,
).await?;
2.3 apply(redo):Rust 侧直接改位图,而不是走 SimpleLru
Neon 回放 ClogSetCommitted/Aborted 时,会验证 key 是 SlruKind::Clog,然后对每个 xid 在 page buffer 上写 2bit 状态。
rust
// apply_in_neon():
// - 输入:某一页的 base image(通常来自 ZERO_PAGE 或历史 image) + 该页的 WAL deltas
// - 输出:更新后的页字节
NeonWalRecord::ClogSetCommitted { xids, timestamp } => {
let (slru_kind, segno, blknum) = key.to_slru_block().context("invalid record")?;
assert_eq!(slru_kind, SlruKind::Clog);
for &xid in xids {
// 校验该 xid 确实落在这个 (segno, blknum) 上
...
transaction_id_set_status(
xid,
pg_constants::TRANSACTION_STATUS_COMMITTED,
page,
);
}
// Neon 额外把 commit timestamp 追加到页尾(不是 Postgres 原生 pg_xact 格式)
...
}
NeonWalRecord::ClogSetAborted { xids } => {
...
transaction_id_set_status(xid, pg_constants::TRANSACTION_STATUS_ABORTED, page);
}
这里的 transaction_id_set_status() 就是对 CLOG 页的 byte/bit 做更新,逻辑与 Postgres 的 TransactionIdSetStatusBit 对应,但不涉及共享缓存/锁/刷盘。
rust
// transaction_id_set_status():
// 计算 byteno/bshift,并写 2bit 状态到页内。
pub fn transaction_id_set_status(xid: u32, status: u8, page: &mut BytesMut) {
let byteno: usize =
((xid % pg_constants::CLOG_XACTS_PER_PAGE) / pg_constants::CLOG_XACTS_PER_BYTE) as usize;
let bshift: u8 =
((xid % pg_constants::CLOG_XACTS_PER_BYTE) * pg_constants::CLOG_BITS_PER_XACT as u32) as u8;
page[byteno] =
(page[byteno] & !(pg_constants::CLOG_XACT_BITMASK << bshift)) | (status << bshift);
}
3. 核心差异对比(结论)
3.1 "是否维护 SLRU 缓冲池/刷盘"不同
- Postgres:
- redo 目标是本地
PGDATA/pg_xact段文件 - 通过
slru.c的共享缓冲池 + 锁(LWLock)管理并发,必要时SimpleLruWritePage刷盘 - 还维护
group_lsn[]来满足 WAL rule(异步提交时尤其关键)
- redo 目标是本地
- Neon:
- redo 目标是 pageserver 的 版本化 KV 中的 "SLRU page key"
- 不需要实现
SimpleLru的共享缓存/LRU/刷盘;按需读取 base image 并回放得到目标页 slru_*keyspace 只是一种"命名空间",底层仍是 timeline 的 layer 存储
3.2 "跨页 subcommitted 中间态"处理不同
- Postgres:
TransactionIdSetTreeStatus在 commit 且跨页时会先写SUB_COMMITTED再最终 committed- 目的:并发观察者在读取 CLOG 时不会看到语义上不一致的中间状态
- Neon:
- ingest 时按页把 xid/subxids 分组,直接写 committed/aborted(不写 SUB_COMMITTED)
- 原因:Neon 的可见性是 按 LSN 版本化 的;commit 对应的
ClogSetCommitted记录只在 commit 的 LSN 之后可见,
从时间维度上避免了 Postgres "同一时刻跨页更新导致的中间态观测"问题(读某个 LSN 的视图是稳定的)。
3.3 timestamp 存储方式不同(Neon 的扩展)
- Postgres:
pg_xact只存 2bit 状态;commit timestamp 由commit_tsSLRU(可选)维护
- Neon:
- 在
ClogSetCommittedapply 时把 timestamp 附加到页尾(见 apply_neon.rs),属于 Neon 为功能/查询优化做的扩展格式。
- 在