ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Neon 静态键空间分片(Sharding Phase 1)架构解析:从 RFC 设计到 pageserver 多分片实现

Neon 静态键空间分片(Sharding Phase 1)架构解析:从 RFC 设计到 pageserver 多分片实现 Neon 静态键空间分片Sharding Phase 1架构解析从 RFC 设计到 pageserver 多分片实现【免费下载链接】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本文深入解读 NeonServerless Postgres存储层横向扩展的基石设计——静态键空间分片 RFC。该设计通过把单个 Tenant 的键空间按宽条带 伪随机哈希切分到多个 pageserver 分片Shard使数据库容量可以突破单台 pageserver 本地磁盘上限例如 16TiB 级同时把写入、压缩与 I/O 带宽成本分摊到多台节点。读完本文你将掌握 Neon 中 Key→Shard 的映射算法、ShardIdentity等核心类型、pageserver 存储格式与 WAL 摄取的分片化改造以及后续分裂split等演进方向的完整脉络。一、背景与动机为什么单 Tenant 必须分片在 Neon 的架构中一个 Tenant控制平面中对应一个 Project的全部数据、连同其所有 timeline当前都存放在同一台 pageserver上。由于 LSM 结构的写入放大效应本地实际占用的存储可能是数据库真实大小的数倍RFC 原文表述为 several times larger。当数据库超过单台 pageserver 磁盘可承载的容量时pageserver 将无法在本地保存数据——而本地存储是它向客户端提供服务的必要条件。RFC 为此设定的核心需求如下大库能力允许创建大容量数据库示例为 16TiB且不需要为其配备专用的 pageserver 节点带宽/容量共享将大型数据库的读写带宽与存储容量成本分摊到多台 pageserver避免大库成为干扰其他租户服务的 I/O 热点稀疏键适配数据分布方案必须能良好处理稀疏/非均匀的键——Postgres 并不会按一个连续页号区间写数据。RFC 同时声明了大数据库的定义边界下界是用户在当代企业级 SSD 上能创建的数据库也应能在 Neon 上良好运行上界是Postgres 本身能处理的极限即不能让 pageserver 后端成为数据库规模的瓶颈。非目标Non Goals不在同一 Tenant 内独立分发 timeline若一个 Tenant 有很多 timeline将 timeline 分散到不同 pageserver 比键空间分片更高效不在 LSN 维度分发工作本 RFC 只关注 Key 维度作者认为两个维度应分别设计各自的分片机制。先例与内部探索RFC 提到 Neon 内部此前已有若干相关探索Layer File Spreading、Key Space Partitioning 等 One-Pager均为仓库外部文档在其他分布式系统里水平扩展存储系统几乎都有类似的分片设计。这里的借鉴意义是分片本身并非新概念难点在于如何贴合 Neon 的版本化键值存储 Postgres 页模型这一具体场景做取舍。二、术语Key 与 LSN 两个维度RFC 定义了本设计的关键术语Key一个由 relation 限定的 Postgres 页号。在 pageserver 作为版本化键值存储的意义上页号就是该存储中的键且Key是现有代码中的字面数据类型Rust 结构体LSN 维度指 LSN日志序号即历史版本的范围。谈论键 LSN 的二维空间时LSN 维度就是其中的历史维度。涉及组件pageserver、control plane、postgres/smgr即 compute 端存储管理器。三、核心取舍Key 分片 vs LSN 分片RFC 明确指出在键/LSN 二维空间中两个维度的分片性质完全不同维度分发的工作协调难度Key 空间写入工作负载数据摄取 WAL ingest 与压缩 compaction高必须保证某个键恰好由一个节点拥有LSN 空间历史读工作负载低只要能看到远程 index 与 layer任何节点无需特殊协调即可服务Key 分片是实现大容量数据库的更困难且更迫切的部分因此本阶段只做 Key 分片历史读的分发被推迟到未来阶段RFC 预想了一种简单的 P2P 卸载模型空间不足的节点可以呼叫对等节点下载并服务某段历史 layer 的读请求。四、Key → Shard 映射方案宽条带 伪随机哈希4.1 两个空间与宽条带策略RFC 定义两个空间Key space无符号整数Shard space0 到 N-1 的整数N 为分片总数。映射采用宽条带wide striping策略以在数据局部性与避免整个大 relation 落进同一分片之间取得折中。stripe 是连续的页区间同一 relation 的多个 block 会分布到许多条带上因而散布到多个分片但相邻 block 通常会落在同一条带、从而落在同一分片。4.2 Key 的现有定义pageserver 的getpagelsn接口中Key定义为pub struct Key { pub field1: u8, pub field2: u32, pub field3: u32, pub field4: u32, pub field5: u8, pub field6: u32, } fn rel_block_to_key(rel: RelTag, blknum: BlockNumber) - Key { Key { field1: 0x00, field2: rel.spcnode, field3: rel.dbnode, field4: rel.relnode, field5: rel.forknum, field6: blknum, } }实际的 Key 结构体 与 RFC 一致。relation 元数据键被忽略——这类数据会镜像到所有分片分片只关心用户数据键。4.3 映射算法与设计动机映射目标属性有三条blknum局部性相邻blknum通常映射到同一条带、同一分片避免跨 relation 混叠不同 relation 的相同blknum不应落到同一条带防止大量小 relation 把数据挤到相同的 stripe/分片抗 relation 标识字段的模式化若relnode取值有规律不能让它显现为数据放置的规律。算法RFC 定义对Key的field 4relNode做哈希将field 6blknum除以条带大小页数对商做哈希并与上一步哈希合并合并结果对分片总数取模即得到持有该键的分片。为什么不使用 Key 中的其他字段忽略forknum它区分同一 relation 中不同类别的数据我们希望同一 relation 的数据待在一起不用spcNode和dbNode也不能用Postgres 建库操作可以以现有数据库为模板新建库的块与源库仅差spcNode/dbNode。为了让这类创建操作无需跨 pageserver 通信必须保证这些块映射到同一分片——做法就是把这两个字段排除在哈希之外。4.4 源码级印证Rust 端与 Postgres 端的一致性仓库中 libs/pageserver_api/src/shard.rs 实现了key_to_shard_number注释明确要求与 postgres smgr 代码中的哈希完全一致pub fn key_to_shard_number( count: ShardCount, stripe_size: ShardStripeSize, key: Key, ) - ShardNumber { // Fast path for un-sharded tenants or broadcast keys if count ShardCount(2) || key_is_shard0(key) { return ShardNumber(0); } // relNode let mut hash murmurhash32(key.field4); // blockNum/stripe size hash hash_combine(hash, murmurhash32(key.field6 / stripe_size.0)); ShardNumber((hash % count.0 as u32) as u8) }其中的murmurhash32与hash_combineshard.rs注释明确标注提供与 Postgreshashfn.h中同名函数完全相同的结果——这是页面由哪个分片服务在 pageserver 与 compute 两端必须严格一致的根本原因。compute 端Postgres 扩展在 pgxn/neon/libpagestore.c 中实现了对应的get_shard_number(BufferTag *)每次页面请求都按相同公式选分片shardno_t get_shard_number(BufferTag *tag) { shardno_t n_shards; size_t stripe_size; uint32 hash; load_shard_map(0, NULL, n_shards, stripe_size); #if PG_MAJORVERSION_NUM 16 hash murmurhash32(tag-rnode.relNode); hash hash_combine(hash, murmurhash32(tag-blockNum / stripe_size)); #else hash murmurhash32(tag-relNumber); hash hash_combine(hash, murmurhash32(tag-blockNum / stripe_size)); #endif return hash % n_shards; }另外注意key_is_shard0shard.rs实现了一条补充规则只有普通 relation 页被分发到 0 号以外的分片其余键SLRU、aux 文件、initfork 等一律落在分片 0。这保证了分片 0 可以独立服务 basebackup 请求而不需要与其他分片通信。这与 RFC 正文relation 元数据镜像到所有分片的说法在实现层面被细化为部分特殊键固定在分片 0。五、数据放置效果示例与统计保证RFC 给出了 8 分片、32k 页条带的极端大库示例单个大 relationblknum除法把数据切成 4096 条带条带被伪随机地散布到各分片4096 个各 32k 页的 relation每个 relation 恰映射到一条带该条带按 field 4 哈希放置整体在分片间统计均匀。小数据量时放置会明显不均匀2 分片 2 个各一条带的 relation有50% 概率两个 relation 落进同一分片另一分片无数据8 分片 1 个 12 条带的 relation其中 4 个分片的数据量是另外 4 个的两倍。这些不均匀无伤大雅只要条带大小比单个分片乐意承载的数据量小一个数量级如果系统能从容处理 10–100GB 的分片那么一个租户内 256MB 与 512MB 分片并存完全可以接受。映射方案提供的是统计性保证随着租户总数据量增长放置均匀度会持续改善。六、重要类型ShardIdentity与配套分片类型6.1ShardIdentityRFC 定义ShardIdentity提供某个键是否属于本分片所需的全部信息Layout version布局版本Stripe size条带大小Shard count分片总数Shard index分片序号其尺寸恒定。如果改用给每个分片显式分配哈希区间的一致性哈希方案ShardIdentity会随分片数增长本方案采用简单的取模映射因此能保持固定小尺寸。仓库中的实现见 libs/pageserver_api/src/shard.rspub struct ShardIdentity { pub number: ShardNumber, pub count: ShardCount, pub stripe_size: ShardStripeSize, layout: ShardLayout, }ShardLayout目前恒为 1LAYOUT_V1为未来引入新的数据分布方案预留shard.rsShardIdentity::new会校验count ! 0、number count、stripe_size ! 0shard.rs默认条带大小DEFAULT_STRIPE_SIZE为2048 页16MiB ÷ 8KiB注释说明这是写入负载分布与 I/O 摊销之间的折中shard.rs。ShardIdentity提供三组关键判定方法shard.rsis_key_local某键是否只存储在本分片含key_is_shard0的分片 0 特例分片必须摄取至少返回 true 的键is_key_global某键是否应存储在所有分片relation 大小键 rel_size 等SLRU/aux 类特殊键仅在分片 0is_key_disposable某键在本分片是否可丢弃用于分裂后压缩阶段清理非强制。6.2 分片 ID 家族ShardNumber/ShardCount/ShardIndex/TenantShardIdlibs/utils/src/shard.rs 定义了整套 ID 类型ShardNumber分片在租户内的零基序号ShardCount租户的分片总数含一个魔数 0 表示未分片的传统租户count()返回实际分片数内部值为 0 时返回 1ShardIndex(ShardNumber, ShardCount)二元组用于 layer 文件等隐式限定在租户内的场景TenantShardId全局唯一标识(tenant_id, shard_number, shard_count)格式为TenantId-shard_slug例如072f1291a5310026820b2fe4b2968934-0102未分片租户不加后缀直接等同于TenantId因此与旧格式前向/后向兼容。ShardSlug采用定长十六进制{:02x}{:02x}编码先分片号后分片数如 4 分片租户的各分片 slug 为0004、0104、0204、0304。ShardIndex::get_suffix()在未分片时返回空串从而完整保留分片前的远程存储键格式shard.rs。七、Pageserver 变更7.1 结构性变更Tenant 全面改为 TenantShardRFC 规定pageserver 中凡是处理 Tenant 的地方都改为处理TenantShard——即一个 Tenant 一个ShardIdentity后者指明该节点拥有键空间的哪一部分。未分片租户只是ShardIdentity覆盖整个键空间的TenantShard。此外layer 与index_part.json写入远程存储时名称中必须包含分片序号与分片总数总数用于前瞻性兼容它将来会随时间变化这些键同样携带 generation 号generation numbers 机制对TenantShard与对 Tenant 的工作方式完全相同每个分片拥有自己的 generation。7.2 存储格式Keys稀疏 layer 与路径前缀对分片数 1 的租户layer 文件隐式变为稀疏的在 layer 名称描述的键范围内某个分片的 layer 文件只存放映射到该分片的条带内容。因此租户内LayerFileName不再唯一——不同分片可用相同的 layer 名称指代不同数据。解法是把分片号编入 layer 使用的键pageserver/v1/tenants/tenant_id-shard_numbershard_count/timelines/timeline id/layer file name-generation pageserver/v1/tenants/tenant_id-shard_numbershard_count/timelines/timeline id/index_part.json-generation设计理由前缀式便于实现不必到处携带分片 ID 构造 layer 文件名也便于按shard-timeline前缀高效列出index_part同时包含分片数与总数将来实现分片分裂时父分片与子分片写同名 layer 不会碰撞——例如父分片0_1分裂为(0_2, 1_2)的过程中0_2写入的 layer/index_part 与0_1在同一位置写出的内容互不相同。实现中分片数预期较小u8足够因此路径中的分片段是定长十六进制如{:02X}{:02X}——单分片租户前缀为0001。向后兼容可定义shard_count 0的特殊ShardIdentity作为路径完全不带前缀的标记。仓库中ShardCount(0)正是这个unsharded/legacy魔数utils/src/shard.rsShardSlug的 Display 实现在未分片时输出空字符串utils/src/shard.rs。7.3 存储格式IndicesIndexPart携带分片元数据Phase 1 中分片只引用自己写入的layer。但为了给未来的分裂铺路分裂时子分片需要引用父分片写入的 layer避免穷举复制全部数据到自己的前缀键下RFC 规定扩展IndexPart结构为每个 layer 记录(shard number, shard count)二元组使其能构造其他分片写入的 layer的路径。这自然引出祖先分片写出的 layer 归谁所有的问题留待 Phase 2 解决。向后兼容任何不带分片信息的 index 条目都被假定属于 legacy shard identity。仓库中 layer 元数据确实持久化了分片信息Layer内部持有shard: ShardIndex字段且注释说明——对于分裂后加载的 layer该值可能是当初写入时的其他分片值pageserver/src/tenant/storage_layer/layer.rs删除旧 layer 时也会跳过属于祖先分片的 layerpageserver/src/tenant/remote_timeline_client.rs。7.4 WAL Ingest全量订阅、按分片过滤Phase 1 中所有分片都向 safekeeper 订阅并下载全部 WAL然后过滤出与本分片相关的页普通用户数据写仅当匹配ShardIdentity时才保留描述 relation 等的元数据所有分片都保留。pageserver 必须向 safekeeper 反馈正确的remote_consistent_lsn。RFC 给出的方案之一由0 号分片周期性查看其他分片的IndexPart并只由 0 号分片填充remote_consistent_lsn但这较昂贵——若 safekeeper 能做成分片感知的则可教它用所有分片remote_consistent_lsn的max()来决定何时裁剪 WAL。仓库中的实现与 RFC 一致WAL 摄取通过ShardIdentity过滤批次pageserver/src/walingest.rs例如关系复制命令只在is_key_local(src_key)时执行由于 dbNode 不参与哈希src/dst 必然在同一分片见 walingest.rsVM/FSM 页面操作也只在该分片拥有对应页时执行walingest.rsrelation 大小键等全局键由分片 0 维护准确性walingest.rs。分片感知的 safekeeper 反馈也已落地pageserver 反馈协议中包含shard_number字段libs/utils/src/pageserver_feedback.rs供 safekeeper 区分不同分片的进度。7.5 Compaction/GC无需变更RFC 明确压缩与 GC 不需要任何特殊处理。pageserver 隐式地只处理映射到其ShardIdentity的键子集结果就是产生稀疏 layer 文件只含本分片拥有的条带。唯一需要注意的优化点现有压缩中识别键区间空隙的逻辑应忽略因分片造成的空隙避免把 layer 无谓地切碎成条带大小的小块。八、Compute Endpoints 变更Compute 端点需要在控制平面下发的配置中接受一组连接字符串向量按键哈希映射把 pageserver 请求路由到连接字符串向量中的正确条目。选择在 compute 内路由、而不是经由某个 pageserver 代理转发是为了在分片租户下不增加额外网络跳数带来的延迟。仓库证据compute 端点规格ComputedSpec中pageserver_connection_info携带shards: HashMapShardIndex, PageserverShardInfo并记录shard_count未分片为 0分片租户从 1 起见 libs/compute_api/src/spec.rs。Postgres 侧 pagestore 客户端按get_shard_number逐页选连接见上文 libpagestore.c并维护每分片的连接槽位与预取位图pgxn/neon/communicator.c。pageserver 侧 page_service 在收到请求后按ShardSelector解析本节点应服务的分片若发现客户端把 getpagelsn 请求路由到了错误分片会给出明确错误pageserver/src/page_service.rs。九、控制平面变更与选择性启用控制平面中TenantProject各自拥有一组TenantShard小租户即 1 个。分片放置逻辑与当前放置 Tenant 的逻辑完全相同。生命周期操作需要扇出到所有分片Tenant 删除需要扇出到租户内所有分片timeline 创建/删除同理——一个 timeline 必须等所有分片都创建完成后才算创建成功。初始阶段只对大型租户显式启用分片将来实现自动 re-sharding 后这个提示hint机制可以变成可选。实操入口本地测试环境neon_localCLI 的 tenant create 支持--shard-count与--shard-stripe-size单位为页参数control_plane/src/bin/neon_local.rstest 夹具create_tenant同样暴露shard_count/shard_stripe_sizetest_runner/fixtures/neon_fixtures.py。例如创建一个 4 分片、128 页条带约 1MiB的租户neon_local tenant create --tenant-id tid --shard-count 4 --shard-stripe-size 128十、未来阶段规划RFC 明确声明以下内容用于预示后续方向其中Phase 2a 与 2b 可并行推进。Phase 2aWAL fan-outWAL 扇出问题所有分片都消费整条 WAL 时safekeeper 到 pageserver 的 WAL 传输网络带宽会乘以分片数。带宽目前不是最紧迫的瓶颈但若在相当数量的租户上设置中等分片数约 8问题就会显现——被分片的大租户通常写入带宽也高于平均。Phase 2bShard Splitting分片分裂问题分片数在租户创建时确定且不可变这既导致大多数小租户过度分片又给超大租户设定了扩展上限。解法是引入分裂特性一个分片通过一次特殊压缩操作把自己的数据按子分片切分成多个 image layer并为每个子分片写出各自的index_part.json随后由控制平面做外部协调安全挂载这些子分片并迁移以均衡负载。反向的合并merging操作可以想象但大概率不会实现——租户一旦分片合并带来的边际效率收益难以抵消其实现风险与复杂度。该方向已由后续 RFC 032-shard-splitting.md 正式承接并已在代码与测试中落地TenantShardId::split(new_shard_count)基于取模映射计算子分片集合libs/utils/src/shard.rs测试覆盖了从 1→N 与 N→M 的各类分裂test_runner/regress/test_sharding.py。Phase N远期分布式历史读问题基于 Key 的分片擅长应对整体数据库规模变化却不适合历史 layer 读负载的尖峰/不可预测变化——突增的历史读可能导致某TenantShard本地磁盘容量需求暴涨。极端示例租户运行一年后以每月间隔创建带祖先的分支可能瞬间造成该分片磁盘占用 12 倍膨胀。应对思路若响应够快更细粒度的 key 分片可缓解但分裂成本高且历史读峰值可能转瞬即逝。更独立的机制可以是pageserver 之间用gossip 协议交流负载配合getpagelsn 卸载机制——一个 pageserver 请另一个从远程存储读取 layer 来服务读请求。由于是只读操作协调成本低任何节点都能服务任何读虽然某分片的所有读仍流经一个节点但磁盘容量与 I/O 影响被分散了。十一、FAQ 与备选方案为什么用条带stripe而不是给每个分片划分连续键空间区间数据库在写入负载下增长时写入可能主要命中键空间末尾造成该分片的带宽热点同理若用户密集重写某个 relation而该 relation 恰好落在一个分片里就无法达成把写入工作分布到多分片的目标。条带化让写入热点在统计上均匀扩散。为什么不通过一个 pageserver 代理读请求从而免改 compute 端RFC 给出两点无法扩展网络带宽繁忙的大库租户仍会在路由其读请求的 pageserver 上形成负载热点额外一跳增加延迟与资源成本CPU、网络带宽。备选方案Layer File Spreading单 owner 按 layer 摊派给对等节点该模型中不存在显式分片但租户所附着的 pageserver 不在本地持有全部 layer它呼叫对等节点存储某些 layer读请求时再呼叫这些对等节点。RFC 的评价是这个机制适合在 LSN 维度分发工作但在键空间维度有重大局限——它要求单一节点处理所有传入写入与压缩。即便大库的写入负载能塞进一台 pageserver它仍是热点此类租户实际上仍需要自己的 pageserver。十二、测试与验证分片行为的仓库证据分片功能在测试套件中有完整覆盖核心文件是 test_runner/regress/test_sharding.py。test_sharding_smoke第 38-97 行验证了分片租户的完整生命周期4 台 pageserver、shard_count4、stripe_size1281MiB 条带便于小数据量下产生有意义的分布initdb 导入的数据被均匀分散没有任何分片超过总量的一半physical_initdb_total约 20MiB分片租户上的 branchtimeline创建、写入负载、删除均正常工作使用 S3 兼容远程存储验证 scrubber 能正确处理分片租户。其余测试覆盖了未分片租户的分裂、分裂后的压缩、分片卸载offload、1→N 与 N→M 分裂、分裂时 stripe size 的变更等场景test_sharding.py。key_to_shard_number与ShardIdentity也内置了单元测试包括 10 分片/32768 页条带的映射、split 子分片计算、非法参数拒绝等libs/pageserver_api/src/shard.rs。结语静态键空间分片是 Neon 存储层从单机容量走向水平扩展的第一步它以宽条带 伪随机哈希换取统计均匀的数据放置用固定大小的ShardIdentity保持映射的简单与廉价通过全量 WAL 按分片过滤让摄取逻辑保持简单并把更复杂的 WAL 扇出、分片分裂与分布式历史读留给后续阶段。理解这份 RFC也就理解了 Neon 如何在不改变 Postgres 计算模型的前提下让单个数据库跨越单台 pageserver 的物理边界。【免费下载链接】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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进