neon源码分析(6)计算启动recovery阶段分析

总结

  • 开源 PostgreSQL 启动时,会先从 pg_control 读取最近一次 checkpoint 的位置,再读取对应的 checkpoint record 得到 checkpoint.redo;如果需要 crash recovery,就从 checkpoint.redo 开始向后 redo WAL,直到最后一条完整有效的 WAL record,然后把这条最后 WAL record的起始位置作为后续新 WAL 的 prev_lsn / xl_prev 来源,把它的结束位置作为后续新 WAL record 的写入起点。

    • 关键位点:checkpoint.redo = 停机checkpoint位点 或 上一个在线chkpt的开始位置(在线chkpt之保证起始位置前的数据)
    • 关键位点:prev_lsn = 最后一个完整wal的开始位置。
    • 关键位点:后续wal写入起点 = 最后一个完整wal的结束位置。
  • Neon primary Compute 在执行 basebackup 之前,会先通过 postgres --sync-safekeepers 或相关检查,让 Safekeepers 同步/确认出一个安全 LSN,例如 lsn=100;然后 compute_ctl 向 Pageserver 请求 basebackup@100,Pageserver 生成 PGDATA 压缩包,其中 neon.signal 记录 prev_lsn,也就是 100 之前最后一条 WAL record 的起始位置,同时生成的 pg_control.checkPointCopy.redo 被设置为 100 这个 basebackup LSN;PostgreSQL 启动时看到 neon.signal 后进入 Neon recovery 分支,不会从本地 WAL redo,而是"假装"已经读完了 prev_lsn -> 100 这条上一条 WAL record,用 prev_lsn 初始化后续新 WAL 的 xl_prev 来源,用 100 初始化后续新 WAL 的写入起点,然后正常启动。

    • 关键位点:checkpoint.redo = 100 = 请求basebackup时的LSN = safekeeper共识点
    • 关键位点:prev_lsn = 100位置上一条wal的起始位置。
    • 关键位点:后续wal写入起点 = 100。
  • 核心代码位置:

c 复制代码
// neon/vendor/postgres-v17/src/backend/access/transam/xlogrecovery.c
else if (NeonRecoveryRequested)
{
    /*
     * Neon hacks to spawn compute node without WAL.  Pretend that we
     * just finished reading the record that started at 'neonLastRec'
     * and ended at checkpoint.redo
     */
    elog(LOG, "starting with neon basebackup at LSN %X/%X, prev %X/%X",
         LSN_FORMAT_ARGS(ControlFile->checkPointCopy.redo),
         LSN_FORMAT_ARGS(neonLastRec));

    CheckPointLoc = neonLastRec;
    CheckPointTLI = ControlFile->checkPointCopy.ThisTimeLineID;
    RedoStartLSN = ControlFile->checkPointCopy.redo;

    /* make basebackup LSN available for walproposer */
    SetRedoStartLsn(RedoStartLSN);

    memcpy(&checkPoint, &ControlFile->checkPointCopy, sizeof(CheckPoint));

    ...
}

1. 什么时候会进入这个分支

进入条件是:

c 复制代码
NeonRecoveryRequested == true

NeonRecoveryRequested 是在 StartupXLOG() 一开始读取 neon.signal 时设置的:

c 复制代码
readNeonSignalFile();

readNeonSignalFile() 会尝试打开 PGDATA 下的:

text 复制代码
neon.signal

如果文件存在,就解析其中内容:

text 复制代码
PREV LSN: <lsn>
PREV LSN: none
PREV LSN: invalid

然后设置:

c 复制代码
NeonRecoveryRequested = true;

因此,进入该分支的直接前提是:

text 复制代码
PGDATA 中存在 neon.signal 文件。

这个文件由 Pageserver 在生成 basebackup tarball 时写入:

rust 复制代码
for signalfilename in ["neon.signal", "zenith.signal"] {
    self.ar.append(...);
}

完整链路是:

text 复制代码
compute_ctl 请求 Pageserver basebackup
    ↓
Pageserver 生成 PGDATA tarball
    ↓
tarball 里包含 neon.signal
    ↓
PostgreSQL StartupXLOG() 看到 neon.signal
    ↓
readNeonSignalFile() 设置 NeonRecoveryRequested = true
    ↓
xlogrecovery.c 进入 NeonRecoveryRequested 分支

所以,这个分支通常发生在:

text 复制代码
Neon Compute 从 Pageserver basebackup 启动 / 重启 PGDATA 时。

它不是开源 PostgreSQL 普通从旧 PGDATA 原地 crash recovery 的路径。


2. 该分支处于什么启动逻辑中

这段代码所在位置大致是在选择 recovery 起点。逻辑结构可以简化成:

c 复制代码
if (backup_label exists)
{
    // 开源 PostgreSQL base backup / archive recovery 路径
}
else if (NeonRecoveryRequested)
{
    // Neon 从 Pageserver basebackup 启动路径
}
else
{
    // 普通 PostgreSQL 从 pg_control + pg_wal 启动路径
}

所以 NeonRecoveryRequested 分支表示:

text 复制代码
不走开源 PG 的 backup_label basebackup recovery;
也不走普通本地 pg_control + pg_wal crash recovery;
而是走 Neon 自己的 basebackup 启动路径。

3. 这个分支要解决的问题

核心问题是:

text 复制代码
Neon Compute 启动时没有完整的本地 WAL 历史。

开源 PostgreSQL 启动时,可以从本地 WAL 中知道:

text 复制代码
上一条 WAL record 从哪里开始;
上一条 WAL record 在哪里结束;
下一条 WAL record 应该从哪里开始写。

但是 Neon Compute 是从 Pageserver 拉了一个快照。PGDATA 中的 pg_wal 只是 Pageserver 生成的 dummy/bootstrap WAL segment,不是完整历史 WAL。

然而 PostgreSQL 写新 WAL record 时必须维护 WAL record 链:

text 复制代码
record_N.xl_prev = record_N-1 的起始 LSN

因此 Neon 必须给 PostgreSQL 两个信息:

text 复制代码
1. basebackup LSN:下一条 WAL 应该从哪里开始写;
2. prev_lsn:上一条 WAL record 的起始 LSN。

这个分支就是把这两个信息灌入 PostgreSQL 的 recovery 状态中。


4. 逐行解释

4.1 打印启动日志

c 复制代码
elog(LOG, "starting with neon basebackup at LSN %X/%X, prev %X/%X",
     LSN_FORMAT_ARGS(ControlFile->checkPointCopy.redo),
     LSN_FORMAT_ARGS(neonLastRec));

这里打印两个关键 LSN:

text 复制代码
ControlFile->checkPointCopy.redo = basebackup LSN
neonLastRec                      = prev_lsn

ControlFile->checkPointCopy.redo 来自 Pageserver 生成的 global/pg_control

Pageserver 在生成 pg_control 时,会把 checkpoint 的 redo 设置为这次 basebackup 的启动 LSN:

rust 复制代码
checkpoint.redo = normalize_lsn(lsn, WAL_SEGMENT_SIZE).0;

neonLastRec 来自 neon.signal 中的 PREV LSN


4.2 设置 CheckPointLoc

c 复制代码
CheckPointLoc = neonLastRec;

在开源 PostgreSQL 中,CheckPointLoc 通常表示 checkpoint record 的位置。

但在 Neon 这个分支中,它被设置为:

text 复制代码
上一条 WAL record 的起始 LSN。

也就是 prev_lsn

这样做是为了复用后续 PostgreSQL 的通用逻辑。后面 FinishWalRecovery() 会把 CheckPointLoc 作为 lastRec,最终在 StartupXLOG() 中初始化:

c 复制代码
Insert->PrevBytePos = XLogRecPtrToBytePos(endOfRecoveryInfo->lastRec);
Insert->CurrBytePos = XLogRecPtrToBytePos(EndOfLog);

所以这行本质是在告诉 PostgreSQL:

text 复制代码
上一条 WAL record 的起始位置是 neonLastRec。

后续第一条新 WAL record 的 xl_prev 就能正确指向它。


4.3 设置 timeline

c 复制代码
CheckPointTLI = ControlFile->checkPointCopy.ThisTimeLineID;

设置当前 checkpoint / recovery 相关的 timeline ID。

该 timeline 信息来自 Pageserver 生成的 pg_control.checkPointCopy


4.4 设置 RedoStartLSN

c 复制代码
RedoStartLSN = ControlFile->checkPointCopy.redo;

在开源 PostgreSQL 中,RedoStartLSN 通常表示:

text 复制代码
从这个 LSN 开始 redo WAL。

但在 Neon 这个分支中,它不是传统含义。

Neon 生成的 pg_control 中:

text 复制代码
checkpoint.redo = basebackup LSN

所以这里实际表达的是:

text 复制代码
RedoStartLSN = Compute 要启动到的 LSN
             = 下一条 WAL record 准备开始写的位置

而不是说真的要从这个 LSN 向后重放本地 WAL。


4.5 通知 walproposer

c 复制代码
SetRedoStartLsn(RedoStartLSN);

源码注释是:

c 复制代码
/* make basebackup LSN available for walproposer */

Neon Compute 写 WAL 时,需要通过 walproposer 和 Safekeepers 交互。

这里把 basebackup/start LSN 暴露给 walproposer,让 walproposer 知道:

text 复制代码
当前 Compute 从哪个 LSN 开始;
后续 WAL 要从哪里接上。

4.6 拷贝 checkpoint

c 复制代码
memcpy(&checkPoint, &ControlFile->checkPointCopy, sizeof(CheckPoint));

把 controlfile 中的 checkpoint copy 拷贝到本地变量 checkPoint

后续通用 recovery/startup 代码还会使用 checkPoint 中的字段,例如:

text 复制代码
nextXid
nextOid
nextMulti
oldestXid
ThisTimeLineID
redo
...

这些信息由 Pageserver 从历史 WAL/checkpoint 元数据中持久化,并在 basebackup 中生成出来。


5. 后面的 wasShutdown 为什么这样设置

紧接着这个分支后面有:

c 复制代码
if (StandbyModeRequested &&
    PrimaryConnInfo != NULL && *PrimaryConnInfo != '\0')
{
    wasShutdown = false;
}
else
    wasShutdown = true;

源码注释说:

c 复制代码
When a primary Neon compute node is started, we pretend that it
started after a clean shutdown and no recovery is needed.

也就是说,Neon primary 启动时会"假装":

text 复制代码
这是一次 clean shutdown 后的启动,不需要传统 WAL recovery。

原因是:

text 复制代码
数据一致性不依赖 Compute 本地 WAL redo;
Pageserver 已经能够按页提供目标 LSN 上的一致数据。

但是如果这是一个会跟随 primary 的 read-only replica:

text 复制代码
StandbyModeRequested && PrimaryConnInfo != NULL

则不能设置 wasShutdown = true

因为 wasShutdown = true 隐含语义是:

text 复制代码
启动 LSN 上没有 running transactions。

但 replica 从某个 LSN 启动时,该 LSN 可能有正在进行中的事务。Hot standby 的 known-assigned XIDs 机制需要正确处理这些 running transactions,所以这里必须:

c 复制代码
wasShutdown = false;

否则只读跟随节点可能错误地认为没有 in-progress transaction。


6. 这个分支不会做传统 WAL redo

开源 PostgreSQL 普通启动逻辑大概是:

text 复制代码
CheckPointLoc = ControlFile->checkPoint
RedoStartLSN  = ControlFile->checkPointCopy.redo
ReadCheckpointRecord()
PerformWalRecovery()
FinishWalRecovery()

也就是它会真的从本地 pg_wal 读 checkpoint record,并根据需要 redo WAL。

Neon 这个分支则是:

text 复制代码
CheckPointLoc = neonLastRec
RedoStartLSN  = basebackup LSN
checkPoint    = generated checkpoint
pretend clean shutdown

它的目的不是:

text 复制代码
从 RedoStartLSN 开始读 WAL 并 redo。

而是:

text 复制代码
绕过本地 WAL replay;
把 PostgreSQL 的内部状态设置成"已经恢复到 basebackup LSN";
并准备从 basebackup LSN 继续写 WAL。

后面在 FinishWalRecovery() 中还有 Neon 特殊逻辑:

c 复制代码
if (!NeonRecoveryRequested)
{
    XLogPrefetcherBeginRead(xlogprefetcher, lastRec);
    ReadRecord(...);
}

endOfLog = xlogreader->EndRecPtr;

如果是 Neon recovery:

text 复制代码
不会真的 ReadRecord(lastRec),因为本地没有完整 WAL 可以读。

然后又有:

c 复制代码
if (NeonRecoveryRequested)
{
    /*
     * When starting from a neon base backup, we don't have WAL.
     * Initialize the WAL page where we will start writing new records from scratch.
     */
}

它会手工初始化最后一个 WAL page 的 header,让后续可以从 endOfLog 继续写新 WAL。


7. checkpoint.redo 在这个分支中的真实含义

在这个 Neon 分支中:

c 复制代码
ControlFile->checkPointCopy.redo

不要按开源 PostgreSQL 习惯理解为"需要 redo 的起点"。

在 Neon basebackup 里,它更像是:

text 复制代码
basebackup_lsn
也就是 Compute 当前数据视图对应的 LSN
也是下一条 WAL record 准备写的位置

来源在 Pageserver:

rust 复制代码
checkpoint.redo = normalize_lsn(lsn, WAL_SEGMENT_SIZE).0;

所以近似可以理解为:

text 复制代码
ControlFile->checkPointCopy.redo == send_basebackup_tarball 里的 lsn / req_lsn

8. neonLastRec 是什么

neonLastRec 来自 neon.signal

text 复制代码
PREV LSN: <lsn>

它表示:

text 复制代码
basebackup_lsn 前一条 WAL record 的起始 LSN。

也就是 Pageserver basebackup.rs 中的 prev_record_lsn,或者 API 入参中的 prev_lsn

例如:

text 复制代码
neonLastRec                    = 0/16B6A50
ControlFile->checkPointCopy.redo = 0/16B6AF8

Neon 启动时会"假装":

text 复制代码
刚刚读完了一条 WAL record:
    start = 0/16B6A50
    end   = 0/16B6AF8

然后下一条 WAL record 从:

text 复制代码
0/16B6AF8

开始写,并且它的 xl_prev 会指向:

text 复制代码
0/16B6A50

9. 和开源 PostgreSQL 分支的区别

开源 PostgreSQL 普通分支会做:

c 复制代码
CheckPointLoc = ControlFile->checkPoint;
RedoStartLSN = ControlFile->checkPointCopy.redo;
record = ReadCheckpointRecord(xlogprefetcher, CheckPointLoc, CheckPointTLI);

也就是说它会真的去本地 WAL 中读取 checkpoint record。

Neon 分支不会这样做:

text 复制代码
不会去 pg_wal 中读取 CheckPointLoc = neonLastRec 的 record;
不会从 RedoStartLSN 做传统 redo;
而是直接使用 Pageserver 生成的 checkpoint/control 信息。

原因是:

text 复制代码
Neon Compute 本地 pg_wal 不是历史 WAL 的权威来源;
权威 WAL 在 Safekeepers;
数据页版本在 Pageserver。

10. 图示

10.1 Neon Compute 从 Pageserver basebackup 启动

假设 Pageserver 给 Compute 的 basebackup 位于 LSN B

text 复制代码
        previous WAL record
A --------------------------------> B
^                                  ^
|                                  |
prev_lsn / neonLastRec             basebackup_lsn / checkpoint.redo
上一条 WAL record 起点              下一条 WAL record 写入位置

Neon 启动时本地没有这条 WAL record 的完整内容,但它知道:

text 复制代码
A = neonLastRec
B = checkpoint.redo

于是该分支告诉 PostgreSQL:

text 复制代码
你可以假装刚刚读完了 A -> B 这条 WAL record。
现在从 B 继续写。

之后新 WAL 链条为:

text 复制代码
        old record                 new record
A -------------------------------> B -------------------------------> C
^                                  ^                                  ^
prev_lsn                           req_lsn/basebackup_lsn             new end
                                   new_record.xl_prev = A

注意:

text 复制代码
new_record.xl_prev 指向上一条 record 的起点 A,
而不是 B。

Neon 场景下,A -> B 这条 previous WAL record 不一定完整存在于 Compute 本地 pg_wal 中。Compute 只是通过 neon.signal 知道 A,通过生成的 pg_control 知道 B,然后伪造出"已经读完上一条 record"的状态。


10.2 开源 PostgreSQL 普通启动 / crash recovery

开源 PostgreSQL 中,本地 pg_wal 是完整且权威的 WAL 来源。启动时 PostgreSQL 会读取 global/pg_control,找到 checkpoint,然后根据需要从 checkpoint 的 redo 位点向后读取并重放 WAL。

简化图如下:

text 复制代码
pg_control
  |
  |  ControlFile->checkPoint
  v
checkpoint record at CKPT
  |
  |  checkpoint.redo
  v
R -----------------------> A -----------------------> B
^                          ^                          ^
|                          |                          |
redo start                 lastRec                    EndOfLog
开始 redo 的位置            最后一条完整 WAL record 起点  下一条 WAL record 写入位置

开源 PG 启动时会从本地 WAL 中真实读到这些信息:

text 复制代码
1. 从 pg_control 找到 checkpoint record;
2. 从 checkpoint record 得到 checkpoint.redo;
3. 如果需要 crash recovery,则从 redo start 开始 replay WAL;
4. replay / scan 到 WAL 末尾,得到:
   - lastRec  = 最后一条完整 WAL record 的起始 LSN;
   - EndOfLog = 最后一条完整 WAL record 的结束 LSN,也就是下一条 WAL 的写入位置。

然后初始化 WAL insert 状态:

text 复制代码
Insert->PrevBytePos = lastRec
Insert->CurrBytePos = EndOfLog

之后新 WAL record 为:

text 复制代码
        last existing WAL record        new WAL record
A --------------------------------> B -------------------------------> C
^                                   ^                                  ^
|                                   |                                  |
lastRec                             EndOfLog                           new end
                                    new_record.xl_prev = A

也就是说,开源 PG 的 lastRecEndOfLog 是通过本地 pg_wal 真实扫描 / replay 得到的。


10.3 两者对比

text 复制代码
开源 PostgreSQL:

本地 pg_control + 本地 pg_wal
        |
        v
读取 checkpoint -> 从 redo 位点 replay/scan WAL -> 得到 lastRec 和 EndOfLog
        |
        v
Insert->PrevBytePos = lastRec
Insert->CurrBytePos = EndOfLog


Neon Compute:

Pageserver 生成的 pg_control + neon.signal
        |
        v
checkpoint.redo = basebackup LSN = B
neonLastRec     = prev_lsn       = A
        |
        v
不从本地 pg_wal replay A -> B
而是假装已经读完 A -> B
        |
        v
Insert->PrevBytePos = A
Insert->CurrBytePos = B

核心区别:

text 复制代码
开源 PG:
    A 和 B 是通过本地 WAL 真实读取 / replay / scan 得到的。

Neon:
    A 由 neon.signal 提供;
    B 由 Pageserver 生成的 pg_control 提供;
    Compute 本地不需要有完整的 A -> B WAL 内容。

11. 总结

xlogrecovery.c 中的 NeonRecoveryRequested 分支在 Neon Compute 从 Pageserver basebackup 启动、PGDATA 中存在 neon.signal 时进入。

它的本质是:

text 复制代码
用 Pageserver 给出的 basebackup LSN 和 prev_lsn,
伪造/初始化 PostgreSQL recovery 结束状态,
让 PostgreSQL 认为自己已经位于一个可以继续写 WAL 的位置。

关键对应关系是:

text 复制代码
ControlFile->checkPointCopy.redo
    = basebackup LSN
    = 下一条 WAL record 起始位置
    = Insert->CurrBytePos 的来源

neonLastRec
    = prev_lsn
    = 上一条 WAL record 起始位置
    = Insert->PrevBytePos 的来源

后续通用代码会初始化:

c 复制代码
Insert->PrevBytePos = XLogRecPtrToBytePos(endOfRecoveryInfo->lastRec);
Insert->CurrBytePos = XLogRecPtrToBytePos(EndOfLog);

在 Neon 场景下大致就是:

text 复制代码
Insert->PrevBytePos = neonLastRec
Insert->CurrBytePos = ControlFile->checkPointCopy.redo

于是第一条新 WAL record 的 header 会包含:

text 复制代码
xl_prev = neonLastRec

从而把 Neon Compute 新生成的 WAL 接到已有 WAL 链上。

相关推荐
zxrhhm3 小时前
PostgreSQL 分页性能优化 FETCH WITH TIES 与传统 LIMIT/OFFSET 的对比
数据库·postgresql·性能优化
zxrhhm3 天前
PostgreSQL 中的层级查询 Oracle CONNECT BY 替代方案
数据库·postgresql·oracle
梦想画家3 天前
PostgreSQL 图计算双雄:Apache AGE 与 pgGraphBLAS 的融合实战指南
数据库·postgresql·图算法
晚风_END3 天前
Linux|操作系统|zfs文件系统的使用详解
linux·运维·服务器·数据库·postgresql·性能优化·宽度优先
梦想画家4 天前
PostgreSQL 物化视图实战:从数据固化到智能刷新的全链路指南
数据库·postgresql·物化视图
Database_Cool_4 天前
在 RDS PostgreSQL 中实现 RaBitQ 量化
数据库·阿里云·ai·postgresql
道法自然,人法天4 天前
PostgreSQL安装与初始化教程(二进制压缩包)
数据库·postgresql
yzs874 天前
从Hydra到storage_engine:PostgreSQL列存引擎的性能跃迁与技术进化
数据库·postgresql
张~颜4 天前
autovacuum
数据库·postgresql