Neon Pageserver 快照优先存储与 PITR 设计演进:从 Snapshot-first RFC 到 Image/Delta Layer 落地
2026/9/13 13:31:44 网站建设 项目流程

Neon Pageserver 快照优先存储与 PITR 设计演进:从 Snapshot-first RFC 到 Image/Delta Layer 落地

【免费下载链接】neonNeon: Serverless Postgres. We separated storage and compute to offer autoscaling, code-like database branching, and scale to zero.项目地址: https://gitcode.com/GitHub_Trending/ne/neon

导读

本文基于 Neon 仓库中《Snapshot-first storage》RFC(docs/rfcs/009-snapshot-first-storage-pitr.md),完整梳理了"按 LSN 重建任意历史页版本"这一核心问题的三种设计方案:以"快照文件 + 独立 WAL"为主体的 Snapshot-first 方案、当前layered_repo分支采用的"快照+WAL 合并层"实现、以及尚未实现的"快照与按关系 WAL 分离"的第三方案。读完本文,你将理解 GetPage@LSN 在只读副本、锚定副本与分支场景下的工作方式,PITR(时间点恢复)的页版本重建原理,以及该设计最终如何在今天的 Pageserver 中落地为 LSM 存储中的 Image Layer 与 Delta Layer(参见 docs/pageserver-compaction.md、pageserver/src/tenant/storage_layer/image_layer.rs)。


一、问题背景:GetPage@LSN 与历史页版本重建

Neon 将计算与存储分离:计算节点(PostgreSQL)通过 GetPage@LSN 协议向 Pageserver 请求指定 LSN 时刻的页面内容。RFC 开篇(Preface)明确指出一个基本事实:

GetPage@LSN 可以用更旧的 LSN 来调用,Pageserver 必须能够重建更早的页版本。

这个能力在以下三类场景中必不可少:

  • 滞后的只读副本(lagging replicas):只读副本落后于主库,需要读取较旧 LSN 的页面;
  • 锚定在旧 LSN 的副本(anchored replicas):副本被固定在某个历史 LSN 上持续对外提供一致读;
  • Pageserver 内部的时间点分支:在更早的时间点创建分支时,需要构造该时刻的数据库镜像。

RFC 作者在该文档中刻意排除了**增量快照(incremental snapshots)**的考虑,认为它不会改变问题的本质——即每个快照/快照文件都包含全部页面的完整镜像,读取时无需再依赖更早的快照文件。

另一个关键设计前提是按关系(per-relation)组织快照:每个快照文件只包含一个"关系"的数据。这里的"关系"是一个模糊概念:

  • 可以是 1 GB 的关系段(relation segment);
  • 可以包含关系的所有 fork(如 main、fsm、vm 等),也可以把每个 fork 当作独立存储对象;
  • 待"非关系对象"(non-relational)工作完成后,"关系"还可能指 PostgreSQL 数据目录中的其他版本化对象。

这一"每文件一个关系/键范围"的粒度设计,直接奠定了今天 Pageserver 中 Layer 文件按Key Range划分的基本形态。


二、方案一:Eric 的 Snapshot-first 存储(RFC 主体)

2.1 快照与 WAL 的分工

RFC 描述的快照机制如下:每隔一段时间创建一个"快照",即为自上次快照以来被修改过的每个关系生成一个新的快照文件,写入该关系在快照 LSN 时刻的完整内容。WAL 则由 WAL safekeeping 服务(safekeeper)以PostgreSQL 原始 WAL 文件格式单独存放在 S3 中。

存储布局的抽象示意(LSN 自下而上增长):

SNAPSHOT @100 WAL . | . | . | . | SNAPSHOT @200 | . | . | . | . | SNAPSHOT @300 | . | . V IN-MEMORY @400
  • 内存层(IN-MEMORY)保存 LSN 300 之后的最新修改;
  • 磁盘上依次存在 LSN 100、200、300 的快照文件;
  • WAL 单独存储,覆盖从 100 至今的完整区间。

2.2 主库读路径:内存层优先

当主库发来 GetPage@LSN 请求时:

  • 若页面在内存层有踪迹,直接返回最新版本;
  • 若内存中完全没有该页面的记录,说明该页面自最近一次快照(上图中 LSN 300)以来未被修改,因此直接返回最近快照中的页面镜像即可,无需扫描任何 WAL。

这一"无修改即免重放"的判定,是快照优先存储降低读延迟的关键。

2.3 PITR 读路径:快照 + WAL 重放

PITR 基于原始 WAL 文件实现。假设来自只读副本的请求为 LSN 250:

  1. 从 LSN 200 的快照文件读取该页面的镜像;
  2. 扫描 200 到 250 之间的 WAL;
  3. 把其中与该页面相关的 WAL 记录全部重放到镜像上,得到 LSN 250 时刻的页版本。

RFC 作者同时指出了朴素实现的性能瓶颈:每次 GetPage@LSN 都从头扫描一遍 WAL 区间代价高昂。实际工程中应对的做法是:在服务端为 200~250 区间一次性构建一个内存数据结构(例如按页面索引的 WAL 记录表),之后对该区间内任意页面的请求都能快速定位所需记录。

这一思想在后来的实现中演进为 Pageserver 的 Layer 内 B-tree 索引:每个 Delta Layer 文件内部都带有一个从(Key, LSN)到数据偏移的 B-tree 索引(详见下文第五部分)。

2.4 RFC 提出的问题与疑问

RFC 在正文中坦诚列出了该方案尚待解决的疑问:

问题 1:快照 LSN 列表存储在哪里?每个时间线(timeline)上需要维护一份已发生快照的 LSN 列表,否则无法判断"某 LSN 处是否发生过快照"。

问题 2:如何避免全区间 WAL 扫描?假设某关系最近一次快照在 LSN 100,现在请求 LSN 1000000 处的页面。如果不加优化,必须扫描 100~1000000 的全部 WAL 才能确认其间是否有对该页面的修改——这显然不可接受。

优化思路:如果知道系统在 LSN 999900 处发生过一次快照(哪怕不是针对这个关系),那么"该关系没有 999900 的快照文件"本身就证明该关系在 100~999900 之间没有被修改过,于是只需扫描 999900~1000000 的 WAL。

问题 3:快照事件的信息从哪里获得?既然该关系的快照文件中没有 999900 的踪迹,就必须从别处获取"发生过快照"的信息。RFC 给出了两个候选:

  • 扫描所有关系:对全部关系取快照 LSN 的并集,得到全局快照 LSN 集合。若在 LSN 999900 看到任意关系的快照文件,则可知若本关系有修改,也必然会有更新的快照文件。缺点:扫描昂贵(至少应在首次计算后缓存在内存中),且依赖"所有文件按同一间隔快照"这一隐含约束,无法支持不同文件采用不同快照间隔,限制较大;
  • 显式元数据文件:单独维护一个记录全部快照 LSN 的元数据文件。

后一种思路在后来的实现中演变为 timeline 的metadata 文件(参见 pageserver/src/tenant/metadata.rs 与 timeline 结构),用于记录各层信息、GC 水位等时间线级元数据。


三、方案二:layered_repo分支的当前实现(SNAPSHOT+WAL 层)

RFC 的后半部分描述了layered_repo分支中已实现的变体:快照与 WAL 合并在同一层文件中,这样重建页版本时无需单独从 S3 拉取 WAL。

SNAPSHOT+WAL 100-200 | | | | SNAPSHOT+WAL 200-300 | | | | IN-MEMORY 300-

每个"SNAPSHOT+WAL"文件包含两部分内容:

  1. 起始 LSN 处关系的完整页镜像(snapshot);
  2. 从起始 LSN 到结束 LSN 之间、适用于该关系的全部 WAL 记录

由此,该文件覆盖的 LSN 区间内任意页版本都能重建——这正是当前 Pageserver Delta Layer 的语义雏形。

RFC 还透露了一个实现细节:文件实际上以序列化的 BTreeMap存储,页镜像与 WAL 记录作为条目放在同一个 B-tree 中。今天的 pageserver/src/tenant/disk_btree.rs 磁盘 B-tree 正是这一思路的延续。

3.1 该实现的性能短板:快照永远滞后一个周期

RFC 以一个单一关系的例子剖析了该实现的问题:

  1. 从空关系开始,收到 LSN 100~200 的 WAL(一批 insert/update),全部驻留内存;
  2. 决定物化到磁盘:写入该关系在LSN 100的完整镜像 + 100~200 的全部 WAL。由于关系初始为空,区间起始处的"镜像"也是空的;
  3. 继续接收 WAL 至 LSN 300,再次物化,得到两个文件:
SNAPSHOT+WAL 100-200 SNAPSHOT+WAL 200-300

注意:磁盘上存储的"最新全量快照"总是落后一个快照周期——第一个文件存的是 LSN 100 的镜像,第二个存的是 LSN 200 的镜像;当我们已经收到 LSN 300 的 WAL 时,写下的却是 LSN 200 的镜像。RFC 明确指出"这看起来有点傻":按照方案一(Eric 的 RFC)的设计,本应在 LSN 200 和 300 各写一次快照,即新镜像正好赶上当前进度。

这个"落后一拍"的问题,正是引入第三方案、最终催生现代 compaction 中"image layer 物化到最新 LSN"机制的直接动因。


四、方案三:快照与按关系 WAL 分离(未实现,两全其美)

第三方案将快照文件与 WAL 文件分离存储,但 WAL 按关系粒度组织成独立的 LSN 区间文件:

SNAPSHOT @100 WAL 100-200 . | . | . | . | SNAPSHOT @200 WAL 200-300 . | . | . | . | SNAPSHOT @300 . . IN-MEMORY 300-

RFC 认为这可能是"best of both worlds":

  • 快照文件独立于 PostgreSQL WAL 格式:快照的格式演进不受 WAL 格式约束;
  • 不再落后一个快照周期:写 LSN 300 的快照时,直接写该关系在 LSN 300 的完整镜像,并把 200~300 累积的 WAL 写入单独文件;
  • WAL 就近可得:每个关系的 WAL 就放在其快照文件旁边,重建时无需去 S3 单独拉取;
  • 无需单独追踪快照 LSN:快照的 LSN 信息由文件自身携带。

RFC 还讨论了一个折中细节:若想减少文件数量,可以把 LSN 300 的快照与 200~300 的 WAL 放进同一个文件,但作者倾向保持分离,以便独立演进与 GC。


五、进一步思考:快照 LSN 与 WAL 范围不必对齐

RFC 的"Further thoughts"部分指出:快照文件的 LSN 与 WAL 文件的区间没有理由必须对齐,例如:

SNAPSHOT @100 WAL 100-150 . | . | . WAL 150-250 . | SNAPSHOT @200 | . | . WAL 250-400 . | . | SNAPSHOT @300 | . | . | IN-MEMORY 300-

这里的 WAL 区间(100-150、150-250、250-400)与快照 LSN(100、200、300)完全错位。作者坦诚"不确定这样做的收益是什么",但给出了一个可能的应用场景:在某个 WAL 文件覆盖区间的中间额外物化一个快照文件——例如在一个 LSN 范围中间创建分支时,或在预见到某个 LSN 将成为热点(大量请求集中在该 LSN)时,额外生成快照可显著加速该点位的读取。

这个"按需在热点 LSN 物化镜像"的想法,在今天对应着 Pageserver compaction 中"是否值得为某键范围创建新 image layer"的启发式决策(见 pageserver/src/tenant/timeline/compaction.rs 中关于 image layer 创建时机的逻辑)。


六、落地方向:从 RFC 到现代 LSM 层存储

RFC 文档写于存储架构早期,其提出的"快照/镜像 + WAL/增量"二元结构,在后续设计中发展成了今天 Pageserver 的 LSM 层存储(docs/rfcs/014-storage-lsm.md)。RFC 009 中的三种方案与现行架构存在清晰的一一对应关系:

RFC 009 概念现代 Pageserver 对应物
快照文件(某 LSN 的完整页镜像)Image Layer:某键范围在单一 LSN 的完整镜像
快照+WAL 层文件 / 按关系 WAL 文件Delta Layer:某键范围在某 LSN 区间内的增量(WAL 记录/页镜像)
内存层 IN-MEMORYMemtable / Ephemeral Layer
快照 LSN 元数据列表timeline metadata 文件

6.1 Image Layer:RFC"快照"的直接化身

pageserver/src/tenant/storage_layer/image_layer.rs 的模块注释明确写道:

An ImageLayer represents an image or a snapshot of a key-range at one particular LSN. It contains an image of all key-value pairs in its key-range. Any key that falls into the image layer's range but does not exist in the layer, does not exist.

这恰是 RFC 中"每个快照文件包含该关系在快照 LSN 时刻的完整内容;文件之外即不存在"语义的键空间化实现。Image layer 文件命名格式为:

<key start>-<key end>__<LSN>

例如000000067F000032BE0000400000000070B6-000000067F000032BE0000400000000080B6__00000000346BC568(键范围 + 单一快照 LSN)。

6.2 Delta Layer:SNAPSHOT+WAL 层文件的继承

pageserver/src/tenant/storage_layer/delta_layer.rs 的注释同样与 RFC 一脉相承:

A DeltaLayer represents a collection of WAL records or page images in a range of LSNs, and in a range of Keys.

并特别指出:通常 Delta Layer 只包含相对于某个基准 LSN 的差异(即 WAL 记录);但如果关系扩展或新关系创建,新页面没有旧版本可作基准,就必须以完整页镜像或带will_init标志的 WAL 记录存储,使其无需引用更旧页版本即可重放——这正是 RFC 中"从空关系开始物化、镜像为空"场景的工程化处理。

Delta layer 文件命名格式为:

<key start>-<key end>__<start LSN>-<end LSN>

对应 RFC 中"SNAPSHOT+WAL 100-200"的 LSN 区间表示。两类文件都采用三段式结构:summary(固定大小文件头)+ values(实际页镜像与 WAL 记录)+ index(从 Key/LSN 到 values 偏移的 B-tree),B-tree 索引正是 RFC 所设想"为 WAL 区间构建内存索引以加速按页查找"的持久化形态。

6.3 层堆叠与重建:RFC 读路径的现代实现

现代读取路径的层级搜索遵循与 RFC 完全一致的逻辑:从最新层向下搜索,遇到 Image Layer 即可停止,因为镜像层之下不可能再有该键的新版本(pageserver/src/tenant/storage_layer.rs 中注释明确:"On hitting image layer, we can mark all keys in this range as done, because if the image layer does not contain a key, it is deleted/never added")。

  • 主库读取(读最新 LSN):命中内存层/最新 delta,无需任何重放;
  • PITR 读取(读旧 LSN):落到某 Image Layer 镜像,再向上重放该 LSN 到请求 LSN 之间的 Delta Layer 记录——等价于 RFC 中"快照@200 + 重放 WAL 200~250"的步骤。

Pageserver 的 gRPC GetPage 入口在 pageserver/src/page_service.rs 的get_page中实现,其中effective_request_lsn会结合 GC cutoff 等约束校准请求 LSN,随后批量调用handle_get_page_at_lsn_request_batched完成逐页重建。

6.4 Compaction:镜像层是如何生成的

RFC 第三方案提出的"在 WAL 区间的中间额外物化快照"、"让镜像追上最新 LSN",正是今天 compaction 的核心工作。docs/pageserver-compaction.md 给出了完整机制:

  • Pageserver 以每租户分片一个compaction_loop后台任务运行,默认每compaction_period(默认 20 秒)唤醒检查;
  • 新 WAL 先进入ephemeral layer(临时层),超过checkpoint_distance(默认 256 MB,见 docs/settings.md)后排序刷出为 L0 层文件;
  • L0→L1 compaction:取底部compaction_threshold(默认 10)到compaction_upper_limit(默认 20)个 L0 层,归并排序写出大小为compaction_target_size(默认 128 MB)的 L1 delta 层;
  • L1 image compaction:对 L1 键空间中与镜像层重叠的 delta 层达到image_creation_threshold(默认 3)的区段,通过向量化读取(vectored reads)物化页镜像,生成新的 Image Layer——这一步直接对应 RFC 中"写快照 @300 时写出该关系在 LSN 300 的完整镜像"的设想。

镜像层带来的收益与 RFC 的预期完全一致:限制一次搜索需要检查的层数,从而给读延迟设上限,并允许对早于 GC 水位的层进行垃圾回收(GC 相关的gc_horizon等参数同样见 docs/settings.md)。层文件本身不可变、只增删不修改的特性,也让其天然适配 S3 对象存储(参见 docs/rfcs/014-storage-lsm.md 对 LSM 设计动机的阐述)。


七、小结:一条清晰的架构演化脉络

从这份 RFC 可以完整看到 Neon 存储引擎的一条核心设计脉络:

  1. 问题:GetPage@LSN 必须重建任意历史页版本,以支撑只读副本、锚定副本与时间点分支;
  2. 方案一(快照优先):按关系存"完整镜像快照",WAL 独立存放,PITR = 快照镜像 + 区间 WAL 重放;代价是需要额外追踪快照 LSN 列表;
  3. 方案二(当前实现):快照与 WAL 合并为层文件,省去单独拉取 WAL,但镜像总是落后一个周期;
  4. 方案三(未实现):快照与按关系 WAL 分离,镜像追平进度、格式解耦,被视为"两全其美";
  5. 落地:最终在 LSM 存储中以 Image Layer / Delta Layer 的形态实现,compaction 机制负责在合适时机物化镜像、控制读放大并支撑 GC。

对想要深入源码的读者,建议按以下路径继续研读:

  • RFC 原文:docs/rfcs/009-snapshot-first-storage-pitr.md
  • LSM 存储设计:docs/rfcs/014-storage-lsm.md
  • Compaction 机制详解:docs/pageserver-compaction.md
  • Image Layer 实现:pageserver/src/tenant/storage_layer/image_layer.rs
  • Delta Layer 实现:pageserver/src/tenant/storage_layer/delta_layer.rs
  • 层管理与读取: pageserver/src/tenant/storage_layer.rs、pageserver/src/tenant/timeline.rs
  • Compaction 与镜像层创建:pageserver/src/tenant/timeline/compaction.rs
  • 配置项:docs/settings.md

【免费下载链接】neonNeon: Serverless Postgres. We separated storage and compute to offer autoscaling, code-like database branching, and scale to zero.项目地址: https://gitcode.com/GitHub_Trending/ne/neon

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询