ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ABD算法:不用共识也能实现原子读写的一致性方案

ABD算法:不用共识也能实现原子读写的一致性方案 ABD 算法是分布式系统里一个经常被误解的经典方案。很多人一听到“分布式一致性”下意识就会想到 Raft、Paxos 这类共识算法但有一类场景根本不需要共识只需要用基于仲裁quorum的多副本复制加上版本号和时间戳就能对外提供原子的读写语义。ABD 就是这个思路的代表作它来自 Attiya、Bar-Noy 和 Dolev 的经典论文解决的问题是在异步消息传递系统里实现共享存储让多个节点看起来像操作同一块内存写操作完成后后续的读操作一定能读到这个值并且所有读写操作有一个全局顺序。这篇文章的核心判断是ABD 不解决共识问题它解决的是原子寄存器问题。如果把它放进共识算法的学习路径里很容易搞混概念。它最值得关注的地方恰恰是“almost consensus”这个定位——看起来和共识很接近但本质上绕开了共识的代价用更轻的机制拿到了大多数多副本读写场景需要的一致性保证。适合正在学习分布式存储、想理解读写仲裁机制、或者准备做多副本存储方案选型的开发者看。下面按实际落地的思路拆开讲。1. ABD 解决的不是共识问题而是原子读写问题1.1 先分清共识、原子寄存器和复制这三样东西经常被混在一起说但本质上不是一回事。共识consensus是多个节点对一个提议值达成一致典型代表是 Paxos、Raft。每个节点提出自己的值最终所有正常节点选择同一个值并且这个决定被所有节点认可。共识解决的是“多节点如何共同做出一个决定”的问题。原子寄存器atomic register是另一个问题让一个共享变量表现得像单机内存一样。读操作能读到“上一个已完成写操作”的值所有操作之间有先后顺序等价于有一个全局的线性排列。它不要求节点对一个命令做投票只要求每个读写操作的结果符合预期。ABD 实现的是后者。它不跑投票不选 Leader不复制日志而是靠读写两轮仲裁在副本之间交叉验证版本信息。这个思路在真实分布式存储里有大量影子不少对象存储的元数据设计、副本读修复机制、分布式缓存里的多副本读取都能看到 ABD 的思想。1.2 为什么说它是 “almost consensus”“Almost consensus” 这个描述很准确它和共识有交集但又刻意避开了共识最重的部分。共同点是ABD 也依赖多数派majority或广义仲裁集合来保证一致性。读操作去多个副本取版本选最高的写操作确保写到足够多的副本。任意一次读仲裁和任意一次写仲裁必然相交交集节点携带的版本信息保证了读操作一定能看到最近完成的写入。不同点是ABD 不做“全局决定”。它没有日志没有严格的成员变更协议没有 Leader也不保证多条命令按相同顺序在所有节点执行。它只保证单个共享变量在持续读写下的原子性。如果两个客户端同时写同一个 keyABD 本身不做冲突合并它靠版本号和时间戳裁决后完成的写覆盖先完成的写。谁先谁后由版本号决定而不是由某个 Leader 排序决定。所以它的定位很微妙比共识轻也比共识弱。它能保证原子读写但不能保证“多个节点对同一个值达成一致并只执行一次”。分布式锁、全局唯一序号生成、Leader 选举这几类场景ABD 解决不了必须靠共识、租约或者带投票的机制。2. 基于仲裁复制的读写全流程2.1 从一个最小场景开始理解 ABD 最好的方式是先假设一个最简单的情况3 个副本节点分别叫 A、B、C客户端可以读写任意一个键键的值是一个字符串每个副本本地保存一份。这时候如果只有一个客户端读写都很简单写就直接写到所有副本读就从任意副本读。但分布式系统里客户端可能有多个节点也可能故障、重启、网络抖动单副本读写就不够用了。ABD 的做法是给每个写入的值附加一个版本号然后通过仲裁保证读到的版本不落后。2.2 写操作的两轮确认ABD 的写操作看起来简单但拆开看有四个步骤客户端生成一个新的版本号通常用本地时间戳或递增计数器。客户端把(key, value, version)发给所有副本。每个副本收到后保存并回复确认。客户端等待至少 W 个副本确认成功写操作才返回成功。这里 W 是写仲裁大小。关键在于不需要所有副本都写成功只要写仲裁成功未来任何读仲裁都会遇到至少一个已经写入新版本的节点因为读仲裁 R 和写仲裁 W 满足R W N。这个公式是整个 ABD 能成立的数学基础。N 是总副本数任意取 R 个节点做读仲裁任意取 W 个节点做写仲裁两个集合的交集一定非空。交集中的节点一定带有最近写入的版本所以读操作可以通过比较版本号判断谁新谁旧。2.3 读操作和容易被忽略的回写读操作比写操作多一个隐藏动作客户端向所有副本发读请求。每个副本返回本地保存的(value, version)。客户端等待至少 R 个副本返回取版本号最高的作为结果。如果读到的值来自版本最高的节点但其他副本的版本较低客户端会把这个最新值回写write-back到那些旧副本。第四步是 ABD 保持原子性的关键。它保证一个慢副本不会长期持有旧值。假设 A 节点因为网络分区错过了最新写操作下一次读仲裁如果恰好落在 A 和一个旧副本上如果没有回写机制读操作就会拿到旧值。回写机制等于在每次读操作里附带了一次“顺路补数据”的动作让旧副本在下一次读取前被更新。这个回写动作在实现时很容易被省略很多入门实现只做了“读最高版本”就返回了结果在节点恢复、网络抖动后出现反复读到旧值的现象。问题不一定出在算法本身而是回写链路没接通。2.4 版本号和时间戳的设计细节版本号必须满足两个条件全序可比唯一不重复。常见的方案有三种节点 ID 加本地递增序号例如3-1042在同一个节点内保证递增不同节点用 ID 区分不会撞号。Lamport 时间戳把逻辑时钟带在消息里每次操作前取max(本地时钟, 收到的时钟) 1。高精度物理时钟加随机后缀适合不想维护逻辑时钟的场景但要处理时钟回拨。比较版本号时要注意如果版本号格式是字符串直接按字典序比较可能出错。比如9会排在10后面必须按规则解析成结构化字段比较或者把序号部分固定长度补零。这种看起来很初级的坑在真实实现里出现频率并不低。3. 参数设计、环境准备与性能判断3.1 仲裁大小怎么选仲裁大小的选择直接影响可用性和一致性边界。先记住核心约束读仲裁 R 和写仲裁 W 必须满足R W N同时 R 和 W 都要小于等于 N。常见的组合副本数 N写仲裁 W读仲裁 R容错能力适合场景322允许 1 个节点故障默认均衡配置533允许 2 个节点故障生产环境常用331写满全部读单节点读多写少、对写延迟不敏感542写要求高读要求低读放大明显但读延迟低N3、W2、R2 是最经典的配置适合学习验证。N5、W3、R3 适合实际生产允许两个节点同时故障而不丢读写。如果想做读优化可以把 R 调小比如 N5 时设 W4、R2。代价是每次写操作要确认的节点更多写延迟上升写可用性下降——任何一个节点故障都可能让写仲裁凑不齐。反过来想优化写就把 W 调小R 调大读延迟上升。参数不是越大越好要看业务是读多还是写多再叠加节点故障概率来权衡。3.2 失败模型、超时和重试ABD 默认的失败模型是节点可能崩溃消息可能延迟或丢失但消息不会被伪造、篡改。在这个模型下只要读仲裁和写仲裁的节点还活着系统就能继续工作。这里有几个实现层面的细节第一客户端超时时间不能设得太短。异步网络里慢节点是常态不是故障。如果超时设成 100ms而某个副本在正常负载下要 200ms 才响应读操作会频繁把慢副本当成故障导致仲裁质量下降。建议先用日志统计正常响应时间分布再把超时设成正常值的 2 到 3 倍。第二重试要幂等。写操作重试时不能因为重试就多写一次不同的版本号。正确的做法是整个写操作绑定一个固定的请求 ID重试时复用同一个版本号和值副本端用版本号做去重已经保存过的版本可以直接返回确认。第三要容忍部分节点失败。客户端发请求给 5 个副本其中一个挂了只要收到 3 个成功确认就能返回。不要把“所有节点都成功”作为成功条件那样的话任何单点故障都会导致整个写入失败仲裁的意义就没了。3.3 消息复杂度和性能判断标准ABD 的消息复杂度其实不低。一次写操作要发出 N 个请求收至少 W 个确认一次读操作要发 N 个请求收至少 R 个响应还可能要回写。判断性能不能只看单次延迟要看四个指标单次读延迟主要取决于收到第 R 个响应的时间所以超时设置很关键。单次写延迟取决于收到第 W 个确认的时间。吞吐量仲裁本身有消息放大效应N 越大放大越明显。5 副本写一次实际产生 10 条以上消息5 请求 5 响应吞吐要按消息总量算。异常情况下的稳定性节点故障恢复时回写会带来额外的消息流量这个峰值可能比正常流量高好几倍。我一般会先用小规模压测把消息量打出来再决定要不要合并请求、批量提交。不要一上来就开最大并发ABD 这种多副本请求模型对网络和节点 CPU 的消耗比单副本方案高得多。4. 边界情况与排查链路4.1 并发读写时的经典坑并发写是 ABD 最容易出错的地方。两个客户端同时写同一个 key各自生成版本号如果版本号的生成方式不保证全序可能出现“后写入的值版本更低”读操作会忽略它导致写入看起来丢失了。排查顺序是这样先看版本号生成方式是否保证全序和唯一。再看写操作是否在返回成功前把所有值都刷到了 W 个节点。再看读操作是否真的等待了 R 个响应还是在收到第一个响应就直接返回。第三种情况很典型。有些实现为了降低延迟收到一个响应就返回结果读到的可能是旧副本里的旧值。这在单副本测试时发现不了只有并发加节点故障时才会暴露。4.2 节点恢复后为什么还会读到旧值节点故障恢复后读到旧值通常不是节点本身的问题而是更新链路没有覆盖到它。假设 5 副本配置某节点在写仲裁期间就挂了它没有拿到新值。等它恢复后如果恰好没有参与后续读操作的回写它就会一直持有旧值。下一次读仲裁如果选中了它和另一个旧副本而新副本刚好不可用读操作就会返回旧值原子语义被破坏。这个问题的根源有两个回写没有做或者回写只覆盖了部分节点。修复思路不是让所有节点每次读都更新而是保证“任何读仲裁集合里至少有一个节点持有最新值”同时让读操作在发现旧副本后一定触发回写。如果回写失败要给下一次读留下补偿机会比如在本地记录待回写副本列表。4.3 一张排查链路清单实际排查时我一般按这个顺序推进看现象是读不到新值、读取值跳变、写返回成功但读不到还是操作超时。看日志确认客户端是否收到足够的仲裁响应有没有“仲裁成功”标记。看版本号对比逻辑是不是把版本号当字符串比较了版本号生成有没有冲突。看超时读操作是不是频繁因为某个慢副本超时而缩小了有效仲裁集合。看回写节点恢复后旧副本是否在下次读操作前被更新。看资源占用网络连接数、节点 CPU、磁盘 IO 是不是在高峰期被打满。很多问题不是算法不对而是实现细节没接住尤其是回写和版本号比较这两块。5. ABD 与共识方案怎么选5.1 更适合 ABD 的场景如果业务符合下面这些条件优先考虑 ABD 或类似思路而不是直接上 Raft只需要单个 key 的原子读写不需要多 key 事务。读多写少副本数量不大比如 3 到 5 个。不想引入日志复制、Leader 选举、成员变更这些重机制。对操作顺序没有全局排序要求只要单寄存器原子即可。节点数量相对固定成员变更不频繁。典型场景包括分布式缓存的后端多副本、配置中心里的单个配置项读写、对象存储里元数据的多副本读取、基于版本号的读修复机制。在这些场景里ABD 的复杂度远低于 Raft部署和排查都更简单。如果硬上 Raft反而要处理一堆跟业务无关的复杂度。5.2 必须上共识的场景下面这些场景 ABD 不管用不要硬套多 key 原子提交或分布式事务。Leader 选举保证在任意时刻只有一个节点在干活。日志复制所有节点按相同顺序执行相同命令。分布式锁需要多个客户端对“谁持有锁”达成一致。全局唯一序号生成要求所有申请者拿到不重复且可排序的序号。这些场景都需要一个全局决定机制ABD 没有。ABD 只保证单个共享变量的读写原子性不会在多节点之间形成统一决定。把 ABD 用在分布式锁上会出现两个客户端同时认为自己拿到锁的严重事故。5.3 落地验证清单我自己在落地这类方案时会按这个清单走一遍画一张读写路径图读哪几个节点、写哪几个节点、仲裁是否满足R W N。把版本号生成和比较逻辑做成独立模块先用单机测试验证边界。在一台机器上起多进程模拟多副本把故障注入、超时、乱序都跑一遍。验证节点重启恢复后读操作能否通过回写补齐旧副本。再考虑并发、吞吐和日志监控确认批量场景下没有消息风暴。如果只是学习用 N3、W2、R2 的配置从零实现一遍比直接看论文更有感觉。实现过程中你会自然理解为什么读回写不能省、为什么版本号不能随便用一个计数器、为什么仲裁公式必须严格满足。最后留一个我自己的判断ABD 不是共识的替代品它是“不需要共识时的一致性方案”。选型时先问自己一个问题——这个系统到底需要全局决定还是只需要原子读写想清楚这个问题方案就自然出来了。
返回列表