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:
- 从 LSN 200 的快照文件读取该页面的镜像;
- 扫描 200 到 250 之间的 WAL;
- 把其中与该页面相关的 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"文件包含两部分内容:
- 起始 LSN 处关系的完整页镜像(snapshot);
- 从起始 LSN 到结束 LSN 之间、适用于该关系的全部 WAL 记录。
由此,该文件覆盖的 LSN 区间内任意页版本都能重建——这正是当前 Pageserver Delta Layer 的语义雏形。
RFC 还透露了一个实现细节:文件实际上以序列化的 BTreeMap存储,页镜像与 WAL 记录作为条目放在同一个 B-tree 中。今天的 pageserver/src/tenant/disk_btree.rs 磁盘 B-tree 正是这一思路的延续。
3.1 该实现的性能短板:快照永远滞后一个周期
RFC 以一个单一关系的例子剖析了该实现的问题:
- 从空关系开始,收到 LSN 100~200 的 WAL(一批 insert/update),全部驻留内存;
- 决定物化到磁盘:写入该关系在LSN 100的完整镜像 + 100~200 的全部 WAL。由于关系初始为空,区间起始处的"镜像"也是空的;
- 继续接收 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-MEMORY | Memtable / 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 存储引擎的一条核心设计脉络:
- 问题:GetPage@LSN 必须重建任意历史页版本,以支撑只读副本、锚定副本与时间点分支;
- 方案一(快照优先):按关系存"完整镜像快照",WAL 独立存放,PITR = 快照镜像 + 区间 WAL 重放;代价是需要额外追踪快照 LSN 列表;
- 方案二(当前实现):快照与 WAL 合并为层文件,省去单独拉取 WAL,但镜像总是落后一个周期;
- 方案三(未实现):快照与按关系 WAL 分离,镜像追平进度、格式解耦,被视为"两全其美";
- 落地:最终在 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),仅供参考