Neon Pageserver 的 Vectored Timeline Get:批量读取重构协议解析与源码实现
2026/9/13 18:56:13 网站建设 项目流程

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)是重建任一页面版本的入口。重建过程大致是:

  1. 从 Layer Map 自顶向下遍历各层,收集重构数据(reconstruct data);
  2. 对每个被访问的层,向DiskBtree发起一次点查询(point lookup);
  3. 命中则把结果装入 reconstruct data bag;若已足以重建页面则进入 walredo 阶段,否则继续向更底层遍历。

RFC 030 指出,basebackup 期间会对在键空间上彼此相邻的 SLRU 页面发起大量Timeline::get调用(见 basebackup.rs 中 SLRU 段的读取逻辑)。对 N 个相邻键做 N 次独立get,会带来三类浪费:

  1. Layer Map 遍历被重复执行:即使所有数据都落在栈底同一张 image 层里,每调用一次get仍要从头走一遍层遍历;
  2. DiskBtree 内部页被反复访问:跨全键空间的 L0 层必须被层遍历访问,但很可能并不包含目标数据;多个键的点查询还会重复读取同一批 Btree 内部页;
  3. 物理相邻的读被打散成 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_concurrencynum_active_ios——控制读 IO 的并发调度。

在 get_vectored_impl 的循环中,逐层检查keys,把命中的键移入keys_done,直到所有键都拿到足够数据或层栈耗尽。

3. 层遍历的向量化与cont_lsn的处理

RFC 特别点名了重构中最微妙的问题:get_reconstruct_data在找到部分键的数据后需要记住cont_lsn(继续遍历的起点 LSN),批量版本必须按需保留各键自己的cont_lsn,并在下一轮迭代时取"仍缺数据的所有键的cont_lsn最大值"继续。从当前代码结构看(ValuesReconstructStateHashMap<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 层读路径依赖PageCacheVirtualFile,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_keysMaxGetVectoredKeys单次get_vectored调用允许的最大键数,超限返回GetVectoredError::Oversized
get_vectored_concurrent_ioGetVectoredConcurrentIo读路径并发模型:SequentialSidecarTask,决定IoConcurrency的构造
max_vectored_read_bytesMaxVectoredReadBytes批量读的字节上限(如 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_serviceget_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_implValuesReconstructStateIoConcurrency已就位,而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_keysget_vectored_concurrent_io)和直方图指标(pageserver_get_vectored_seconds)融入生产运维体系。若要深入源码,建议从 timeline.rs 的get/get_vectored/scan三连读起,再对照 storage_layer.rs 的ValuesReconstructStateIoConcurrency,即可还原 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),仅供参考

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

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

立即咨询