Neon Page Server 架构深度解析:GetPage@LSN、WAL 摄取与分层存储的实现原理
【免费下载链接】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 仓库 docs/pageserver.md 及其姊妹文档为主线,深入剖析 Page Server(页面服务器)在 Neon 计算-存储分离架构中的核心职责与实现机制:如何响应 Compute Node 的 GetPage@LSN 请求、如何从 Safekeeper 摄取并重放 WAL、如何将数据组织成不可变的 L0/L1 层文件并上传至 S3,以及分支(Branching)与 PITR 时间点恢复如何在存储层落地。读完本文,你将掌握 Neon 存储引擎的完整数据流、层文件的生命周期与命名规则、垃圾回收(GC)的取舍逻辑,以及 WAL redo 进程的多租户安全设计,并可直接对照仓库源码进一步研究。
Page Server 在 Neon 架构中的定位
Neon 将传统 PostgreSQL 拆分为计算(Compute)与存储(Storage)两部分:Compute Node 运行完整 PostgreSQL 实例,而 Page Server 负责提供数据页。根据 docs/pageserver.md,Page Server 承担三项核心职责:
- 响应 Compute Node 的 GetPage@LSN 请求:按指定 LSN(Log Sequence Number,WAL 中的字节位置)提供页面内容;
- 从 WAL Safekeeper 接收并存储 WAL:持续摄取 WAL 记录,为重建任意历史页版本提供素材;
- 上传数据到 S3 以持久化,并按需从 S3 下载:把本地层文件归档到云端。
一个关键的设计事实是:Neon 没有 Page Server 副本,S3(以及其他兼容对象存储)是所有数据的唯一容错主存储。出于降低延迟的考虑,Neon 使用一套独立的容错 WAL 服务(即 Safekeeper)来临时保管尚未同步到 S3 的 WAL 记录——写入路径可以先把 WAL 交给 Safekeeper 完成持久化,而无需等待 S3 上传完成,这正是 Neon 能在不牺牲持久性的前提下降低写延迟的核心手段。
从源码看,这套职责落在 pageserver/src/lib.rs 组织的多个独立服务上,页面请求、WAL 摄取、后台整理分别由不同线程/任务驱动,下文逐一展开。
服务构成:多线程协作的共享仓库
docs/pageserver-services.md 给出了 Page Server 的线程/服务全景图:多个线程围绕一个"页面版本共享仓库"协作运行。整理后的服务划分如下:
- Page Service(页面服务):监听来自 Compute Node 的 GetPage@LSN 请求,从仓库取出页面并返回;
- WAL Receiver(WAL 接收器):通过 PostgreSQL 物理流复制协议连接外部 WAL Safekeeper,持续接收 WAL,解码记录并写入仓库;
- Backup service(备份服务):负责把 Page Server 的恢复数据外部化存储(即上传远程对象存储);
- Repository 后台任务:包括 checkpointing(将内存中累积的 WAL 落盘)、compaction(合并整理文件以降低读放大)和 garbage collection(回收过期文件);
- HTTP 管理 API:对外提供租户/时间线的管理接口;
- WAL redo 进程:用于把 WAL 重放为具体页面版本(详见下文"WAL redo 与多租户安全"一节)。
对应实现可分别查看 pageserver/src/page_service.rs、pageserver/src/walingest.rs、pageserver/src/tenant/tasks.rs。
Page Service:libpq 协议上的页面服务
docs/pageserver-page-service.md 说明:Page Service 监听 GetPage@LSN 请求,并为每个进入的连接派生一个独立线程,使用 libpq 协议与客户端(Compute 的 PostgreSQL 实例)通信,每个请求最终调用仓库的页面获取函数。
在源码中,请求处理入口位于 pageserver/src/page_service.rs 的handle_get_page_at_lsn_request_batched(约 L2454),它批量处理页面请求并交由 Timeline 的读取路径完成页版本重构;收到请求后,Page Server 需要"重建"出该页在指定 LSN 时刻的内容——这正是存储层层文件设计要解决的核心问题。
WAL Receiver:物理流复制接入 WAL
WAL Receiver 以 PostgreSQL 物理流复制的方式连接 Safekeeper 并连续接收 WAL,解码后将记录写入仓库。摄取逻辑在 pageserver/src/walingest.rs 中实现,其ingest_record(约 L234)及一系列按记录类型分派的ingest_xlog_*/ingest_*函数(如ingest_xact_record、ingest_xlog_smgr_create、ingest_xlog_smgr_truncate、ingest_relmap_update等)负责把不同类型的 WAL 记录映射为对键值空间的写入。摄入过程中会维护disk_consistent_lsn水位线(见 pageserver/src/tenant/timeline.rs 中的disk_consistent_lsn: AtomicLsn字段),表示已完整落盘到本地并 fsync 的 WAL 位置,是崩溃恢复与层文件状态判断的重要依据。
Backup Service:把数据托付给远程对象存储
因为 Page Server 无副本、本地工作目录可能相当"短命"(例如运行在 k8s 中且未挂载持久卷的 pod),所以它必须与更可靠的外部存储交互,以备份和恢复自身状态。Backup service 默认禁用,启用后与单个远程存储交互。存储代码被设计为可扩展的:只要实现相应的 Rust trait 即可接入任意存储后端。
从当前仓库源码看,RemoteStorageKind枚举(定义于 libs/remote_storage/src/config.rs L80-L93)已有四类实现:
| 后端 | 说明 |
|---|---|
LocalFs { local_path } | 本地文件系统,主要用于测试 |
AwsS3(S3Config) | AWS S3,用于生产 |
AzureContainer(AzureConfig) | Azure Blob 容器 |
GCS(GCSConfig) | Google Cloud Storage |
命令行启用示例(沿用文档中的写法,${PAGESERVER_BIN}为 pageserver 可执行文件):
# 本地文件系统 ${PAGESERVER_BIN} -c "remote_storage={local_path='/some/local/path/'}" # AWS S3(凭据可通过环境变量注入) env AWS_ACCESS_KEY_ID='SOMEKEYAAAAASADSAH*#' \ AWS_SECRET_ACCESS_KEY='SOMEsEcReTsd292v' \ ${PAGESERVER_BIN} -c "remote_storage={bucket_name='some-sample-bucket',bucket_region='eu-north-1',prefix_in_bucket='/test_prefix/'}"使用 AWS 时,如果本机 awscli 已配置过目标 bucket,则密钥也可以放在~/.aws/credentials(在 AWS 控制台用户设置页生成);注意 bucket 名称中不带任何协议前缀。对于本地 S3 实现(如 MinIO),名称格式与凭据请参考其自身文档。
与 pageserver 的其他设置一样,也可以在 TOML 配置文件中配置远程存储,所需字段如下:
# 本地文件系统 [remote_storage] local_path = '/Users/someonetoignore/Downloads/tmp_dir/'# AWS S3 [remote_storage] bucket_name = 'some-sample-bucket' bucket_region = 'eu-north-1' prefix_in_bucket = '/test_prefix/'S3 凭据可通过AWS_SECRET_ACCESS_KEY与AWS_ACCESS_KEY_ID环境变量提供;另外S3Config还支持endpoint(用于非 AWS 的 S3 兼容存储,例如http://127.0.0.1:5000)和concurrency_limit等字段(见 libs/remote_storage/src/config.rs L119-L144)。
# Azure Blob [remote_storage] container_name = 'some-container-name' storage_account = 'somestorageaccnt' container_region = 'us-east' prefix_in_container = '/test-prefix/'Azure 凭据可通过AZURE_STORAGE_ACCESS_KEY环境变量指定。
存储格式:从 WAL 到不可变的层文件
核心思路:替代传统基备份与 WAL 归档
docs/pageserver-storage.md 阐明了 Page Server 最主要的职责:处理进来的 WAL,并将其重排为一种能快速访问任意页版本的格式。Page Server 按"关系+页"对进来的 WAL 进行切片,再把切片后的 WAL 打包成大小合适的层文件(layer files)。层文件保存了数据库回溯到某个合理保留期(retention)之前的全部历史,取代了传统 PostgreSQL 中的基础备份(base backup)与 WAL 归档(WAL archive)。层文件是不可变的:创建后不再原地修改,新 WAL 到来时创建新层文件,不再需要的旧层文件则被删除。
数据流动的整体画面如下(原文档 ASCII 图,Safekeeper 在右,云端存储在左):
Cloud Storage Page Server Safekeeper L1 L0 Memory WAL +----+ +----+----+ |AAAA| |AAAA|AAAA| +---+-----+ | +----+ +----+----+ | | | |AA |BBBB| |BBBB|BBBB| |BB | AA | |BB +----+----+ +----+----+ |C | BB | |CC |CCCC|CCCC| <---- |CCCC|CCCC| <--- |D | CC | <--- |DDD <---- ADEBAABED +----+----+ +----+----+ | | DDD | |E |DDDD|DDDD| |DDDD|DDDD| |E | | | +----+----+ +----+----+ | | | |EEEE| |EEEE|EEEE| +---+-----+ +----+ +----+----+WAL 从右侧以流形式从 Safekeeper 到达,立即被 Page Server 捕获并快速存进内存。内存可以理解为一个快速的"重排缓冲区(reorder buffer)":把进来的 WAL 暂存并重排,使属于同一页、同一关系的 WAL 记录彼此靠近。
- 当内存中累积的 WAL 足够多时,被 flush 为一个新的L0 层文件,内存随之释放;
- 当 L0 文件积累到一定数量后,它们被合并并按 key 空间重新切片,产生一组新的文件:每个文件覆盖更窄的 key 区间、但更大的 LSN 区间(即L1 层文件);
- 层文件再从本地盘复制到云存储做长期归档;复制完成后理论上可以从本地删除,但当前实现仍把一切保存在本地以换取快速访问;若需要某层而本地没有,则从云存储取回。L0 与 L1 文件都会被上传到云存储。
这一"先攒内存、落 L0、合并成 L1、再上云"的流水线,本质上是一个两级的 LSM 树变体(详见 docs/pageserver-compaction.md)。
Layer map:时间线内层文件的索引
LayerMap跟踪一条时间线(timeline)中现存的所有层。其设计与实现位于 pageserver/src/tenant/layer_map.rs:
- 时间线首次被访问时,服务端会列出
timelines/<timeline_id>目录下的所有层文件,为每个文件构建ImageLayer或DeltaLayer结构并填入映射表; - 收到第一条新 WAL 时创建
InMemoryLayer承接后续记录;此后在checkpoint()过程中冻结内存层并拆分为新的 image/delta 层文件落盘。
文档记载的早期实现是一个可伸缩数组(Vec),读取时线性扫描以定位包含目标页面的层;而当前源码已演进为**基于持久化不可变二叉搜索树(persistent BST)**的结构:按 LSN 起始序从旧到新逐层插入,每次插入后保存该版本树的一个引用,存入按 LSN 键控的 BTreeMap;查询某个 (key, LSN) 时先在 BTreeMap 找到对应的树版本,再以 key 在树中检索。LayeredTimeline的读取代码还感知祖先(ancestor):当前时间线找不到数据时会回到祖先时间线去取(这正是分支的基础)。
层的不同状态
docs/pageserver-storage.md 列出层的几种状态:
- Open(打开):还可以追加新 WAL 记录;
- Closed(关闭):只读,不能再追加("Historic" 是它的同义词);
- InMemory(内存中):需要在 pageserver 启动时从 WAL 重建;为避免 OOM,可把 InMemory 层溢出(spill)到磁盘上的临时文件(ephemeral file,见 pageserver/src/tenant/storage_layer/inmemory_layer.rs 中基于
EphemeralFile的实现); - OnDisk(磁盘上):存储在磁盘;若其 end-LSN 早于
disk_consistent_lsn,则确认已完整 flush 且 fsync 到本地盘; - Frozen(冻结):已关闭的 InMemory 层。
OnDisk 层分两类:
- ImageLayer(镜像层):某一特定 LSN 下、某 key 区间内所有键的快照;凡不在镜像层中的键,即可认定在该 LSN 处不存在;
- DeltaLayer(增量层):某 key 区间在某个 LSN 区间内的 WAL 记录或页镜像集合。
对应的 Rust 类型(ImageLayer、DeltaLayer、InMemoryLayer、DeltaLayerName、ImageLayerName等)统一从 pageserver/src/tenant/storage_layer.rs 导出。
层生命周期:冻结与落盘两步走
LSN 区间的边界约定:
start_lsn包含;end_lsn不包含。
对于打开的内存层,end_lsn为MAX_LSN;对于冻结的内存层或 delta 层,它是有效的上界;镜像层表示单个 LSN 处的快照,因此其end_lsn恒等于快照 LSN + 1。
每层都以"打开的 InMemory 层"开始:Page Server 收到某时间线的第一条 WAL 记录时,为它创建 InMemory 层并放入 layer map;当该层写满后,其内容落盘为 OnDisk 层。落盘是两步过程:
- 冻结(freezing):先把层标记为 closed,使其不再接受新 WAL,同时创建新的 InMemory 层承接此后的 WAL。此时该层处于 Closed InMemory 状态;
- 生成新层:创建包含冻结层全部数据的 Delta 层,写入并 flush 到磁盘后,用新层替换 layer map 中的原冻结层,并丢弃原冻结层以释放内存。
层文件命名:key 区间 × LSN 区间
层文件存储在各时间线的子目录下:.neon/tenants/<tenant_id>/timelines/<timeline_id>/。每个层文件覆盖一个 key 区间和一个 LSN 区间(镜像层为单一 LSN),可以想象成二维"key-LSN 空间"里的一个矩形。文件名由三部分组成:起始 key、结束 key、以及 LSN 信息。
镜像文件(image file)示例:
000000067F000032BE0000400000000070B6-000000067F000032BE0000400000000080B6__00000000346BC568 start key end key LSN增量文件(delta file)命名类似,但覆盖一个 LSN 区间:
000000067F000032BE0000400000000020B6-000000067F000032BE0000400000000030B6__000000578C6B29-0000000057A50051 start key end key start LSN end LSNdelta 文件只包含在其 LSN 区间内被更新过的那些键值;未被修改的键在其中不留任何痕迹。关于 key 空间如何划分(relation、fork、segment 到 key 的映射),可参见 pageserver/src/pgdatadir_mapping.rs 中的映射逻辑。
L0 与 L1 的判别:delta 层文件可以只覆盖部分 key 空间,也可以覆盖整个 key 区间(起始 key 全 0、结束 key 全 F):
000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__000000578C6B29-0000000057A50051覆盖整个 key 区间的文件称为L0 文件(Level 0),只覆盖部分 key 区间的称为L1 文件(Level 1)。"级别"并不显式存储在文件里,只能通过观察 key 区间来区分;读取路径无需对 L0/L1 做任何区别对待。
文档补充说明:早期层文件按单个关系切分,如今已改为按 key 区间覆盖;下文"创建层文件"一节沿用文档中简化的可读写法(用表名代替 key 区间、省略 tenant/timeline id、缩写 LSN),但描述的页版本查找、分支与 GC 原理仍然有效。例如完整路径
.neon/tenants/941ddc8604413b88b3d208bddf90396c/timelines/4af489b06af8eed9e27a841775616962/rel_1663_13990_2609_0_10_000000000169C348_0000000001702000可简写为main/orders_100_200(主分支上 orders 表、LSN 100-200 的 delta 文件),base image 文件则写作main/orders_100。
Checkpointing:如何创建层文件
一个逐步演进的例子
假设系统里只有一条分支main,两张表orders与customers,WAL 尾部当前在 LSN 250。初始时磁盘上有:
main/orders_100 main/orders_100_200 main/orders_200 main/customers_100 main/customers_100_200 main/customers_200LSN 200 到 250 之间的最新变更仍保存在内存中——如果此时 Page Server 崩溃,这段 200-250 的 WAL 需要重新从 Safekeeper 读取。
每当内存中累积了足够多的 WAL,Page Server 就把内存中的变更写成新的层文件,这个过程称为checkpointing(注意:与 PostgreSQL 的 checkpoint 概念不同,只是名字相似)。Page Server 只为"自上次 checkpoint 以来被修改过"的关系创建层文件。例如当前 WAL 尾部在 LSN 450、上次 checkpoint 在 LSN 400,而customers表最近没有变化,则磁盘上是:
main/orders_100 main/orders_100_200 main/orders_200 main/orders_200_300 main/orders_300 main/orders_300_400 main/orders_400 main/customers_100 main/customers_100_200 main/customers_200若稍后customers表被修改,下一次 checkpoint 会为它创建新文件,且新文件会覆盖"自上一个层文件以来的空隙",保证同一关系的 LSN 区间始终连续:
main/orders_100 main/orders_100_200 main/orders_200 main/orders_200_300 main/orders_300 main/orders_300_400 main/orders_400 main/customers_100 main/customers_100_200 main/customers_200 main/customers_200_500 main/customers_500读取页版本(GetPage@LSN 的处理)
每当 Compute Node 发来 GetPage@LSN 请求,Page Server 需要重构该页在请求 LSN 时刻的内容:
- 先查最近的内存层——若目标页版本在其中,直接返回,无需碰磁盘;
- 否则定位包含该页版本的层文件:加载之,从基镜像开始重放其中适用于该页的 WAL 记录,直至目标 LSN。
例如请求orders表在 LSN 250 的页,Page Server 会加载main/orders_200_300文件,由于层文件 = 起始 LSN 处的完整关系镜像 + WAL,重构过程就是从 LSN 200 的基镜像开始,重放 200-250 之间作用于该页的 WAL 记录。文档中同时指出(见 docs/pageserver-compaction.md):Postgres 以 8KB 页存储数据,页内更新要么是整页镜像、要么是增量补丁,每个写操作由其在 WAL 中的字节位置(LSN)标识,因此"页版本"天然由page@LSN唯一标识——这正与层文件的 key-LSN 二维组织方式一一对应。
checkpoint 相关配置(源码默认值)
文档明确指出:每条关系可以独立 checkpoint,即各关系的 LSN 区间不必对齐;同时同一关系也可以存在重叠的 LSN 区间。当前实现虽不会主动制造这两种情形(代码总是把所有关系一起 checkpoint),但读取代码需要能应对它们——例如在分支点附近做 GC、或设置显式恢复点时,这类状态可能作为瞬态出现,用于只保留仍被引用的那个精确点附近的层(例如把main/orders_100_200替换成main/orders_150),不过该 compaction 优化目前尚未实现。
在代码中,checkpoint 的触发与尺寸由租户级配置控制(默认值定义在 libs/pageserver_api/src/config.rs):
| 配置项 | 默认值 | 作用 |
|---|---|---|
checkpoint_distance | 256 MB(DEFAULT_CHECKPOINT_DISTANCE) | InMemory 层持有的 WAL 若旧于该距离就 flush;它实际决定了 L0 层文件的大小,也约束了崩溃后需要重新消化的 WAL 量 |
checkpoint_timeout | 10 分钟(DEFAULT_CHECKPOINT_TIMEOUT) | 即使活动停止,内存层也会至少每隔该时间 flush 一次,确保 WAL 最终上传 |
多分支与 PITR:祖先链上的线性历史
假设在 LSN 250 处创建子分支:
@250 ----main--+--------------------------> \ +---child-------------->随后orders表在main与child上被不同地更新,磁盘上出现:
main/orders_100 main/orders_100_200 main/orders_200 main/orders_200_300 main/orders_300 main/orders_300_400 main/orders_400 main/customers_100 main/customers_100_200 main/customers_200 child/orders_250_300 child/orders_300 child/orders_300_400 child/orders_400customers表在子分支上没被修改,因此child目录下没有它的文件。若在child分支上请求customers的页面,Page Server 在child目录里找不到对应层文件,就会递归到父分支main去查找。
从child分支的视角看,每个关系的历史是线性的,请求的 LSN 唯一地确定了需要查看哪个文件:
main分支视角下orders的历史:main/orders_100、main/orders_100_200、main/orders_200、main/orders_200_300、main/orders_300、main/orders_300_400、main/orders_400;child分支视角下则拼接为:main/orders_100、main/orders_100_200、main/orders_200、main/orders_200_300、child/orders_250_300、child/orders_300、child/orders_300_400、child/orders_400。
分支元数据记录了子分支的创建点 LSN 250。若请求 LSN 275 的页,则从child/orders_250_300读取;为了把 WAL 重放到 275,可能还需要先重建 LSN 250 处的页版本,即结合main/orders_200_300与main/orders_200;而main/orders_200_300中 250-300 之间的页版本在子分支上会被忽略。
一个值得强调的事实:子分支在main尾部恰好是 LSN 250 时创建,还是在main已前进之后于历史 LSN 250 处创建,对存储逻辑没有任何区别——后者(从历史 LSN 分支)正是 Neon 支持PITR(Point-In-Time Recovery)的方式。这与pitr_interval配置(默认 7 天)共同构成了 Neon 的按时间点恢复能力:只要页版本在保留期内,就可以从任意 LSN 分支出新的时间线。
垃圾回收(GC):如何安全地删除旧层文件
系统会持续产生新的层文件,磁盘空间不是无限的,因此需要 GC 删除不再需要的旧文件。目前 Page Server 支持"从任何分支上、距离分支 tip足够近"的任何 LSN 进行 PITR 和分支。"足够近"由一个 LSN 地平线(horizon)定义,默认 64 MB(即DEFAULT_GC_HORIZON = 64 * 1024 * 1024,见 libs/pageserver_api/src/config.rs L878)。下面沿用文档例子,假设 horizon 为 150 个 LSN 单位。
单分支场景
假设分支尾部在 LSN 525,则 GC horizon 位于 525-150 = 375:
main/orders_100 main/orders_100_200 main/orders_200 main/orders_200_300 main/orders_300 main/orders_300_400 main/orders_400 main/orders_400_500 main/orders_500 main/customers_100 main/customers_100_200 main/customers_200end LSN 早于 GC horizon 375、且该表存在更新层文件的文件可以删除:
main/orders_100 DELETE main/orders_100_200 DELETE main/orders_200 DELETE main/orders_200_300 DELETE main/orders_300 STILL NEEDED BY orders_300_400 main/orders_300_400 KEEP, NEWER THAN GC HORIZON main/orders_400 .. main/orders_400_500 .. main/orders_500 .. main/customers_100 DELETE main/customers_100_200 DELETE main/customers_200 KEEP, NO NEWER VERSION注意main/customers_200:虽然足够老,但因为该表没有更新的层文件,不能删除(否则数据会丢失)。
多分支场景
多分支时情况更复杂:除"近期"文件外,仍被子分支需要的旧快照文件也必须保留。例如子分支创建于 LSN 150,且customers在子分支上被更新过:
main/orders_100 KEEP, NEEDED BY child BRANCH main/orders_100_200 KEEP, NEEDED BY child BRANCH main/orders_200 DELETE main/orders_200_300 DELETE main/orders_300 KEEP, NEWER THAN GC HORIZON main/orders_300_400 KEEP, NEWER THAN GC HORIZON main/orders_400 KEEP, NEWER THAN GC HORIZON main/orders_400_500 KEEP, NEWER THAN GC HORIZON main/orders_500 KEEP, NEWER THAN GC HORIZON main/customers_100 DELETE main/customers_100_200 DELETE main/customers_200 KEEP, NO NEWER VERSION child/customers_150_300 DELETE child/customers_300 KEEP, NO NEWER VERSIONmain/orders_100与main/orders_100_200虽然老于 GC horizon,但子分支仍在引用,不能删;main/orders_200与main/orders_200_300则可以删除。如果之后orders在child分支上被修改,会为它生成新的镜像与 delta 文件:
main/orders_100 main/orders_100_200 main/orders_300 main/orders_300_400 main/orders_400 main/orders_400_500 main/orders_500 main/customers_200 child/customers_300 child/orders_150_400 child/orders_400此后main/orders_100、main/orders_100_200理论上可以删除(子分支已有更新的层文件),但文档明确标注:该优化尚未实现——只要子分支存在,GC 算法目前仍会保留main分支上的这些文件。
GC 的实现与配置(源码佐证)
- GC 后台任务的主循环在 pageserver/src/tenant/tasks.rs 的
gc_loop(约 L343):周期性调用tenant.gc_iteration(...),其中gc_horizon来自租户配置get_gc_horizon()(见 pageserver/src/tenant.rs L4162),并同时传入pitr_interval——即 GC 的"历史保留"由WAL 字节数(gc_horizon)与时间(pitr_interval)双维度共同决定; - 当
gc_period == Duration::ZERO或gc_horizon == 0时自动 GC 被禁用(源码注释与TaskKind::GarbageCollector上下文); gc_horizon、gc_period均可通过租户配置/管理 API 覆盖,配置结构体定义见 libs/pageserver_api/src/models.rs(TenantConfig/patch 结构,L697 附近),序列化测试在 pageserver/src/tenant/config.rs L276-L285;- 默认值一览(libs/pageserver_api/src/config.rs):
gc_horizon = 64 MB、gc_period = 1 hr、pitr_interval = 7 days、compaction_target_size = 128 MB、compaction_period = 20 s、compaction_threshold = 10(L0→L1 合并的最小层数)、compaction_upper_limit = 20(单次合并的 L0 层数上界,单位为checkpoint_distance的倍数)、image_creation_threshold = 3(创建 L1 镜像层的 delta 层搅动阈值)。
WAL redo 与多租户安全
要"从某个页镜像 + 若干 WAL 记录重构出特定页版本",Page Server 需要重放 WAL。这在 GetPage@LSN 请求到来时按需发生,也会作为后台整理任务的一部分发生(用于重组数据以获得更快的访问)。
重放 WAL 有一个严峻的安全前提:数据不能跨租户泄漏,且某条时间线上的一条损坏 WAL 记录不能影响其他租户或其他时间线。传统 PostgreSQL 并不把这当安全问题——能访问 WAL 目录或拥有超级用户权限本就可完全控制系统;但 Neon 的 pageserver 是多租户的,必须在同一进程内执行属于不同租户的 WAL,恶意 WAL 不能波及其他租户。
为此(详见 docs/pageserver-walredo.md):
- 每个租户独立进程:为每个租户启动一个独立的 WAL redo 进程;该进程用
seccomp(2)系统调用把自身权限裁剪到重放 WAL 所需的最小集合——不能访问文件系统,不能访问网络,只能通过管道与父 pageserver 进程通信; - 威胁模型:即便攻击者把恶意 WAL 注入某时间线的 WAL 流并劫持了对应的 WAL redo 进程,该进程也接触不到系统其他部分;又因每租户一个进程,被劫持者只能看到同租户的 WAL 与数据——而这些攻击者本来就能访问;
- 通信方式:WAL redo 进程以 Neon 专属命令行参数启动
postgres可执行文件进入 redo 模式;pageserver 控制其生命周期,租户被 detach 时杀掉对应进程。通信基于 stdin/stdout/stderr 的请求-响应式简单自定义协议(协议说明位于 pageserver/src/tenant/walredo.rs):重放某页的一组 WAL 记录时,pageserver 先把该页的 "before" 镜像和 WAL 记录经 stdin 送入,再发重放命令,redo 进程返回 "after" 镜像; - 部分记录不走 redo 进程:一些 WAL 记录类型(如 SLRU 相关的 commit 记录)由 pageserver 内的专属 Rust 代码直接处理——SLRU 不使用 Postgres 标准缓冲管理器,在 redo 模式里处理它们需要对 Postgres 大改;另外带整页镜像(如
XLOG_FPI)的记录在摄取阶段就被特殊处理,直接存为页镜像而非 WAL 记录; - 多页记录的复制:某些 Postgres WAL 记录会修改多个页,这类记录会被复制多份,为每个受影响的页各存一份。虽然略有浪费,但大多数记录只影响单页,开销可接受;且 WAL redo 永远针对单页进行,若记录包含对其他页的修改则直接忽略。
结语与延伸阅读
总结下来,Neon Page Server 用"内存重排缓冲区 → L0 层文件 → L1 层文件 → 云存储归档"的流水线,把传统 PostgreSQL 的基备份 + WAL 归档替换为不可变层文件构成的二维 key-LSN 空间;以disk_consistent_lsn和gc_horizon/pitr_interval为锚点,在无副本的前提下保证持久性、支撑按 LSN 的任意页版本读取、分支与 PITR,并用每租户独立的 seccomp 受限 redo 进程守住多租户隔离底线。
若希望深入本仓库继续研究,推荐按以下路径展开:
- docs/pageserver-storage.md:层文件格式、生命周期与命名规范(本文第 4-5 节的主体来源);
- docs/pageserver-services.md:服务线程模型与 Repository 抽象;
- docs/pageserver-walredo.md:WAL redo 进程与多租户安全细节;
- docs/pageserver-compaction.md:compaction 的动机与两阶段算法(L0→L1 合并、L1 镜像化);
- 源码入口:pageserver/src/page_service.rs(页面请求)、pageserver/src/walingest.rs(WAL 摄取)、pageserver/src/tenant/timeline.rs(
disk_consistent_lsn与 checkpoint/GC 调度)、pageserver/src/tenant/layer_map.rs(层索引)、pageserver/src/tenant/storage_layer.rs(层类型定义)、libs/pageserver_api/src/config.rs(全部默认配置值)。
【免费下载链接】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),仅供参考