ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

分布式一致性共识算法全景:从Paxos到Raft的工程实践

分布式一致性共识算法全景:从Paxos到Raft的工程实践 简介在分布式系统中多个节点如何对同一份数据的状态达成一致是保障系统可靠性的核心难题。不同业务场景需要不同强度的一致性模型从线性一致性到最终一致性形成一条完整光谱。共识算法作为底层支撑Paxos与Raft通过复制日志驱动状态机确保所有节点按相同顺序应用操作。理解这些原理有助于工程师在分布式事务、分布式锁、高可用存储等场景中做出合理选型并利用日志复制与故障注入验证系统的一致性边界。全面梳理一致性模型与CAP边界、共识算法演进、工程实现中的高频陷阱以及本地消息表、基于etcd的分布式锁等落地实践为构建可靠分布式系统提供完整参考。1. 从一份课程讲义说起分布式一致性到底在解决什么主库刚刚完成一次切换两个原本连接同一台数据库的客户端却读到了完全不同的键值。一个看到订单状态是“已支付”另一个看到的是“处理中”。这种场景在单体系统里几乎不会出现但在分布式系统里几乎是家常便饭。你以为是网络抖动、超时设置、或者主从同步延迟的锅实际上根子都在一个地方系统里同时存在多个节点而这些节点对同一个数据的“下一个状态”没有达成一致。这个问题就是分布式一致性。这篇内容对应的是一份典型的分布式算法课程课件章节编号 06主题集中在一件事上在不可靠的网络和可能宕机的节点之间让所有参与者对某个值或某个操作顺序达成一致。它不讨论业务幂等也不讨论最终一致性的所有业务补偿方案而是要讲清楚一致性模型、共识协议、状态机复制这些底层机制是怎么串起来的。适合正在啃分布式理论、准备面试系统设计或者工作中要设计高可用存储、分布式事务、选主流程的工程师阅读。下面直接从模型和协议说起然后落到工程里怎么选、怎么配、怎么验证。2. 一致性模型和 CAP 边界任何一致性设计都要从这里出发2.1 一致性不是“一个东西”而是一条光谱“一致性”这个词在分布式系统语境里被严重过载。数据库 ACID 里的一致性、分布式缓存里的最终一致性、共识算法里的线性一致性名称都带“一致”但含义完全不同。在判断一个系统该用哪种一致性方案之前先要把这条光谱拉出来。从最强到最弱常见的一致性模型大致包括线性一致性Linearizability所有操作在某个全局时间点生效一旦写入完成所有后续读都必须看到新值。这是最接近单机内存模型的一致性也是最贵的一致性。顺序一致性Sequential Consistency要求所有进程看到的操作顺序一致但不要求这个顺序与真实时间对齐。比线性弱一点实现上通常靠全局序号或锁。因果一致性Causal Consistency有因果关系的操作必须被所有节点按因果顺序观察到没有因果关系的操作可以乱序。最终一致性Eventual Consistency不保证任何时间点的一致只保证在没有新写入后经过足够长时间所有副本会收敛到相同值。如果用一个表格来对比会更直观一致性模型约束强度典型应用场景代价线性一致性最强分布式锁、选主、金融转账吞吐受限延迟高顺序一致性强分布式队列、全局 ID 生成需要统一排序通道因果一致性中社交媒体时间线、评论系统需要维护依赖关系最终一致性弱DNS、缓存、离线同步业务需容忍旧读这里的核心判断是一致性越强系统的可用性和性能付出就越大。很多时候不是“哪个更好”而是“你的业务能不能接受旧读”。如果业务能容忍几毫秒甚至几秒的延迟可见就用最终一致性如果绝对不能容忍读到旧主那就必须强一致。2.2 CAP 里真正要选的是 CP 还是 APCAP 理论常被说成“一致性、可用性、分区容忍性三者取二”这个表述容易让人误解。更准确地说当网络分区发生时系统必须在“保证所有节点读到相同数据”和“保证每个请求都能得到响应”之间做选择。注意不是平时三选二而是只在发生分区这个时间窗口内二选一。常见做法是多数存储系统默认选择 CP例如 ZooKeeper、etcd、Consul而缓存类系统或者大规模社交应用倾向于 AP例如 Cassandra 的某些配置、Dynamo 风格的存储。需要澄清的是AP 并不等于没有一致性它只是放弃了强一致转而提供最终一致的收敛路径。选型时我一般会看三个问题第一系统是否存在单点写入瓶颈如果是强一致写请求基本都要经过 Raft 或 Paxos 的 Leader吞吐上限是有限的第二业务对“脑裂”的容忍程度比如两个机房同时对外提供服务如果各自为政数据怎么合并第三客户端是否能在会话内读到自己的写入如果只有最终一致用户刷新页面看到数据消失这在很多业务场景里是致命的。2.3 从数据结构角度看一致性协议的本质分布式一致性协议本质上是在维护一个所有节点共同操作的数据结构。最常见的抽象是“日志”。每个节点保存一份操作日志日志里的条目顺序是全局一致的然后按日志顺序应用命令到状态机。这就是状态机复制State Machine Replication的核心思路。如果把这个思路抽象到数据结构的层面日志就是一个线性写的数组每个位置只写一次。共识算法要解决的问题是在多个节点同时向这个数组追加内容时如何保证最终所有节点的数组内容一致并且没有人写入了一个在某位置上被覆盖的值。这正是 Raft 和 Paxos 在做的它们不是直接让业务数据一致而是先让日志一致再通过日志驱动状态机间接保证数据一致。2.3.1 演示最小状态机复制的数据结构用伪结构来看日志复制的存储模型type LogEntry struct { Term int // 任期号用于防止旧 leader 写入 Index int64 // 日志下标全局连续递增 Command string // 实际要执行的操作比如 set keyvalue } type ReplicatedLog struct { Entries []LogEntry // 有序日志数组 CommitIndex int64 // 已提交的最高下标 LastApplied int64 // 已应用到状态机的下标 }日志追加操作的核心逻辑是新 Leader 选举成功后只会接受比自己日志更新的节点作为权威来源并强制覆盖与自己不一致的旧条目。从数据结构视角看这里的关键点是Index作为唯一键Term作为防重用的令牌两个字段合起来保证每个日志槽位上只能有一个合法内容。3. 从 Paxos 到 Raft共识算法演进中凡人能用的部分3.1 Paxos 难懂但不代表难用学分布式一致性绕不开 Paxos。Lamport 在 1998 年提出的 Basic Paxos理论上奠定了共识问题的可解基础但它以“难懂”著称。难懂的原因在于它抽象层次高推导过程数学化工程实现留下了太多隐形条件。Multi-Paxos 更复杂它把 Basic Paxos 做多轮复用又引入 Leader 概念来加速但原始论文里并没有给出完整的工程细节。在工业界Paxos 的变体实际更常见。比如腾讯的 PhxPaxos、微信的 PaxosStore、Chubby 的 Paxos 实现阿里云 PolarDB 里的 X-Paxos 也是 Paxos 的改版。这些实现都针对 Paxos 的痛点做了优化减少消息轮数、优化 Leader 切换、支持乱序提交等。3.2 Raft 把共识问题拆成了三个子问题Raft 的目标不是提出一种新的共识算法而是把 Paxos 里的共识逻辑重新组织让人能看懂、能实现。它拆成了三个相对独立的部分Leader 选举日志复制安全性保证其中 Leader 选举靠任期Term和随机超时实现日志复制靠 AppendEntries RPC安全性保证靠“选举限制”和“提交限制”两个规则。这里有一个常见的误解Raft 只有在大多数节点存活时才能工作这个“大多数”指的是节点总数的大多数不只是 Leader 和 Follower 之间的多数。假设集群有 5 个节点任意时刻提交一个日志条目至少需要 3 个节点返回成功。3.2.1 Raft 日志复制的最小流程伪代码以 Python 表达 Raft Leader 追加日志的行为def append_entries(self, entries): # entries 是由 Leader 生成的新日志条目列表 for entry in entries: # 校验前一个日志是否匹配 prev_entry self.get_entry(entry.index - 1) if prev_entry is None or prev_entry.term ! entry.prev_term: self.reject_append(entry.index) return False # 如果本地已存在同 index 但不同 term 的日志需要截断 if self.contains_index(entry.index): if self.get_entry(entry.index).term ! entry.term: self.truncate_from(entry.index) # 追加日志 self.log.append(entry) self.commit_index max(self.commit_index, entries[-1].index) return True这段代码对应了日志复制里的两个关键异常处理分支一是“日志检查失败”时 Follower 不追加并通知 Leader 回退二是“同下标冲突日志”时 Follower 截断自己的尾部以和 Leader 对齐。参数commit_index只会在多数派确认后推进这是日志一致性和状态机安全性的最后一道防线。3.3 工程选型Raft 还是 Paxos 变体如果从零自研一个分布式存储系统Raft 是更务实的底座。它有明确的论文规范、多个成熟的开源参考实现例如 etcd 的 Raft 库、Hashicorp 的成员库。在选型时我通常遵循三个参考标准。如果系统需求是“强一致 高可靠 中小规模集群37 节点”Raft 实现成本低pitfall 少。如果系统需要海量分片每个分片单独跑共识组Raft 的组内通信开销也可以用“多组并行 共享存储”来缓解。如果对延迟极度敏感且专门有团队长期维护共识模块可以考虑类 Paxos 的实现但没有别人可参考的情况下不推荐从零写 Paxos。3.4 实现过程中的 4 个高频坑Raft 看起来简单实现起来的坑并不少。第一Leader 切换时旧 Leader 可能不知道自己的任期已过期它会继续接收客户端请求并发送 AppendEntries此时需要靠请求中包含的任期号做大小比较来拒绝旧 Leader。第二选举限制经常被忽略有些实现会投给日志落后的候选人导致日志回退这是致命的。第三快照Snapshot安装后的日志块与内存日志之间的衔接处理不当会造成 entry 缺失。第四磁盘写入的 fsync 被许多教材忽略如果不持久化日志就直接回包宕机会丢数据整个协议形同虚设。3.4.1 判断一个 Raft 库是否可用的验证命令实际接入一个第三方 Raft 库时不能只依赖单元测试。我一般会在本地搭建一个 3 节点或 5 节点的集群用 kill 命令模拟节点宕机观察集群的读写可用性和数据恢复情况# 假设容器内运行了 raft_server先启动 3 个节点 raft_server --node1 --peers127.0.0.1:8001,127.0.0.1:8002,127.0.0.1:8003 --data./data1 raft_server --node2 --peers127.0.0.1:8001,127.0.0.1:8002,127.0.0.1:8003 --data./data2 raft_server --node3 --peers127.0.0.1:8001,127.0.0.1:8002,127.0.0.1:8003 --data./data3 # 写入一条测试数据以后杀掉 Leader 节点 pkill -f raft_server --node1 || true sleep 2 # 检查其余两个节点能否继续选主并读取到已提交数据 raft_ctl --endpoint127.0.0.1:8002 get my-key核心验证目标有三个一是旧 Leader 宕机后新 Leader 是否能在可接受时间内选出二是已经提交的数据在 Leader 切换后是否仍然可读三是被 kill 的节点重新加入后是否会被动补齐日志并快速追赶。4. 分布式一致性在事务、锁和存储里的落地方式4.1 共识算法是手段分布式事务一致性才是业务关心的目标工业界提到“分布式事务一致性”通常关心的不是共识算法里的日志复制而是多个微服务之间如何保证要么全部成功、要么全部失败。这里常见的技术路线有 2PC、3PC、TCC、Saga、本地消息表。他们各自的侧重点不同但和分布式一致性协议有共通之处都需要一个协调者都有“确认”和“回滚”的判定机制。2PC 的问题在于协调者单点和阻塞参与者资源被锁定直到协调者给出最终决定3PC 缓解了阻塞问题但引入了更大的消息复杂度TCC 把事务拆成 Try、Confirm、Cancel 三个阶段适合业务层事务但对业务侵入大Saga 强调补偿适合长事务。4.2 用共识协议做分布式锁的正确姿势一种典型的使用方式是多个进程竞争一个全局锁锁的状态保存在一个支持线性一致性的存储里比如 ZooKeeper 的临时顺序节点、etcd 的 Revision API。正确加锁流程是每个进程创建一个顺序临时节点然后读取当前节点列表判断自己创建的是不是序号最小的节点如果是就获得锁否则监听比自己序号小的前一个节点的删除事件。以 etcd 实现分布式锁为例核心参数在租约Lease上# 创建 10 秒租约 etcdctl lease grant 10 # 带租约写入锁 key携带并写入创建时的 Revision etcdctl put /lock/resource-1 clientA --lease64f0d1e0d2c3c000逻辑说明lease grant 10表示这个锁携带的租约有效期为 10 秒持有进程需要定期续约如果进程崩溃租约到期自动过期锁自动释放无需人工介入。参数--lease后跟的 ID 要替换成实际生成的租约 ID。etcdctl put写入的锁值用于标识持有者的身份释放锁时要对比值防止误删他人锁。这类锁的一致性强是因为 etcd 走的是 Raft 共识每个节点的状态一致读取 Revision 可以直接判断写入顺序不存在“两个客户端同时拿到锁”的情况。4.3 本地消息表与最终一致性的经典结合对于非强一致的业务场景本地消息表是成本最低、维护最方便的模式。核心思想是同一个本地事务里同时写入业务数据和一条待发送消息然后一个后台任务扫描这个消息表把消息发送到 MQ消费方处理成功后主动回调确认确认后消息标记为已完成。这个模式的关键并不在 MQ 本身而在于把“业务操作”和“记录消息”放在同一个本地事务里。如果分开写会出现业务成功但消息没记上或者消息记上但业务没成功的情况。本地消息表用数据库事务把两者绑定语义上等价于一次原子提交这也是“最终一致性”在工程落地里最实用的一课。4.3.1 本地消息表的建表 SQL 与轮询脚本简单给出发送端消息表的建表语句和扫描发送的伪代码CREATE TABLE outbox_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, aggregate_id VARCHAR(64) NOT NULL COMMENT 业务聚合根ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2已完成, retry_count INT NOT NULL DEFAULT 0 COMMENT 重试次数, next_retry_time DATETIME NOT NULL COMMENT 下次重试时间, payload JSON NOT NULL COMMENT 消息体, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_status_time (status, next_retry_time) ) COMMENT 本地消息表;def scan_and_send(): while True: rows db.query( SELECT * FROM outbox_message WHERE status 0 AND next_retry_time NOW() ORDER BY id LIMIT 100 ) for row in rows: send_to_mq(row) # 简化版处理完后短暂休眠避免空转 time.sleep(1)参数说明status区分消息生命周期retry_count用于控制最大重试次数next_retry_time是实现“退避重试”的关键第一次失败后可以设置 10 秒后重试连续失败则逐步延长间隔。这里最容易被忽略的参数是LIMIT如果没有它扫描会一次加载全表消息积压时直接把数据库内存打爆。消息表必须和业务表在同一个数据库实例里否则就失去了“本地事务”的意义。5. 一致性验证与故障演练最后一个容易被跳过却又最该做好的环节先设计一套可落地的验证策略。验证一致性不能只看“正常情况下的读写是否符合预期”还必须覆盖网络分区、节点宕机、主从切换、消息延迟、时钟跳跃这些故障场景。模拟工具首选是 Jepsen 的测试思路它通过控制网络分区partition、随机 kill 进程、注入延迟来验证系统是否违反一致性约束。把验证分成三层协议层、数据层、业务层。协议层用黑盒接口观察选主时间和日志提交进度数据层直接校验副本之间数据是否收敛业务层模拟客户端读写检查是否出现“已提交的数据丢失”“旧 Leader 存活期间写入被吞”这类问题。常见验证工具包括 Maelstrom、Jepsen以及我们自己编写的混沌测试脚本。其中 Maelstrom 提供了模拟网络层的环境可以直接在本地运行 Go、Python 等语言的节点然后注入分区和延迟# 运行一个 5 节点的分布式 KV 节点测试持续 30 秒并注入分区 maelstrom test -w kv --bin ./kv-node --node-count 5 --time-limit 30 --partition关键参数说明--node-count控制节点数量--time-limit控制测试时长--partition开启网络分区注入、--nemesis可以指定故障注入器。测试结束后工具会输出一致性验证结果如果出现“Observed a violation of linearizability”就说明系统在某个时间点给客户端返回了过期数据或错误顺序。最后给出一个可以立即放入工作流的技巧把一致性验证嵌入到 CI 流程的“夜间测试”任务里每次代码变更后自动运行 500 次随机并发读写记录线性一致性违反率和 Raft 选举耗时分布。一旦发现违反立即输出引起问题的日志快照和请求时间线。这个做法的价值在于把“我们用的是强一致存储”从一个口号变成可以量化、可回归的指标而不是在上线后等用户来报告数据错乱。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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