写入、复制、提交、读取不是同一个状态:etcd 三节点分区实测
配置中心用了三节点 etcd。一次机房网络抖动后,有个服务读到的配置比别的服务旧了好几分钟;另一个服务在抖动期间写配置一直超时,可它连着的那个 etcd 节点明明「活着」,进程、端口、健康检查都正常。
共识系统常被简化成一句话:「多数节点同意就算成功」。这句话没错,但它省略了一条请求经过的几个不同状态:写入(客户端把请求交给 leader)、复制(leader 把日志发给 follower)、提交(多数派持久化之后,这条日志不会再丢)、应用(每个节点把已提交的日志应用到自己的状态机),以及读取(从哪个节点、以什么一致性读)。一个节点「活着」不等于它在多数派里,它的数据「存在」不等于是最新的。
本文用 etcd 3.7.2 搭一个三节点集群,只切断节点之间的复制网络、保留客户端到每个节点的连接,看少数派节点和被隔离的 leader 上读写的真实表现。实验见文末。
一、一条写入要经过的状态
在 Raft(etcd 使用的共识算法)里:
- 客户端的写请求只由 leader 处理;发给 follower 的写会被转发给 leader。
- leader 把这条请求追加到自己的日志里,并发送给所有 follower,这是复制。
- 当多数节点(三节点中的两个)把这条日志持久化之后,leader 认为它已提交,提交的日志即使 leader 宕机也不会丢失。
- 各节点按顺序把已提交的日志应用到状态机(etcd 的键值存储),之后读取才看得到。
- leader 在提交并应用之后才回复客户端成功。
etcd 的 endpoint status 能看到其中几个数字:raftTerm(当前任期)、raftIndex(日志里最后一条的编号)、raftAppliedIndex(已应用到哪一条)。正常时三个节点的这些数字接近;分区时,被隔离节点的 index 会停在分区前的位置。
二、隔离一个 follower
隔离一个 follower 后,经由 leader 写入 v2:成功
另一个 follower 上线性一致读:v2
被隔离的 follower 上线性一致读:失败
被隔离的 follower 上可串行读(--consistency=s):v1
经由被隔离的 follower 写入:失败
被隔离的 follower 的 raft index 落后于 leader:是;它报告的错误:etcdserver: no leader
恢复连接后,被隔离的 follower 追上并在可串行读中读到 v2:是leader 和另一个 follower 仍构成多数派,写入照常提交。被隔离的节点进程正常、客户端连接正常,但它联系不到 leader:
- 写入失败:它无法把请求转发给 leader,也无法参与提交。
- 线性一致读失败:etcd 默认的读是线性一致的,节点要先向 leader 确认自己的数据足够新(ReadIndex),确认不了就不回答。
- 可串行读返回旧值:
--consistency=s直接读本地状态,速度快、不依赖 leader,代价是可能返回旧数据。开头那个「读到旧配置」的服务,就是用了可串行读,或者用了只读本地缓存的客户端。
恢复连接后,leader 把缺失的日志补发给它,它很快追平。
所以「节点活着」和「节点能提供最新数据」是两件事。健康检查只检查进程和端口,会把一个少数派节点报告为健康;etcd 的状态接口里那条 etcdserver: no leader 才说明了问题。
三、把 leader 隔离到少数派
原 leader 被隔离后,另外两个节点选出新 leader:是;term 增大:是
经由新 leader 写入 v3:成功
隔离 5 秒后,原 leader 是否仍认为自己是 leader:否;它报告的错误:etcdserver: no leader
经由原 leader 写入:失败
原 leader 上线性一致读:失败
原 leader 上可串行读:v2
恢复连接后原 leader 读到 v3:是;当前 leader 仍是新 leader:是另外两个节点收不到 leader 的心跳,经过选举超时后以更高的 term 选出新 leader,继续提供写入。
关键在原 leader 一侧。它在被隔离的瞬间并不知道自己已经不是 leader 了。如果此时它还接受写入并回复成功,就会出现两个 leader 各自接受写入的局面。Raft 用两个机制防止这种情况:
- 写入必须提交:原 leader 收不到多数派的确认,写入永远无法提交,客户端得不到成功的回复。
- CheckQuorum:leader 在一个选举超时内没有收到多数派的响应,就主动下台。实验里 5 秒后原 leader 已经不认为自己是 leader。
线性一致读同样失败,因为它要确认自己仍是 leader。只有可串行读还会返回本地的旧值 v2。
恢复连接后,原 leader 看到更高的 term,作为 follower 加入,追上 v3。它没有发起新的选举打断当前 leader,这是 etcd 默认开启的 Pre-Vote 的作用:被隔离过的节点先「预投票」,确认自己能赢才真正增加 term 发起选举。
四、选出新 leader,不等于旧 leader 停止了动作
共识系统内部能保证同一时刻只有一个 leader 能提交写入,但它保证不了依赖它的外部系统。如果一个服务通过 etcd 的租约当选「主节点」,然后去操作数据库:网络分区时它的租约过期、另一个实例当选,而它自己可能还在执行之前开始的操作(GC 停顿、网络延迟让它晚了几秒才意识到)。这时需要外部系统能拒绝旧主的请求,常见做法是携带单调递增的令牌(etcd 的 revision、租约的版本),外部系统只接受更大的令牌。分布式锁的同一个问题,见分布式锁与并发控制实战。
同理,「失联」和「业务接管」应该分开判断:节点联系不上,可能只是网络问题,它也许还在处理请求;接管它的工作之前,要先确保它无法再产生副作用。
五、读的一致性怎么选
| 读的方式 | 保证 | 代价 | 适合 |
|---|---|---|---|
| 线性一致读(etcd 默认) | 读到的是读请求发起之后已提交的最新值 | 每次读要和 leader 确认,少数派节点上失败 | 选主、锁、配置的「当前值」 |
| 可串行读 | 只保证读到某个已应用的状态,可能旧 | 不依赖 leader,分区时仍可用 | 可以容忍旧数据的展示、监控 |
| 带版本的读 + watch | 客户端知道自己读到的是哪个 revision,并接收之后的变化 | 客户端要处理版本 | 配置下发、缓存失效 |
分区时哪些读仍然承诺最新:只有能联系到多数派的那一侧。少数派一侧要么拒绝(线性一致读),要么返回旧值(可串行读),没有第三种选择。这就是 CAP 在一个具体系统上的样子。
六、检查清单
- 健康检查不只看进程和端口,还要看节点是否有 leader(
etcdserver: no leader)。 - 需要最新数据的读使用线性一致读;明确哪些场景接受可串行读的旧值。
- 写入超时不等于写入失败;重试前确认写入是否已提交,或让写入幂等。
- 依赖选主结果去操作外部系统时,携带单调递增的令牌,让外部系统拒绝旧主。
- 失联判断与业务接管分开:接管前确保原节点不能再产生副作用。
配套实验
- codesphere-labs/distributed/raft-states-with-etcd:etcd 3.7.2 三节点:隔离 follower 与隔离 leader 时的写入、线性一致读、可串行读、raft index 与重新选主(验证记录)
参考资料
- Diego Ongaro、John Ousterhout,In Search of an Understandable Consensus Algorithm (Raft)
- Diego Ongaro,《Consensus: Bridging Theory and Practice》第 6 章(客户端交互、线性一致读)与第 9 章(Pre-Vote、CheckQuorum)
- etcd 文档:API guarantees
- etcd 文档:etcdctl get --consistency