Neon 深度拆解:Serverless Postgres 的存储计算分离架构——从 Safekeeper 到 Pageserver,手写一个生产级分支数据库引擎
引言:为什么 PostgreSQL 需要「 Serverless 化」?
传统 PostgreSQL 部署有一个根本性的架构矛盾:存储与计算耦合在同一进程中。一个 PostgreSQL 实例启动时,wal_receiver、background writer、checkpointer、autovacuum launcher 等 20+ 个后台进程同时运行,即使你只跑一条 SELECT 1,整套进程树也必须全部就位。这意味着:
- 无法 Scale-to-Zero:即使没有查询,实例也在消耗 CPU 和内存
- 分支成本极高:想创建一个生产数据库的测试副本,需要完整复制整个数据目录(TB 级数据 = 数小时等待)
- 恢复时间不可控:PostgreSQL 的 PITR(Point-in-Time Recovery)需要重放 WAL 日志,从全量备份恢复可能需要数小时
Neon 的回答是:把存储层从 PostgreSQL 中抽离出来,用 Rust 重写一个专用的存储引擎,让 PostgreSQL 变成一个无状态的「计算节点」。
这不是一个简单的「加个缓存层」的优化。Neon 重新设计了 PostgreSQL 的 WAL(Write-Ahead Log)流转路径,引入了 Safekeeper(WAL 持久化)和 Pageserver(页面存储与懒加载)两层架构,让数据库的存储变成了一个可以按需扩展、按需挂载的独立服务。
2026 年 7 月,Neon 被 Databricks 以 10 亿美元收购,正式并入 Databricks 平台,成为「Lakebase Postgres」的核心引擎。这意味着 Serverless Postgres 从一个独立产品变成了企业级数据平台的一部分。
本文将从架构层面深度拆解 Neon 的每一个核心组件,分析其存储引擎的设计哲学,并手写一个最小化的「存储计算分离」Postgres 分支引擎。
第一章:Neon 架构全景——六层抽象的 Serverless 数据库
1.1 整体架构概览
Neon 的架构可以用一句话概括:PostgreSQL 是无状态的计算节点,Neon 是有状态的存储服务。
┌─────────────────────────────────────────────────────────┐
│ Client (你的应用) │
└──────────────────────┬──────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────┐
│ Proxy (连接代理) │
│ 路由查询到正确的 Compute 节点 │
└──────────┬───────────────────────────┬──────────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Compute A │ │ Compute B │
│ (PostgreSQL)│ │ (PostgreSQL)│
│ 无状态节点 │ │ 无状态节点 │
└──────┬──────┘ └──────┬──────┘
│ │
┌──────────▼───────────────────────────▼──────────────────┐
│ Safekeeper (WAL 持久化层) │
│ 接收 WAL、持久化、仲裁复制 │
└──────────┬───────────────────────────┬──────────────────┘
│ │
┌──────────▼───────────────────────────▼──────────────────┐
│ Pageserver (页面存储引擎) │
│ 懒加载页面、创建分支、管理 Snapshot、处理 Compaction │
└──────────┬──────────────────────────────────────────────┘
│
┌──────────▼──────────────────────────────────────────────┐
│ Cloud Storage (S3 / Azure Blob) │
│ 最终的持久化存储层(对象存储) │
└─────────────────────────────────────────────────────────┘
1.2 六层抽象详解
第一层:Proxy(连接代理)
- 基于 Rust 实现的连接池和路由层
- 接收客户端的 PostgreSQL 协议连接
- 根据项目/分支信息路由到正确的 Compute 节点
- 处理 TLS 终止、连接复用、负载均衡
第二层:Compute(计算节点)
- 标准的 PostgreSQL 进程,但经过了修改
- 通过
neon扩展与 Pageserver 通信 - WAL 不写本地磁盘,而是通过网络发送给 Safekeeper
- 从 Pageserver 按需拉取页面(lazy loading)
第三层:Safekeeper(WAL 持久化层)
- 接收 Compute 节点发送的 WAL 段
- 持久化到本地磁盘和/或对象存储
- 实现 quorum 复制(通常 3 节点中 2 节点确认)
- 管理 WAL 的保留策略和 GC
第四层:Pageserver(页面存储引擎)
- Neon 最核心的组件,用 Rust 编写
- 将 WAL 流转换为页面快照(Materialized Page)
- 支持 Copy-on-Write 分支(核心创新点)
- 实现懒加载:只在需要时才从对象存储拉取页面
- 管理 Compaction(合并 LSM-tree 层级)
第五层:Storage Controller(存储控制器)
- 管理 Pageserver 之间的负载均衡
- 处理租户(Tenant)的迁移和分裂
- 监控存储层的健康状态
第六层:Cloud Storage(对象存储)
- 最终的持久化层(S3、Azure Blob)
- Pageserver 的本地磁盘作为缓存
- WAL 段最终也会归档到对象存储
第二章:存储计算分离的核心——WAL 流转路径重设计
2.1 传统 PostgreSQL 的 WAL 路径
在标准 PostgreSQL 中,WAL 的流转路径是:
事务写入 → WAL Buffer → walwriter → 本地磁盘 WAL 文件
↓
checkpoint → 数据文件
关键点:WAL 和数据文件在同一个文件系统中,由同一个 PostgreSQL 实例管理。
2.2 Neon 的 WAL 路径
Neon 完全重构了这条路径:
事务写入 → WAL Buffer → WAL Sender → Safekeeper (网络传输)
↓
持久化到 Safekeeper 本地磁盘
↓
归档到对象存储
↓
Pageserver 消费 WAL → 更新页面快照
关键设计决策:
- WAL 不落本地盘:Compute 节点的本地磁盘上没有 WAL 文件,这意味着 Compute 可以随时被销毁和重建
- Safekeeper 是 WAL 的唯一持久化点:WAL 通过网络发送给 Safekeeper,由 Safekeeper 负责持久化
- Pageserver 是 WAL 的消费者:Pageserver 订阅 Safekeeper 的 WAL 流,将其转换为页面快照
2.3 为什么不能直接把 WAL 写到 S3?
你可能会问:为什么不直接把 WAL 写到 S3,为什么需要 Safekeeper?
原因有三:
- 延迟:S3 的写入延迟在 50-200ms,而 WAL 写入需要在事务提交前完成。Safekeeper 使用本地 SSD,写入延迟在 1ms 以内
- 部分写入:S3 不支持追加写入(append),而 WAL 是顺序追加流。Safekeeper 可以高效地处理 WAL 段的追加
- 仲裁复制:Safekeeper 实现了 quorum 复制,可以在不依赖 S3 的情况下保证 WAL 的持久性
第三章:Pageserver 深度拆解——LSM-tree 与 Copy-on-Write 分支
3.1 Pageserver 的核心职责
Pageserver 是 Neon 架构中最复杂的组件,它需要同时解决三个问题:
- WAL 重放:将 WAL 流转换为页面快照
- 懒加载:不在内存中保留所有页面,按需从对象存储加载
- 分支管理:支持 Git 式的数据库分支,零拷贝创建测试环境
3.2 LSM-tree 存储引擎
Pageserver 的存储引擎基于 LSM-tree(Log-Structured Merge-tree)设计,与 LevelDB/RocksDB 的思路类似,但针对 PostgreSQL 的页面访问模式做了优化:
内存层(MemTable)
↓ flush
L0 层(最近的 WAL 段转换结果)
↓ compaction
L1 层(合并后的页面快照)
↓ compaction
L2 层(更老的页面快照)
↓ ...
对象存储(最终归档)
关键区别:
- 传统 LSM-tree 的 key 是用户定义的 key,Pageserver 的 key 是
(tenant_id, timeline_id, block_number) - 每个 key 的 value 是整个 8KB 的 PostgreSQL 页面
- Compaction 的目的是合并多个版本的同一页面,只保留最新的
3.3 Copy-on-Write 分支:数据库的 Git
这是 Neon 最核心的创新。当你执行以下操作时:
-- 在 Neon 中创建分支
-- 等价于 git branch feature-x
SELECT neon.create_branch('feature-x', 'main');
Pageserver 的处理方式:
- 不复制任何数据:分支创建的开销是 O(1),只创建一个新的 timeline 元数据记录
- 共享父分支的页面:子分支和父分支共享相同的基础页面
- 写时复制:当子分支修改某个页面时,Pageserver 才会为子分支创建该页面的新版本
这与 Git 的分支模型完全一致:
git branch=neon.create_branch()(O(1),不复制数据)git checkout= 挂载分支到 Compute 节点(毫秒级)git merge= 合并两个分支的修改(Pageserver 处理冲突)
3.4 懒加载机制
Pageserver 不会一次性加载所有页面到内存。它的策略是:
- 首次访问:当 Compute 节点请求某个页面时,Pageserver 从对象存储下载该页面所在的 layer 文件
- 缓存:下载的页面缓存在 Pageserver 的本地 SSD 上
- 驱逐:当缓存满时,LRU 驱逐最久未访问的页面
- 预取:对于顺序扫描,Pageserver 可以预取后续页面
这种设计让 Pageserver 可以管理远超本地磁盘容量的数据集。一个 Pageserver 节点可以管理数 TB 的数据,但本地 SSD 只需要几百 GB 作为热数据缓存。
第四章:手写一个最小化的存储计算分离引擎
让我们从零开始实现一个最小化的「存储计算分离」Postgres 分支引擎。这个引擎将演示 Neon 的核心概念:
4.1 核心数据结构
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// 页面编号(PostgreSQL 使用 8KB 页面)
type BlockNumber = u32;
/// LSN(Log Sequence Number)
type LSN = u64;
/// WAL 记录:记录对某个页面的修改
#[derive(Clone, Debug)]
struct WALRecord {
lsn: LSN,
block_number: BlockNumber,
/// 页面修改后的完整内容(简化版,实际应该是 delta)
page_data: Vec<u8>,
}
/// Timeline:一个分支的完整历史
#[derive(Debug)]
struct Timeline {
id: String,
/// 父分支 ID(如果是根分支则为 None)
parent_id: Option<String>,
/// 创建时的 LSN
start_lsn: LSN,
/// WAL 记录(按 LSN 排序)
wal_records: Vec<WALRecord>,
}
/// Tenant:一个项目的所有分支
#[derive(Debug)]
struct Tenant {
id: String,
timelines: BTreeMap<String, Timeline>,
}
/// Pageserver:核心存储引擎
struct Pageserver {
tenants: Arc<RwLock<BTreeMap<String, Tenant>>>,
/// 对象存储客户端(简化为本地文件系统)
storage_path: String,
}
4.2 WAL 消费与页面物化
impl Pageserver {
/// 消费 WAL 记录并更新页面快照
async fn consume_wal(&self, tenant_id: &str, timeline_id: &str, record: WALRecord) {
let mut tenants = self.tenants.write().await;
let tenant = tenants.get_mut(tenant_id).expect("tenant not found");
let timeline = tenant.timelines.get_mut(timeline_id).expect("timeline not found");
// 追加 WAL 记录
timeline.wal_records.push(record.clone());
// 物化页面快照(将 WAL 转换为页面的最终状态)
self.materialize_page(tenant_id, timeline_id, record.block_number).await;
}
/// 物化单个页面:重放所有涉及该页面的 WAL 记录
async fn materialize_page(&self, tenant_id: &str, timeline_id: &str, block_number: BlockNumber) {
let tenants = self.tenants.read().await;
let tenant = tenants.get(tenant_id).unwrap();
let timeline = tenant.timelines.get(timeline_id).unwrap();
// 重放所有涉及该页面的 WAL 记录
let mut page_data = vec![0u8; 8192]; // PostgreSQL 默认 8KB 页面
for record in &timeline.wal_records {
if record.block_number == block_number {
page_data.copy_from_slice(&record.page_data);
}
}
// 写入物化后的页面到本地缓存
let page_path = format!(
"{}/{}/{}/pages/{}.page",
self.storage_path, tenant_id, timeline_id, block_number
);
tokio::fs::write(&page_path, &page_data).await.unwrap();
}
}
4.3 Copy-on-Write 分支实现
impl Pageserver {
/// 创建分支:O(1) 操作,不复制任何数据
async fn create_branch(
&self,
tenant_id: &str,
new_timeline_id: &str,
source_timeline_id: &str,
start_lsn: LSN,
) {
let mut tenants = self.tenants.write().await;
let tenant = tenants.get_mut(tenant_id).expect("tenant not found");
// 获取源分支的当前状态
let source = tenant.timelines.get(source_timeline_id)
.expect("source timeline not found")
.clone();
// 创建新分支:只复制元数据,不复制页面数据
let new_timeline = Timeline {
id: new_timeline_id.to_string(),
parent_id: Some(source_timeline_id.to_string()),
start_lsn,
wal_records: Vec::new(), // 空的!新分支从零开始记录 WAL
};
tenant.timelines.insert(new_timeline_id.to_string(), new_timeline);
println!(
"[Pageserver] Branch '{}' created from '{}' at LSN {} (O(1) operation)",
new_timeline_id, source_timeline_id, start_lsn
);
}
/// 读取页面:实现 CoW 语义
async fn read_page(
&self,
tenant_id: &str,
timeline_id: &str,
block_number: BlockNumber,
) -> Option<Vec<u8>> {
let tenants = self.tenants.read().await;
let tenant = tenants.get(tenant_id).unwrap();
// 递归查找页面:先在当前分支找,找不到就去父分支找
let mut current_timeline_id = timeline_id;
loop {
let timeline = tenant.timelines.get(current_timeline_id)?;
// 尝试从物化缓存读取
let page_path = format!(
"{}/{}/{}/pages/{}.page",
self.storage_path, tenant_id, current_timeline_id, block_number
);
if let Ok(data) = tokio::fs::read(&page_path).await {
return Some(data);
}
// 当前分支没有这个页面,去父分支找
match &timeline.parent_id {
Some(parent_id) => {
current_timeline_id = parent_id;
println!(
"[Pageserver] Page {} not in timeline '{}', falling back to parent '{}'",
block_number, timeline_id, parent_id
);
}
None => return None, // 到达根分支仍然没有
}
}
}
}
4.4 Safekeeper 实现
use std::collections::VecDeque;
/// Safekeeper:WAL 持久化层
struct Safekeeper {
/// 项目 ID
tenant_id: String,
/// 分支 ID
timeline_id: String,
/// WAL 段队列
wal_segments: VecDeque<WALSegment>,
/// 最小保留 LSN(低于此 LSN 的 WAL 可以被 GC)
min_retain_lsn: LSN,
/// 已确认的 commit_lsn
commit_lsn: LSN,
}
#[derive(Debug)]
struct WALSegment {
start_lsn: LSN,
data: Vec<u8>,
}
impl Safekeeper {
/// 接收来自 Compute 节点的 WAL 记录
async fn receive_wal(&mut self, record: WALRecord) {
// 持久化到本地磁盘
let segment_path = format!(
"/data/wal/{}/{}.segment",
self.timeline_id,
record.lsn / (16 * 1024 * 1024) // 16MB WAL 段
);
// 追加写入 WAL 段
let mut segment_data = tokio::fs::read(&segment_path).await.unwrap_or_default();
segment_data.extend_from_slice(&record.page_data);
tokio::fs::write(&segment_path, &segment_data).await.unwrap();
// 更新 commit_lsn(假设单节点场景,无需仲裁)
self.commit_lsn = self.commit_lsn.max(record.lsn);
println!(
"[Safekeeper] WAL record at LSN {} persisted, commit_lsn = {}",
record.lsn, self.commit_lsn
);
}
/// WAL GC:删除不再需要的 WAL 段
async fn gc_wal(&mut self) {
self.wal_segments.retain(|seg| {
seg.start_lsn + (16 * 1024 * 1024) as u64 > self.min_retain_lsn
});
}
}
4.5 完整的使用示例
#[tokio::main]
async fn main() {
// 初始化 Pageserver
let pageserver = Pageserver {
tenants: Arc::new(RwLock::new(BTreeMap::new())),
storage_path: "/tmp/neon-demo".to_string(),
};
// 创建项目和分支
{
let mut tenants = pageserver.tenants.write().await;
let mut timelines = BTreeMap::new();
timelines.insert("main".to_string(), Timeline {
id: "main".to_string(),
parent_id: None,
start_lsn: 0,
wal_records: Vec::new(),
});
tenants.insert("my-project".to_string(), Tenant {
id: "my-project".to_string(),
timelines,
});
}
// 模拟写入:向 main 分支写入数据
for i in 0..100 {
let record = WALRecord {
lsn: i * 8192,
block_number: i % 10, // 写入 10 个页面
page_data: vec![i as u8; 8192],
};
pageserver.consume_wal("my-project", "main", record).await;
}
// 创建分支(O(1) 操作)
pageserver.create_branch("my-project", "feature-x", "main", 50 * 8192).await;
// 读取分支页面(CoW 语义)
let page = pageserver.read_page("my-project", "feature-x", 0).await;
println!("[Client] Read page 0 from feature-x: {} bytes", page.unwrap().len());
// 向 feature-x 分支写入新数据(不影响 main)
let record = WALRecord {
lsn: 100 * 8192,
block_number: 0,
page_data: vec![42u8; 8192],
};
pageserver.consume_wal("my-project", "feature-x", record).await;
// 验证:main 分支的页面没有被修改
let main_page = pageserver.read_page("my-project", "main", 0).await;
let feature_page = pageserver.read_page("my-project", "feature-x", 0).await;
println!("[Client] main page[0]: {}", main_page.unwrap()[0]);
println!("[Client] feature-x page[0]: {}", feature_page.unwrap()[0]);
// 输出:main page[0]: 0, feature-x page[0]: 42
}
第五章:Neon 的性能优化策略
5.1 计算节点的无状态化
Neon 的 Compute 节点是完全无状态的,这意味着:
- 可以在秒级启动新的 Compute 节点
- 可以根据负载自动扩缩容
- Compute 节点故障时,可以立即在另一台机器上重建
性能数据:
- 冷启动时间:~500ms(从对象存储加载必要的元数据)
- 热启动时间:~50ms(复用缓存的页面)
- 传统 PostgreSQL 启动:~5-30s(需要恢复 shared buffers、启动后台进程)
5.2 页面预取与缓存策略
Pageserver 使用多级缓存:
L1: Compute 节点的 shared_buffers(PostgreSQL 原生)
↓ miss
L2: Pageserver 的本地 SSD 缓存
↓ miss
L3: 对象存储(S3 / Azure Blob)
预取策略:
- 顺序扫描:预取后续 128 个页面(1MB)
- 随机访问:不预取,按需加载
- 索引扫描:预取索引层级的页面
5.3 WAL 压缩与归档
Neon 对 WAL 进行压缩后再归档到对象存储:
原始 WAL(16MB/段)
↓ LZ4 压缩
压缩后 WAL(~2-4MB/段)
↓ 归档
对象存储
压缩率:对于典型的 OLTP 工作负载,压缩率在 4:1 到 8:1 之间。
第六章:Neon vs 传统方案——为什么不是简单加个缓存?
6.1 与 RDS Aurora 的对比
| 特性 | Neon | Aurora |
|---|---|---|
| 存储计算分离 | ✅ 完全分离 | ⚠️ 部分分离(存储层仍与实例绑定) |
| Scale-to-Zero | ✅ 真正的零 | ❌ 最小实例仍然消耗资源 |
| 数据库分支 | ✅ Git 式零拷贝分支 | ❌ 需要完整快照 |
| 开源 | ✅ Apache 2.0 | ❌ 闭源 |
| 存储引擎 | Rust(自研 LSM-tree) | InnoDB(修改版) |
| 冷启动 | ~500ms | ~30s(最小实例) |
6.2 与 PlanetScale(Vitess)的对比
| 特性 | Neon | PlanetScale |
|---|---|---|
| 数据库 | PostgreSQL | MySQL(Vitess) |
| 分支模型 | 存储层原生支持 | 依赖 Vitess 的 VReplication |
| 向后兼容 | 完全兼容 PostgreSQL | MySQL 兼容但有差异 |
| 本地开发 | ✅ CLI 本地运行 | ❌ 仅云端 |
6.3 与 Supabase 的对比
| 特性 | Neon | Supabase |
|---|---|---|
| 核心创新 | 存储计算分离 | BaaS(后端即服务) |
| 存储引擎 | 自研 Pageserver | 标准 PostgreSQL |
| 分支功能 | ✅ 原生支持 | ❌ 不支持 |
| 实时订阅 | 需要额外配置 | ✅ 内置 Realtime |
第七章:生产部署指南
7.1 Neon Cloud 部署(推荐)
# 安装 Neon CLI
curl -fsSL https://cli.neon.tech/install.sh | sh
# 创建项目
neon projects create my-project
# 创建分支
neon branches create --project my-project feature-x
# 获取连接字符串
neon connection-string --project my-project --branch feature-x
7.2 本地自托管部署
# 克隆 Neon 存储引擎
git clone https://github.com/neondatabase/neon.git
cd neon
# 编译
cargo build --release
# 启动本地 Pageserver
./target/release/pageserver \
--listen-addr 127.0.0.1:6400 \
--config pageserver.toml
# 启动 Safekeeper
./target/release/safekeeper \
--listen-addr 127.0.0.1:5454 \
--config safekeeper.toml
# 启动 Compute 节点(使用 neon 扩展的 PostgreSQL)
./target/release/postgres \
-D /tmp/neon-data \
-c neon.storage_controller=127.0.0.1:6400
7.3 监控与调优
-- 查看分支的 WAL 使用量
SELECT * FROM neon.branches WHERE tenant_id = 'my-project';
-- 查看 Pageserver 的缓存命中率
SELECT * FROM neon.pageserver_stats;
-- 查看 Safekeeper 的 WAL 延迟
SELECT * FROM neon.safekeeper_stats;
第八章:Neon 的未来——被 Databricks 收购后会怎样?
2026 年 7 月,Neon 被 Databricks 以 10 亿美元收购,更名为「Lakebase Postgres」。这次收购意味着:
- Serverless Postgres 成为数据平台标配:Neon 的存储计算分离架构将成为 Databricks 平台的标准 Postgres 引擎
- OLAP + OLTP 统一:Lakehouse 架构(Iceberg + Spark)将与 Serverless Postgres 深度集成
- AI Agent 的数据库:Neon 的分支功能非常适合 AI Agent 场景——每个 Agent 可以有自己的数据库分支,互不干扰
总结
Neon 重新定义了 PostgreSQL 的架构边界:
- 存储计算分离让数据库变成了「无状态计算 + 有状态存储」的云原生架构
- Copy-on-Write 分支让数据库拥有了 Git 般的版本控制能力
- 懒加载让 Pageserver 可以管理远超本地磁盘容量的数据集
- Rust 实现的存储引擎提供了内存安全和极致性能
对于需要多租户隔离、数据库分支、Scale-to-Zero 的场景,Neon 是目前最成熟的选择。随着被 Databricks 收购,Serverless Postgres 将从一个独立产品变成企业数据平台的核心组件。
核心 takeaway:
- Neon 的核心创新是「把 WAL 从 PostgreSQL 中抽离出来」
- Pageserver 的 LSM-tree + CoW 分支是整个架构的关键
- Safekeeper 解决了「WAL 不能直接写 S3」的延迟问题
- 生产部署推荐使用 Neon Cloud,自托管适合对数据主权有要求的场景
本文代码基于 Neon 存储引擎的概念简化实现,完整源码参考 github.com/neondatabase/neon。生产环境请使用 Neon Cloud 或经过充分测试的自托管方案。