ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Neon 持久性与共识:WAL Safekeeper 仲裁协议技术解析

Neon 持久性与共识:WAL Safekeeper 仲裁协议技术解析 Neon 持久性与共识WAL Safekeeper 仲裁协议技术解析【免费下载链接】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本文基于仓库 docs/rfcs/004-durability.md 展开并结合 safekeeper 服务safekeeper/src、WAL proposer 实现pgxn/neon/walproposer.c以及 docs/safekeeper-protocol.md 进行源码级印证深入剖析 Neon 如何定义WAL 记录已持久化、如何通过 WAL safekeeper 仲裁实现故障下的不丢事务以及 Epoch/Term 共识状态机与主节点选举的内部机制。Neon 将 PostgreSQL 的存储与计算分离后计算节点compute node / primary本身不再持有数据的持久副本而是把生成的 WAL 交给一组专门的节点保管。这组节点就是WAL safekeeper。本文聚焦一个核心问题当一条事务提交时WAL 记录写到什么程度才算持久可以安全地给客户端返回 COMMIT它直接决定了 Neon 的持久性durability与故障恢复能力。1. 持久性的定义Quorum 而非单机落盘在传统 PostgreSQL 中事务提交的持久性由WAL 写入并 fsync 到本机磁盘保证。在 Neon 中计算节点不落盘数据因此该语义被改写为一条 WAL 记录被认为是持久的当且仅当它已被写入到 WAL safekeeper 节点组的多数派majority / quorum节点并落盘成功。RFC 原文以 5 个 safekeeper 为例I use 5 safekeepers, because I have five fingers当至少 3 个 safekeeper 将这条 WAL 写入磁盘后该记录即为持久commit 可以向客户端确认。quorum 大小约定为N/2 1这与 Paxos/Raft 的多数派语义一致——任意两个 quorum 必然相交从而保证已提交的记录在任何后续选举中都不会丢失。这一设计可以在 docs/safekeeper-protocol.md 的 General requirements and architecture 一节找到对应实现描述WAL proposer 进程运行在 PostgreSQL 服务器内通过标准的流复制streaming replication接收 PostgreSQL 生成的 WAL再广播给各 safekeepermaster 侧采用同步复制语义只有 WAL 中 commit 记录的 LSN 被 safekeeper quorum 确认后才对客户端返回提交成功。这也解释了为什么 safekeeper 协议中把 commit 应答交给独立的get_commit_lsn()逻辑处理见该文档的 Algorithm 伪代码。1.1 整体架构图RFC 中给出的架构如下Primary计算节点把 WAL 同时推给一组 WAL safekeeper同时 WAL 也会被归档到 S3。---------------- ----- | WAL safekeeper | | ---------------- | ---------------- ----- | WAL safekeeper | ------------ | ---------------- | Primary | | ---------------- | Processing | -------------- | WAL safekeeper | | Node | | ---------------- ------------ | ---------------- \ ----- | WAL safekeeper | \ | ---------------- \ | ---------------- \ ----- | WAL safekeeper | \ ---------------- \ \ \ -------- \ | | ------ | S3 | | | --------从这个图中可以看到两个关键事实Primary 是全连接扇出fan-out它把生成的 WAL 发给所有可到达的 safekeeper。S3 承担长期归档WAL 一旦归档到 S3就可以从 safekeeper 本地删除因此 safekeeper 不需要巨大的磁盘空间。1.2 WAL 的三个区段每个 WAL safekeeper 持有一段连续的 WAL并维护一个 VCL 值。RFC 将 safekeeper 上的 WAL 划分为三个区段VCL LSN | | V V .................ccccccccccccccccccccXXXXXXXXXXXXXXXXXXXXXXX Archived WAL Completed WAL In-flight WALArchived WAL已被归档到 S3 的 WAL。它仍然可以从 safekeeper 本地删除。Completed WAL已被至少 3 个quorumsafekeeper写入磁盘的 WAL。算法保证只要同一时刻故障节点不超过 2 个这段 WAL 不会丢失。In-flight WAL已持久化在某个 safekeeper 上但如果发生崩溃它仍可能被覆盖或截断truncate。RFC 特别强调了一个与 Aurora 的差异safekeeper 上保存的 WAL 是一段连续的区间不允许出现空洞。Aurora 的 WAL 允许空洞并依靠 Gossip 协议回填Neon 选择保持简单要求 WAL 按顺序写入 safekeeper但在崩溃恢复期间已存储的 In-flight WAL 可以被截断或覆盖。这一点在源码中有直接印证safekeeper/src/recovery.rs 中proposer_elected相关流程通过计算历史共同点find_highest_common_point后执行truncate WAL locally并明确注释it is expected to get refusing to overwrite correct WAL here if walproposer reconnected concurrently如果 walproposer 并发重连会拒绝覆盖正确的 WAL。1.3 VCLVolume Complete LSNVCLVolume Complete LSN是本文最核心的概念之一。RFC 对 VCL 的定义是VCL 是在 Primary计算节点上确定的一个点。所有早于 VCL 的 WAL 记录都已被 quorum 持久化可以安全地用于恢复。VCL 之后In-flight 区段的记录在崩溃时可能被覆盖。docs/glossary.md 给出了更精确的表述VCL: the largest LSN for which we can guarantee availability of all prior records可以保证其之前所有记录都可用的最大 LSN。与之配套的还有另外两个 LSN 概念CommitLSN被 quorum safekeeper 确认的 WAL 位置即 RFC 中 VCL 的运行时对应物。RestartLSN被所有safekeeper 确认的 WAL 位置也就是消息队列队头的 LSN可作为 WAL 段删除的截止线。FlushLSNsafekeeper 已写入本地持久存储的 WAL 位置。RFC 也指出VCL 不一定要物理存储在 safekeeper 上但它允许做一些优化和健全性检查sanity checks对系统整体是有用的safekeeper 上存储的 VCL 值可以滞后于 Primary 计算出的 VCL。在 docs/safekeeper-protocol.md 的 Algorithm 伪代码中可以看到commitLsn和restartLsn会随每个请求发给 safekeeper 并写入其 control file为避免额外的 fsync 开销该 control file 并非每次请求都 fsync而是周期性刷新因此 safekeeper 上保存的restartLSN/commitLSN可能略有滞后——这不会影响正确性只会导致某些 WAL 记录被冗余处理。从源码看这一设计已被完整实现在 safekeeper/src/state.rs 的TimelinePersistentState结构中持久化的状态包含commit_lsnPart of WAL acknowledged by quorum and available locally、peer_horizon_lsn对应 walproposer 协议中的truncate_lsn即所有节点都收到的最小 LSN、remote_consistent_lsnpageserver 已 checkpoint 并推送到 S3 的 LSN等字段。2. 正常操作Primary 如何推进 VCLRFC 给出 Primary 正常运行时的操作流程4 步生成 WALPostgreSQL 产生新的 WAL 记录。发送 WAL把 WAL 发送给所有能到达的 safekeeper。等待 quorum 确认一旦 quorum 的 safekeeper 确认已收到并持久化到该 LSN就在内存中更新本地 VCL 值并向客户端确认提交。广播新 VCL可选把新的 VCL 发送给参与 quorum 的那些 safekeeper。在 docs/safekeeper-protocol.md 的 Main loop 一节中这一流程的工程化实现是walproposer 从 PostgreSQL 接收 WAL 流把消息追加到内存消息队列每个 safekeeper 关联队列中的一个元素一旦它确认收到某条消息对应位置就前移每个队列元素维护一个确认掩码acknowledgment mask每个 bit 对应一个 safekeeper当所有safekeeper 都确认某条消息后该元素出队restartLSN前移commitLSN取各 safekeeperflushLSN排序数组中的flushLSN[nSafekeepers - Quorum]元素——这正是第 quorum 大的 flushLSN即多数派确认位置。Algorithm 伪代码中的get_commit_lsn()印证了这一点function get_commit_lsn() sorted_feedbacks feedbacks.sort() return sorted_feedbacks[safekeepers.size() - quorum] end function在 C 实现 pgxn/neon/walproposer.c 中walproposer 每收到一个VoteResponse就会记录 safekeeper 的flushLsn并据以推进本地 commit 点commit LSN 的推进由选举完成后VotesCollected的compute_commit_lsn相关逻辑驱动最终通过 shmem 反馈给 PostgreSQL 后端后端才允许返回 commit 成功。一个容易混淆的点RFC 中的 VCL 与协议文档中的 CommitLSN 基本对应多数派确认点而 RestartLSN 则是全量确认点。RFC 撰写于早期设计阶段术语在后续实现中有所演化阅读源码时以 docs/glossary.md 的术语表为准。3. 崩溃恢复新 Primary 如何接管RFC 描述崩溃恢复流程时假设同一时刻只能有一个 Primary 在运行该保证由Magic STONITH Fairy即外部选主机制提供详见第 5 节。新 Primary 启动后、生成任何新 WAL 之前必须先联系 safekeeper 的多数派来计算 VCL。RFC 给出的恢复流程3 步联系所有 WAL safekeeper在能到达的节点中找出Max((Epoch, LSN))元组。拥有该元组的 safekeeper 是Winner它的 LSN 成为新的 VCL。回填落后节点从每个 safekeeper 的旧 VCL 点开始从 Winner 拷贝 WAL 到其他能到达的 safekeeper上一 Epoch 遗留的所有 In-Flight WAL 被截断。递增 Epoch 并广播将新的 Epoch 发送给 quorum 的 safekeeper。这样保证如果之前联系不到的 safekeeper 之后重新上线它在任何未来的恢复中都会被判定为更旧。完成后新 Primary 才能从新计算的 VCL 开始生成新的 WAL。3.1 为什么选Max((Epoch, LSN))而不是Max(LSN)这是整个协议的精髓所在。仅凭 LSN 比较会引入两个问题离线节点的 WAL 可能超前某个离线 safekeeper 上可能有 LSN 更高的 In-flight WAL但那些记录从未被 quorum 确认不能作为新 VCL。老节点可能落后但 Epoch 相同见 docs/safekeeper-protocol.md Recovery 一节的例子S3(1): R1(a),R2(b),R3(c),R4(d) - offlineS3 持有 LSN4 但 Epoch1quorum (S1,S2) 的 Epoch2、LSN3。此时必须比较(Epoch, FlushLSN)二元组S3 虽然 LSN 更大但 Epoch 更小所以 VCL 取 Epoch2 一方的 3S3 上 R3(c)、R4(d) 会被覆盖为 R3(e)、R4(f)其 Epoch 随后被调整。该文档给出的完整推演含两次崩溃场景展示了 Epoch 在回填与覆盖中的动态变化。协议文档中的对应实现是Proposer 计算max(FlushLSN)时先比较 Epoch实际比较的是(Epoch, FlushLSN)对。此外We need to choose max(flushLSN) because voting quorum may be different from quorum committed the last message——投票 quorum 可能与提交最后一条消息的 quorum 不同因此无法确定 max(flushLSN) 处的记录是否已被 quorum 提交必须将其视为已提交以避免丢失已提交数据。在 docs/safekeeper-protocol.md 的 Algorithm 伪代码中整个流程被具体化为next_term max(safekeepers.state.nodeId.term)1 restart_lsn max(safekeepers.state.restartLsn) epoch,VCL max(safekeepers.state.epoch,safekeepers.state.flushLsn) curr_epoch epoch 1 proposal Proposal(NodeId(next_term,server.id),curr_epoch,VCL) safekeepers.send(proposal) responses safekeepers.read() if any responses.is_rejected() exit() if restart_lsn ! VCL do_recovery(epoch,restart_lsn,VCL)do_recovery从拥有(epoch, VCL)的 leader safekeeper 建立复制流下载restart_lsn..VCL之间的 WAL按各节点的flushLsn定位补发点——这正好对应 RFC 中从旧 VCL 点开始从 Winner 拷贝的步骤。3.2 源码印证recovery 中的 last common point在真实实现中回填并不简单地从旧 VCL开始而是基于TermHistory 的最后一个共同点last common point。见 safekeeper/src/safekeeper.rs 的find_highest_common_point它比较 proposer 与 safekeeper 两边的 term 历史找到最后一个 term 相同的分界点取两侧边界 LSN 的较小值作为共同点safekeeper/src/recovery.rs 随后基于该共同点发送ProposerElected消息执行本地 WAL 截断再进行recovery_stream从 donor 拉取缺失的 WAL。若找不到共同点则直接报错couldnt find common point in histories并中止恢复——这正是保证能从一个共同的历史点重建的正确性底线。4. 优化降低网络与磁盘负载RFC 在 Optimizations 一节提出了两个方向链式转发daisy-chainPrimary 不必向所有 safekeeper 直接发送 WAL。部分 safekeeper 可以从其他 safekeeper 串联daisy-chained接收 WAL或节点间采用广播机制以降低 Primary 的网络流量。但每个 safekeeper 到 Primary 的确认acknowledgment路径仍需保留直连否则无法完成 quorum 确认的闭环。归档委托把 WAL 归档到 S3 的职责可以委托给某个 safekeeper减轻 Primary 的负担。从当前仓库的实现看S3 归档能力已经成熟safekeeper 目录下有 safekeeper/src/wal_backup.rs、safekeeper/src/wal_backup_partial.rs部分段上传与清理、safekeeper/src/pull_timeline.rs从远端拉取 WAL 用于恢复safekeeper/src/remove_wal.rs 负责在归档完成后删除本地 WAL从而落实 RFC 中safekeepers 不需要大磁盘的设计目标。而链式转发方案在 RFC 中作为未来优化方向提出当前实现仍以 Primary 全连接扇出为主。5. Magic STONITH Fairy如何保证单主RFC 指出上述持久性协议正确性的前提是同一时刻只有一个 Primary并戏称这个保证来自一只神秘的Magic STONITH FairySTONITH Shoot The Other Node In The Head即把另一个节点干掉的经典分布式系统术语。RFC 给出了三种实现方案方案机制优缺点etcd 租约用 etcd 在 key 上授予租约Primary 只有在持有有效租约时才允许运行Primary 死亡后租约超时过期新节点获准成为 Primary需要引入新的架构组件etcdS3 存储租约用 S3 存储租约。S3 的一致性保证较宽松理论上不能安全实现但实践上把租约时间和超时设得足够长通常是可行的优点是不需要引入新组件缺点是依赖宽松一致性的存储做互斥属于工程上的实用主义取舍Raft / Paxos 内建由 WAL safekeepers 充当 Acceptor 组成 quorum把选主与共识合二为一这是 RFC 推荐并单独用一章论述的方案见下节etcd/S3 方案本质上是外部 STONITH租约机制保证旧主在超时后无法继续写入新主才能接管。而第三种方案把选主内建到 safekeeper 共识协议中Neon 最终选择了这条路线Built-in Paxos并在后续演进为基于 generation number 的租约机制相关背景可参见 docs/rfcs/025-generation-numbers.md。6. Built-in Paxos用 safekeeper 做 AcceptorRFC 提出的内建共识方案把经典 Paxos 的角色映射到 Neon 组件上WAL safekeeper Paxos Acceptor接受者Processing node计算节点 Proposer提议者 Learner学习者每个 safekeeper 在 VCL 和 WAL 之外额外持有一个Epoch世代值。Primary 每次请求 safekeeper 保存 WAL 时都必须携带 Epoch如果 safekeeper 收到与当前已接受的 Epoch 不匹配的请求就忽略或 NACK 它。RFC 注明在不同 Paxos 论文中Epoch 被称为 terms 或 round numbers——Neon 最终采用了term这个命名见下文。RFC 描述的新主启动流程Paxos 两阶段联系所有能到达的 safekeeper若无法连到 quorum 则立即放弃找出其中最新的 Epoch。生成一个全局唯一且大于已见最大 Epoch的新 Epoch。向 quorum 的 safekeeper 发送携带新 Epoch 的Prepare消息PAXOS Prepare。每个 safekeeper 返回Promise。若某 safekeeper 已对更高 Epoch 做过 Promise则不应答或返回 NACK做出 Promise 后该 safekeeper 停止响应任何更低 Epoch 的写请求。收到多数派 Promise 后即可确定旧 Epoch 上的 VCL 无法再前进——这等效于杀死任何旧 Primary。在 quorum 的 safekeeper 中找出最高已写 LSN可包含在 Promise 消息中作为新 VCL。此后任何新节点发起选举都会计算出相同或更高的 VCL。从 LSN 最高的 safekeeper 拷贝 WAL 到 quorum 内其他节点使用新 Epoch 写入PAXOS Accept 阶段。此后即可从 VCL 开始生成新 WAL。若另一进程在此之后发起选举并掌控了多数派本节点将无法再推进 VCL。这组步骤与经典 Paxos 的 Prepare/Promise/Accept 一一对应其安全性核心在于任何两个多数派必然相交因此 Prepare 阶段必然能发现旧世代已接受的最高记录从而保证已提交数据不丢失。6.1 从 Epoch 到 Term 的实现演进RFC 中的 Epoch 在真实代码中演化为term并且引入了比每次选举递增更细致的term history机制。证据如下safekeeper/src/safekeeper.rs 定义了TermLsn { term: Term, lsn: Lsn }与TermHistory(pub VecTermLsn)后者保存该 safekeeper WAL 上的世代切换历史同一个文件中的AcceptorState持久化状态只含两个字段termacceptors last term it voted foracceptor 最近投票支持的 term和term_historypgxn/neon/walproposer.c 中walproposer 通过greetResponse.term汇总各 safekeeper 的最高 term取Max后作为自己的propTerm发起投票voteRequest.term wp-propTerm若某 safekeeper 返回更高的 term则视为 Another compute with higher term is running直接以 FATAL 退出——这就是旧主被杀死的运行时实现TermHistory还提供find_highest_common_pointsafekeeper/src/safekeeper.rs用于在恢复时找到新旧世代 WAL 的共同前缀与 RFC 第 3 节从旧 VCL 点开始回填的语义对应。6.2 TermHistory 推演示例pgxn/neon/walproposer.c 中wp-propTerm Max(sk-greetResponse.term, wp-propTerm)以及投票被拒时的 FATAL 处理正是 RFC Built-in Paxos 第 3、4 步的工程化表达选举阶段新主先用一个更高的 term 发起 Vote 请求Prepare多数派返回 VoteResponsePromise并附上各自的termHistory与flushLsn接受阶段新主从 term 最高、LSN 最新的节点donor拉取 WAL以新 term 写入其他节点提交推进commit 点VCL仅在收到多数派的新 term 确认后前进。协议文档 docs/safekeeper-protocol.md 的 safekeeper 端伪代码还揭示了一个细节safekeeper 收到新 epoch 后不会立即切换而是等待收到 LSN max(flushLSN, VCL) 的记录时才切换世代if state.epoch state.proposed_epoch and req.endPos max(state.flushLsn,state.VCL): state.epoch state.proposed_epoch。其含义是必须先恢复完旧世代的所有记录、再切到新世代避免在回填完成前就丢弃旧世代数据。这一延迟切换正是 RFC 中上一 Epoch 的 In-flight WAL 被截断的精确时机控制。7. 从 RFC 到产品当前仓库中的一致性现状RFC 004 写于项目早期设计阶段但它的核心决策全部沉淀到了当前代码中多数派持久化safekeeper 的TimelinePersistentState.commit_lsn字段safekeeper/src/state.rs持久化被 quorum 确认且本地可用的 WAL 位置Term/TermHistoryAcceptorStatesafekeeper/src/safekeeper.rs持久化 acceptor 的 term 与切换历史恢复与截断proposer_elected流程通过 last common point 截断本地 WALsafekeeper/src/recovery.rs单主保证从外部租约演化为基于 term/generation 的内建共识相关演进可阅读 docs/rfcs/025-generation-numbers.md 与 docs/rfcs/013-term-history.md协议细节docs/safekeeper-protocol.md 给出了握手handshake、投票、恢复、主循环的完整流程与 Python 伪代码是理解实现的最佳补充材料。对于想深入了解的读者建议按以下顺序阅读仓库资料docs/rfcs/004-durability.md —— 本文对应的设计原点docs/safekeeper-protocol.md —— 协议的工程化定义与推演示例docs/glossary.md —— VCL/CommitLSN/RestartLSN/FlushLSN 等术语的统一释义safekeeper/src/safekeeper.rs 与 safekeeper/src/state.rs —— 持久化状态机实现safekeeper/src/recovery.rs —— 崩溃恢复与 WAL 回填实现pgxn/neon/walproposer.c —— 运行在 PostgreSQL 内的 WAL proposer 实现。小结Neon 的持久性模型可以浓缩为一句话WAL 写入多数派 safekeeper 即视为持久VCL 标记多数派确认点termEpoch标记世代任何新主都必须通过多数派 Prepare 拿回最高世代的数据后才能继续写。这套设计让 Neon 能够在计算无状态、存储分离的架构下依然向用户提供与 PostgreSQL 等价的提交持久性承诺——即便同时有两台 safekeeper 故障已确认提交的事务也绝不会丢失。本文全部架构判断基于仓库内 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),仅供参考
RELATED READING

延伸阅读

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