Neon Pageserver 的 Vectored Timeline Get:批量读取重构协议解析与源码实现
【免费下载链接】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 将计算与存储分离后,Pageserver 的核心读路径是Timeline::get——按单个(Key, Lsn)定位并重建一个 PostgreSQL 页面。本文以 RFC 030(Vectored Timeline Get)为骨架,梳理该特性的动机、API 设计、落地过程及其与 Layer Map、DiskBtree、VirtualFile、分片(Sharding)的关系,并结合当前仓库源码验证其最终实现形态。读完你将掌握:为什么相邻键的逐点查询低效、get_vectored如何用一次调用替代 N 次get、以及它在 basebackup / page_service / compaction 等场景下的真实用法。
背景:Timeline::get与 basebackup 的性能痛点
在 Neon 的分层存储架构中,一个时间线(Timeline)由一层层 L0/L1 delta 层与 image 层堆叠而成,Timeline::get(key, lsn)是重建任一页面版本的入口。重建过程大致是:
- 从 Layer Map 自顶向下遍历各层,收集重构数据(reconstruct data);
- 对每个被访问的层,向
DiskBtree发起一次点查询(point lookup); - 命中则把结果装入 reconstruct data bag;若已足以重建页面则进入 walredo 阶段,否则继续向更底层遍历。
RFC 030 指出,basebackup 期间会对在键空间上彼此相邻的 SLRU 页面发起大量Timeline::get调用(见 basebackup.rs 中 SLRU 段的读取逻辑)。对 N 个相邻键做 N 次独立get,会带来三类浪费:
- Layer Map 遍历被重复执行:即使所有数据都落在栈底同一张 image 层里,每调用一次
get仍要从头走一遍层遍历; - DiskBtree 内部页被反复访问:跨全键空间的 L0 层必须被层遍历访问,但很可能并不包含目标数据;多个键的点查询还会重复读取同一批 Btree 内部页;
- 物理相邻的读被打散成 N 次 IO:经验观测表明,键空间相邻、同时写入的键在层文件中往往也物理相邻(RFC 脚注引用了 2023-12 的 basebackup 缓慢问题调查)。理想情况下,为 N 个相邻键提供重构数据只需要一次大的文件系统读,而文件系统若能连续存放层文件,这次大读就会合并为对磁盘的一次 IOP。
简而言之:现有 API 是"一页一查",而 basebackup 的访问模式是"一段一查",两者失配。
解决方案:Timeline::get_vectored的 API 设计
RFC 提出的核心是新增一个 vectored(批量 / scatter-gather 式)版本Timeline::get_vectored,签名如下:
struct KeyVec { base: Key, count: usize, } async fn get_vectored(lsn: Lsn, src: &[KeyVec]) -> Result<Vec<Bytes>, ...>;其中每个KeyVec { base, count }表示从base开始的count个连续键;lsn统一应用于整批查询。RFC 给出了函数语义上等价的最小实现:
let mut keys_iter: impl Iterator<Item = Key> = src.map(|KeyVec { base, count }| (base..base + count)).flatten(); let mut out = Vec::new(); for key in keys_iter { let data = Timeline::get(key, lsn)?; out.push(data); } out但这只是"正确性基准"。RFC 明确指出理想实现应满足三条硬约束:
- 每个
struct Layer至多访问一次; - 对每个被访问的层,
Layer::get_value_reconstruct_data至多调用一次(即每张DiskBtree页至多读一次); - 为合并发往 OS / NVMe 的读创造条件。
在真实仓库中,这个 API 经过演化后落地为更通用的形态:get_vectored不再直接接收lsn + &[KeyVec],而是接收一个VersionedKeySpaceQuery(键空间 + LSN 的封装)和一个IoConcurrency,返回BTreeMap<Key, Result<Bytes, PageReconstructError>>,见 timeline.rs。KeyVec的"连续键段"概念则由KeySpace/VersionedKeySpaceQuery承接。
落地现状:从 RFC 到仓库源码
1.Timeline::get已是get_vectored的特例
RFC 的 Rollout 章节提出:"本 epic 结束时,Timeline::get将转发到Timeline::get_vectored"。这一目标在仓库中已经实现:现在的 Timeline::get 内部构造一个只含单键的KeySpace,再调用get_vectored_impl,最后从返回的BTreeMap中取出唯一键值——单点查询与批量查询共用同一条读路径,这正是 RFC 要求的"单页 vectored get 的基础性能应与原get一致"。
let mut reconstruct_state = ValuesReconstructState::new(IoConcurrency::sequential()); let query = VersionedKeySpaceQuery::uniform(KeySpace::single(key..key.next()), lsn); let vectored_res = self.get_vectored_impl(query, &mut reconstruct_state, ctx).await; let key_value = vectored_res?.pop_first();2. 批量的 reconstruct data 状态:ValuesReconstructState
RFC 提出要"把get_reconstruct_data重构为持有并返回 N 个键的状态"。仓库中对应实现是 storage_layer.rs 的ValuesReconstructState:
keys: HashMap<Key, VectoredValueReconstructState>——本批待检索的键及其重构进度;keys_done: KeySpaceRandomAccum——已完成检索的键集合;keys_with_image_coverage: Option<Range<Key>>——image 层覆盖的键范围;layers_visited/delta_layers_visited——本次查询访问的层数与 delta 层数统计;io_concurrency与num_active_ios——控制读 IO 的并发调度。
在 get_vectored_impl 的循环中,逐层检查keys,把命中的键移入keys_done,直到所有键都拿到足够数据或层栈耗尽。
3. 层遍历的向量化与cont_lsn的处理
RFC 特别点名了重构中最微妙的问题:get_reconstruct_data在找到部分键的数据后需要记住cont_lsn(继续遍历的起点 LSN),批量版本必须按需保留各键自己的cont_lsn,并在下一轮迭代时取"仍缺数据的所有键的cont_lsn最大值"继续。从当前代码结构看(ValuesReconstructState以HashMap<Key, VectoredValueReconstructState>逐键追踪状态),这一"按键维护进度、整批推进层遍历"的模型正是 RFC 设想的形态。
4. DiskBtree 的向量化访问
RFC 对DiskBtree::visit提出两个层次的目标:
- 最小方案:让
visit接收一个连续的键范围(单个KeyVec),在其范围内对所有值回调,这对 basebackup 的连续键段场景已经够用; - 理想方案:接收
&[KeyVec]并排序,Btree 遍历过程中"窥视"下一个键段以决定是下降还是回溯。
当前 disk_btree.rs 的visit仍以单个search_key为起点、配合方向参数(VisitDirection)遍历,底层通过OnDiskNode::binary_search定位首个匹配项后沿兄弟节点游走;delta 层用它收集(offset, len)对以定位 delta 记录 blob,image 层则用 DiskBtree::get 直接取 image blob 偏移(其内部同样是visit调用)。RFC 所设想的"多键段排序 + 窥视式下降/回溯"是对这套代码的自然扩展。
5. 读合并:blob_io 与 VirtualFile
RFC 指出:DiskBtree::visit产出一批文件偏移,随后要从VirtualFile中读取(delta 层读路径依赖PageCache与VirtualFile,image 层读路径在 image_layer.rs 中)。要真正把 N 次小读合并成一次大读,需要向量化blob_io接口,再向量化VirtualFileAPI。RFC 同时给出两条工程警示:
VirtualFile正处于 io_uring 改造的活跃期,接口变动的耦合风险高;- 缓冲区管理方面,指导原则是不把
PageCache视为缓冲管理的必要组成部分,而应把它当作 IO 链路上可有可无的一跳,避免此工作与PageCache的未来走向强耦合。
从当前仓库看,读路径的并发已由 IoConcurrency 抽象:Sequential(顺序 IO,临时兜底方案)或SidecarTask(把 IO future 投递给一个独立的 sidecar 任务用FuturesUnordered并发执行)。这正是"以并发换取批量读延迟"的落地体现。
运维与配置:与get_vectored相关的参数与监控
该特性无需 feature flag(RFC 明言),但引入了两个影响读路径行为的配置项,见 config.rs:
| 配置项 | 默认类型 | 作用 |
|---|---|---|
max_get_vectored_keys | MaxGetVectoredKeys | 单次get_vectored调用允许的最大键数,超限返回GetVectoredError::Oversized |
get_vectored_concurrent_io | GetVectoredConcurrentIo | 读路径并发模型:Sequential或SidecarTask,决定IoConcurrency的构造 |
max_vectored_read_bytes | MaxVectoredReadBytes | 批量读的字节上限(如 basebackup 分段时按max_get_vectored_keys * BLCKSZ划分) |
监控方面,仓库在 metrics.rs 注册了pageserver_get_vectored_seconds直方图,按task_kind(如 basebackup、compaction、page_service)分别统计get_vectored耗时,便于定位各调用方的批量读延迟。
主要调用方:批量读在仓库中的真实使用
RFC 提出的三大受益场景(basebackup、compaction、page_service)在仓库中均有对应实现:
- basebackup:SLRU 非惰性下载时,先把整个 SLRU 键空间按
max_get_vectored_keys * BLCKSZ字节分片,再对每个分片调用timeline.get_vectored(query, io_concurrency, ctx),逐块喂给SlruSegmentsBuilder拼装段文件(见 basebackup.rs)。这正是 RFC 动机章节描述的"为 SLRU 相邻页批量取数"场景; - pgdatadir_mapping:向 compute 提供页面时,把请求的键范围构造为
VersionedKeySpaceQuery后批量取页(pgdatadir_mapping.rs),并在批内逐键处理错误,其中还保留了"把get_vectored的错误改为按键粒度返回"的 TODO; - page_service:
get_vectored目前强制每批上限 32 个键(page_service.rs 附近注释),为将来向 compute 暴露 vectoredget_page_at_lsn(利于 seqscan / prefetch)铺路,并复用同一套IoConcurrency并发模型; - compaction / 元数据扫描:
scan接口基于get_vectored_impl扫描整个元数据键空间,语义接近 RocksDB 的 scan iterator(存在即返回、缺失不报错),见 timeline.rs; - detach_ancestor:在剥离祖先时间线时直接调用
get_vectored_impl,绕过max_get_vectored_keys上限检查(detach_ancestor.rs)。
这些调用方共同验证了 RFC 的判断:批量读的价值不仅限于 basebackup,而是贯穿 Pageserver 读路径的通用能力。
与分片(Sharding)的交互
RFC 专门讨论了与 sharding 的边界:分片把键空间按key_to_shard_number切分,与Timeline::get一样,get_vectored的调用方必须只查询属于当前Timeline分片的键。仓库实现中这一约束以断言/校验落实:get_vectored会对查询键空间内的每个键执行assert!(!self.shard_identity.is_key_disposable(&key))(timeline.rs),Timeline::get也保留了debug_assert!形式的双重校验。
RFC 同时给出一个前瞻性警告:上层代码对"键空间 <=> 分片"的约束感知很弱,例如KeySpace并未按分片条带拆分——若有人把 compaction 代码朴素地改成"对整段键空间发起 vectored get",就会违反分片约束。因此这类断言在未来很长一段时间内都值得保留。
演进路径与工程节奏
RFC 给出的落地顺序是"先做前三项、再回头看":先明确 API(返回类型Vec<Bytes>与impl Stream之间的取舍、与 peers 迭代),再做向量的 Layer Map 遍历与get_reconstruct_data状态重构,接着向量化Layer::get_value_reconstruct_data/DiskBtree,最后才触碰 IO 合并这一最难的部分(因为受 io_uring 与PageCache变局影响)。工程节奏上,RFC 明确要求以多个小 PR 分周交付、不做 feature flag、一次性切换,以便在每周发布中隔离性能回归。
这一演进路径在仓库中清晰可见:核心的get_vectored_impl、ValuesReconstructState、IoConcurrency已就位,而DiskBtree::visit的多键段化、blob_io/VirtualFile的向量化仍留有演进空间,page_service中"向 compute 暴露批量取页"的 TODO 也仍然开放——这正是 RFC 作为长期 epic 的中间态。
小结
RFC 030 用一份紧凑的协议为 Neon Pageserver 定义了批量读路径的蓝图:以get_vectored统一单点与批量查询,把"逐页遍历层栈 + 逐点查 Btree + N 次文件读"压缩为"一次层遍历 + 每层至多一次 Btree 访问 + 尽量合并的底层读"。当前仓库中,Timeline::get已退化为get_vectored的单键特例,basebackup、元数据扫描、剥离祖先时间线等路径均已切换到批量读,且全程不依赖 feature flag——它以配置项(max_get_vectored_keys、get_vectored_concurrent_io)和直方图指标(pageserver_get_vectored_seconds)融入生产运维体系。若要深入源码,建议从 timeline.rs 的get/get_vectored/scan三连读起,再对照 storage_layer.rs 的ValuesReconstructState与IoConcurrency,即可还原 RFC 设想的完整读路径。
【免费下载链接】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),仅供参考