资讯详情

资讯详情

CRAQ一致性协议:链式复制如何实现强一致读扩展

分布式系统里的数据一致性向来是个“既要又要还要”的难题。如果你维护过存储、缓存、元数据这类服务大概率体会过这种纠结用强一致方案读性能上不去放开一致性业务方又天天投诉数据对不上。今天想聊的CRAQ一致性协议就是专门解决“强一致读扩展”这个矛盾的一类方案。CRAQ全称是Chain Replication with Apparent Query翻译过来是“带查询优化能力的链式复制”它用一种比共识协议简单得多的思路做到了强一致同时还能让读请求在所有副本节点上水平扩展。适合正在做分布式存储、文件系统、配置中心或者对Raft一类协议的性能开销不满、想换种思路的工程师参考。我会从复制协议的基本问题讲起再拆CRAQ的读写流程、实现细节最后给一份能直接落地的实操经验。1. 复制协议到底在解决什么问题1.1 分布式存储里“复制”的本质任何复制协议归根到底都在做一件事让多个副本上的数据最终保持一致同时对外提供一个明确的一致性语义。所谓一致性最简单的定义就是“所有客户端无论从哪个副本读都能读到同一个、且当前已提交的值”。这在单机系统中根本不用讨论因为只有一个内存视图但到了多副本场景问题就来了同一时刻不同节点上的数据可能不同客户端 A 从节点 1 读到的值和客户端 B 从节点 2 读到的值可能完全不一致。业界处理这个问题的主流思路分两派。一派是用共识算法比如常见的 Raft 类协议选出一个领导者所有写入和强一致读取都走领导者通过多数派投票确定日志提交顺序。另一派是链式复制也就是 Chain Replication简称 CR所有写请求固定从链头进入数据像流水线一样沿着一串节点向后传最后由链尾确认提交。Raft 的思路是“少数服从多数”链式复制的思路是“依次传递、末端定序”。两者各有取舍但 CR 的写路径比 Raft 简单很多因为它不需要选举多数派、不需要处理复杂的投票交互。1.2 链式复制的读写路径链式复制的基本拓扑是一条有序节点链head 节点接收写请求中间节点负责转发tail 节点负责确认写请求并向客户端返回成功。整条链只有 tail 对外提供读服务。写请求从 head 进入每个节点把数据写入本地存储后转发给下一个节点一直传到 tailtail 持久化成功后向前一个节点返回确认确认再逐级传回 head最后 head 告诉客户端“写成功了”。这条路径保证了任意时刻每个节点的数据顺序都完全一致只是滞后程度不同越靠近 head 的节点可能越新越靠近 tail 的节点反而有完整数据。读请求就全部发到 tail。这样做的好处是协议模型极其干净单点定序天然线性一致坏处也很明显——tail 成了读瓶颈。如果你的系统是典型的读多写少比如元数据查询、配置拉取、资源索引tail 的 CPU、带宽很快就会被读流量打满整条链的扩缩容都没用因为读只能挂在 tail 上。这是 CR 在实际生产中最先被诟病的地方。1.3 CRAQ 想解决的痛点读放大与读单点有一个很自然的优化思路既然链上所有节点都存有一份数据副本为什么不让中间节点也可以处理读请求但这里有个核心障碍——中间节点上的数据可能比 tail 落后。比如 head 刚收到一个写请求数据还没传到 tail此时你从中间节点读读到的是一个“尚未被确认”的新值而 tail 上还是旧值。这会造成两个客户端读到完全不同的结果破坏一致性。CRAQ 解决这个问题的办法很巧妙它给每个值增加了一个版本号并区分“已确认版本”和“未确认版本”。当客户端从中间节点读到一个未确认的新值中间节点不会直接返回而是向 tail 发起一次确认查询由 tail 判断这个版本是否已经提交。如果 tail 确认该版本已提交中间节点才把新值返回给客户端如果 tail 发现版本还没提交就返回旧值。这就是所谓 Apparent Query读到的“最新值”表面上存在但有效性由 tail 背书。这样一来读流量就可以被分散到所有节点上不再压死在 tail 一个点上同时强一致语义还能保住。2. CRAQ 核心机制拆解2.1 从 CR 到 CRAQ版本号与状态标记理解 CRAQ 的第一步是理解它如何在链式复制基础上引入版本管理。在原始 CR 中每个节点只保存一个当前值CRAQ 中每个节点为每个键保存一串历史版本值每个版本包含三个关键信息版本号、值内容、确认状态。确认状态又分两种clean 和 dirty。当一个版本已经经过 tail 的提交确认节点可以把这个版本标记为 clean如果节点已经收到了这个新版本的写入但还没有收到 tail 的确认回执版本就处于 dirty 状态。举个例子一条链包含 head、node1、tail 三个节点。假设 tail 当前已提交版本是 v3。这时 head 接受到一个写请求生成新版本 v4 并转发给 node1node1 本地存下 v4此时 v4 是 dirty因为 node1 并不知道 tail 是否已经接收并提交 v4。等写请求传到 tailtail 提交 v4 后沿着链反向逐个发送确认消息node1 收到确认后把本地 v4 的状态改成 clean。这就是 CRAQ 和 CR 的本质差别CR 的节点只有一份值CRAQ 的节点维护多个版本的副本并且知道哪些版本已经安全。2.2 写请求的完整流转过程CRAQ 的写路径和 CR 几乎一样。客户端把写请求发给 headhead 为这个写请求分配一个全局单调递增的版本号然后写入本地存储紧接着把写请求转发给下一个节点。每个节点收到写请求后先把数据追加到本地日志再更新内存中该键的最新版本最后把请求转发到下一个节点。节点之间没有任何确认交互确认只发生在 tail 一侧。tail 收到写请求后做两件事第一把它追加到本地日志并更新自己维护的“已提交版本号”为这个新版本号第二沿着链反向逐级发送确认消息每个节点收到确认消息后把对应版本从 dirty 改成 clean。当确认消息一级一级传回到 headhead 才向客户端返回写成功。这个小细节很值得注意一个写请求在整个转发链路中只是单向传递反向确认也只是状态翻转没有复杂的投票过程所以 CRAQ 的写路径延迟非常可预测基本就是整条链的传输延迟之和。2.3 读请求的两种分支CRAQ 的读请求可以发给任意节点但节点的处理逻辑取决于最新版本的确认状态。第一种情况节点收到读请求检查本地该键的最新版本如果最新版本状态是 clean直接把这个值返回给客户端不需要和任何其他节点通信。第二种情况最新版本是 dirty节点不能直接返回因为它不确定这个新版本是否已经被 tail 提交如果直接返回可能出现“读到未来值”的一致性违例。此时节点会向 tail 发送一个查询消息请求内容是你当前的已提交版本号是多少我本地最新版本号是 v它是否已经提交tail 收到查询后对比自己的已提交版本号。如果 v 小于等于已提交版本号说明这个版本已经提交tail 返回“已提交”确认中间节点再把 v 对应的值返回给客户端如果 v 大于已提交版本号说明这个版本还在传播途中或尚未被 tail 接收tail 返回“未提交”中间节点改为返回本地最新的 clean 版本。tail 自己处理读请求时更简单直接返回本地已提交的最新版本。这里有一个稍显反直觉的结果如果链很长并且写入频繁很多读请求会命中 dirty 版本引发一次额外的 tail 查询读延迟反而上升。但 CRAQ 的目标场景是读多写少大部分读请求落在 clean 版本上不产生额外通信小块 dirty 查询带来的平均延迟增加可以忽略不计。开发者在心里要有数CRAQ 适合写频次低、读频次高的业务不适合打满写吞吐的场景。2.4 CRAQ 为什么是强一致而非最终一致很多人第一次接触 CRAQ 会问让中间节点处理读还允许读到 dirty 版本后去 tail 确认这真的能保证线性一致吗答案是能关键在于所有值都有一个单一的提交顺序——由 tail 确定。写入路径上所有写请求从 head 进入head 分配的版本号是全局唯一的、单调递增的所以每个节点收到数据时看到的版本序列完全一致只是时间上可能有先有后。读取路径上中间节点面对 dirty 版本时必须向 tail 询问而 tail 的“已提交版本号”是唯一的定序标准所以任何读请求最终都会对应到一个明确的逻辑时刻要么是 tail 已提交的新版本要么是当前已提交的旧版本。所有客户端对这些版本的观察顺序完全一致不存在两个客户端分别读到互为矛盾的提交值的情况。用日常场景类比一条流水线上每个工位都会在自己手头看到“最新”的半成品但只有末端质检员贴了合格证的半成品才能出厂。工位看见新半成品时不敢直接发货必须先问质检员这个批次合格了没有质检员说合格就发新的说还没就发上一批合格的。因为质检员只有一个出厂货的批次顺序永远唯一所以不管从哪条旁路发货顾客拿到的货一定符合质检员当前的判定标准。3. 实操把 CRAQ 落地到一个元数据服务3.1 场景定义与设计边界讲原理不难难的是把协议变成一个能跑的系统。我前段时间在模拟项目X里实现了一个简化版 CRAQ用于一个配置元数据服务业务节点需要读取配置项的当前值并且要求强一致配置变更频次很低一天几十次但读取量很大。这个场景完美契合 CRAQ 的特性。系统拓扑是三节点链head 接收配置写入中间节点转发tail 最终确认和定序同时三个节点都能服务读请求。设计时首先要划清一个边界CRAQ 协议本身不负责链拓扑管理、节点故障发现和链路重建这些属于外围控制逻辑。实际落地时需要单独设计一个管理者组件负责维护链成员列表、探活、剔除故障节点、修复新节点等任务。管理者组件不参与数据面只做控制面避免引入额外延迟。我们可以用租约机制让管理者唯一且确定所有节点都信任管理者的链视图。3.2 关键数据结构和协议消息每个节点需要维护几类数据日志条目保存收到的写入记录版本存储保存某个键的历史版本以及对应状态元数据包括当前节点在链中的身份、前驱和后继节点地址、当前已提交版本号、当前分配给写请求的最后一个版本号。版本存储不能只保留最新值因为客户端可能读到旧版本节点需要能根据版本号返回对应值所以至少要保留一段滑动窗口内的历史版本具体长度取决于日志回收策略和副本落后容忍度。协议消息可以设计成以下几种WriteRequest{key, value}客户端发给 head 的写请求。WriteForward{version, key, value}节点之间沿链转发的写入消息。WriteAck{version}tail 发起的反向确认消息。ReadRequest{key}客户端发给任意节点的读请求。QueryClean{version}中间节点发给 tail 的 dirty 查询。QueryReply{committed_version, is_committed}tail 返回的确认结果。用类似 Go 的伪代码描述核心数据结构type VersionValue struct { Version uint64 Value []byte Clean bool } type NodeState struct { Role string // head | middle | tail Prev, Next string // 前后节点地址 LatestVersion uint64 // 本节点见过的最大版本号 Committed uint64 // 仅 tail 维护已提交版本号 Versions map[string][]VersionValue // 每个键的版本链 Log []LogEntry NextWriteID uint64 // head 分配版本号用 }3.3 head、middle、tail 三个角色的实现要点head 节点只做两件事接收外部写请求分配版本号后向链上转发。需要注意head 分配版本号时不能简单地基于本地最新版本加一因为可能存在多条写入从不同客户端并发进入必须用互斥锁保护 NextWriteID 的自增逻辑。head 收到多个写请求后可以立即分配连续版本号并按顺序转发比如 v5、v6、v7即使前一个写请求尚未被确认也不影响后一个请求分配版本号因为版本号天然编码了顺序。这个设计让写路径可以在不牺牲顺序的前提下保持高吞吐。middle 节点收到写请求后先追加日志然后更新对应键的版本链把新版本初始化为 dirty。如果当前键已经存在多个版本新版本只会追加在末尾。middle 节点收到来自后置节点的确认消息后将该版本状态改为 clean。它还需要响应本地读请求逻辑是拿到该键的最新版本如果状态是 clean直接返回如果状态是 dirty提取版本号向 tail 发 QueryClean在等待 tail 回复期间可以阻塞当前读协程收到回复后再决定返回新值还是旧值。实现时建议给查询加超时否则 tail 故障时读请求会全部挂死。tail 节点实现最简单但责任最重。收到写请求时tail 把它追加本地日志、更新 Committed 为请求版本号然后反向发送 WriteAck。收到 QueryClean 时比较请求版本号和当前 Committed返回确认结果。tail 的 Committed 是全局一致性的锚点所有节点都必须信任这个值所以 tail 持久化日志和 Committed 的操作必须落到磁盘不能只写内存。落地时我给 tail 的每次提交都做了一次 fsync虽然牺牲了一点点写延迟但换来了故障恢复时不丢已提交数据的安全保障。3.4 故障转移与链重建节点故障是分布式系统逃不开的主题。CRAQ 在这方面比共识协议更“老实”它不提供自我修复能力需要用外部控制面把失效节点从链中剔除或者把新节点加入链。管理者通过周期心跳探活假设 middle 节点挂了管理者会重建链让 head 的下一个节点指向原 middle 的后继即 head 直接连到 tail或者将另一个备用节点补到原 middle 位置。新的 tail 不能凭空产生如果原 tail 挂掉链上最后一个存活节点需要被提升为 tail并把自己的 Committed 值设为它本地最长的连续已确认日志的末尾位置因为此时没有任何节点可以告诉它哪个版本已提交。一个我踩过的坑是链重建后的数据补齐。新加入的节点没有历史日志如果直接从 head 同步全量数据传输量大如果从相邻节点同步又可能出现同步期间的版本间隙。最稳妥的方案是新节点先向相邻节点请求一个基线快照再追加同步后续日志最后再从 tail 请求当前的 Committed 版本号比对本地版本链是否连续。只有版本号连续新节点才可以对外服务读请求否则它可能会在脏查询中犯错。3.5 一条简单可跑的读路径示例我用简化代码来展示中间节点处理读请求的核心逻辑方便对照实现func (n *Node) HandleRead(key string) ([]byte, error) { versions : n.Versions[key] if len(versions) 0 { return nil, ErrNotFound } latest : versions[len(versions)-1] if latest.Clean { return latest.Value, nil } // 向 tail 确认该版本是否已提交 committed, err : n.AskTail(latest.Version) if err ! nil { return nil, err } if committed latest.Version { // 新版本已提交可以返回 return latest.Value, nil } // 新版本未提交返回本地最新 clean 版本 for i : len(versions) - 1; i 0; i-- { if versions[i].Clean { return versions[i].Value, nil } } return nil, ErrNoCleanVersion }这段逻辑看着简单实际坑不少。比如AskTail必须带上当前节点的身份信息让 tail 知道查询来自哪个节点tail 返回后最好带一个时间戳或请求 ID防止旧响应覆盖新状态。还有如果本地只有一个 dirty 版本且没有任何 clean 版本查询返回未提交时理论上是不会发生的因为 tail 提交前中间节点不会收到该版本的确认但为了代码健壮我还是保留了ErrNoCleanVersion分支。4. 典型问题与排查实录4.1 读到了旧数据dirty 判断失误我在第一次联调时遇到了一个“诡异”问题从中间节点读取配置返回值是旧值但 tail 上明明已经提交了新值。查了半天发现问题出在确认消息的乱序处理上。中间节点收到 WriteForward(v5) 后先把 v5 追加进版本链随后收到 WriteForward(v6)再追加 v6。但反向确认消息到达时v6 的确认先于 v5 的确认到达我的实现按确认顺序更新状态导致 v5 一直保持 dirty读 v5 时询问 tail 返回已提交逻辑本身没问题但我在返回旧版本的遍历中只检查了最新版本是否 clean没检查中间版本的状态以致用户读到的是较早的 clean 版本。解决办法是版本状态的更新不能按“收到确认的顺序”来必须按版本号来。确认消息虽然可能在网络里乱序但版本号本身有全序关系节点应记录“已确认到哪个版本号”而不是单独翻转某个版本的布尔值。也就是说维护一个ConfirmedUpto字段凡是版本号小于等于该字段的值一律视为 clean。这样即使 v6 的确认先到ConfirmedUpto 也只能从 v5 推到 v6 前状态不会错乱。4.2 tail 成为瓶颈dirty 查询风暴CRAQ 最大的卖点就是读扩展但你如果把写入频率拉高会发现一个尴尬现象所有节点上的最新版本基本都是 dirty客户端读任何节点都触发一次 tail 查询最终所有读请求还是聚集到 tail读扩展彻底失效。在写入频繁的场景下CRAQ 的读性能甚至还不如原始 CR因为多了一次往返。这是协议模型决定的不算 bug但可以优化。我采用的一个有效优化是tail 定期向所有节点广播自己的 Committed 版本号让中间节点在本地就能把连续版本批量标记为 clean不需要等逐个确认消息。这个广播不需要每条写入都发可以设置 50ms 或 100ms 的间隔显著降低 dirty 查询比例。代价是引入了微小的窗口期但在这个窗口内读请求依然可以通过尾部查询获得正确结果不会破坏一致性。4.3 日志膨胀与版本回收策略节点保存了大量历史版本如果长时间不清理内存和磁盘都会爆炸。但清理不能只按时间必须考虑两个约束一是 tail 的已提交版本不能删因为那是所有读请求的兜底二是任何节点都不能删除本地最后一个 clean 版本的旧版本因为中间节点在 dirty 查询返回未提交时还需要回退返回旧值。我的策略是tail 定期记录当前 Committed并在日志中打一个快照点中间节点只保留“从本地 clean 版本到当前最新 dirty 版本”之间的有限范围并把更早的版本合并成基线快照。如果某个节点落后太多不能直接丢弃旧版本而要触发一次全量日志同步同步完成后才能裁剪。4.4 脑裂与重复写入fencing token 的必要性有人觉得链式复制没有选举就不会脑裂这是误解。虽然 tail 唯一但 head 和节点之间的网络分区或者管理者误判节点故障都可能导致两个节点同时以为自己还是原来的角色。解决思路是用 fencing token管理者在每次链拓扑变更时生成一个递增的编号节点写入和转发消息时都携带当前 token接收方发现 token 比本地旧就拒绝处理。这样即使旧的 head 还在运行它发出的消息也会被新的链忽略避免旧节点写入覆盖新数据。5. CRAQ 的适用边界与选型心得5.1 什么时候选 CRAQ我的判断标准有三条。第一强一致是硬需求不能接受最终一致或租约方案。第二读请求量远大于写请求量且需要读水平扩展典型场景是元数据、配置、商品信息、组织架构等。第三系统拓扑相对固定链长度适中不需要频繁扩容、缩容。如果满足这三条CRAQ 会是一个让你非常省心的方案因为它比 Raft 简单很多写路径和读路径都容易审计故障恢复逻辑也不像共识协议那样复杂。有一个很典型的架构是用一个外部共识组件选出一个链管理者管理者维护 CRAQ 链业务读写全部走链共识组件只负责管理者选举不参与每一条数据的复制路径。这样既得到了 CRAQ 的高性能和简单性又利用共识协议解决了拓扑管理的自动化和选主问题。5.2 什么时候不要用 CRAQ写多读少的场景不要用 CRAQ因为每次写入都要沿全链传播延迟和带宽成本远高于多数派写入方案而且 dirty 查询会进一步放大 tail 压力。数据量大、需要分片的场景也不适合单条长链一条链上的所有节点都存全量数据存储成本和写入带宽都浪费。跨区域部署场景更要慎重如果链的节点分布在多个数据中心每笔写请求的尾延迟会被最远一跳的网络时延拖垮建议只在单数据中心内部署。5.3 最后一点实操体会回过头看实现 CRAQ 最容易出问题的环节不在读优化本身而在版本连续性维护。我踩过最深的坑是链重建后新节点的版本号从旧值重新递增导致新版本和旧版本冲突客户端读到旧版本后无法用版本号判断新旧。后来我在所有持久化数据里都增加了一个单调递增的 epoch链重建时 epoch 加一版本号上下文中带上 epoch任何比较都必须是“epoch version”二元组这个设计让日志同步和故障恢复瞬间清晰了很多。如果你正准备在项目里尝试 CRAQ我建议先不要急着写完整实现先用单机仿真把读写流程、版本翻转、tail 确认这三个核心路径跑通再用模拟网络试验乱序确认和节点故障最后再上真实服务器做压测。因为 CRAQ 协议本身很小调试时真正花时间的往往不是协议逻辑而是网络交互产生的各种边界情况。把这套流程走完你会对它产生比“又一个一致性算法”更深的理解下次再有人问强一致读扩展怎么做你就能直接给出链式复制的答案。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →