Skip to content

写入、复制、提交、读取不是同一个状态:etcd 三节点分区实测 ​

配置中心用了三节点 etcd。一次机房网络抖动后,有个服务读到的配置比别的服务旧了好几分钟;另一个服务在抖动期间写配置一直超时,可它连着的那个 etcd 节点明明「活着」,进程、端口、健康检查都正常。

共识系统常被简化成一句话:「多数节点同意就算成功」。这句话没错,但它省略了一条请求经过的几个不同状态:写入(客户端把请求交给 leader)、复制(leader 把日志发给 follower)、提交(多数派持久化之后,这条日志不会再丢)、应用(每个节点把已提交的日志应用到自己的状态机),以及读取(从哪个节点、以什么一致性读)。一个节点「活着」不等于它在多数派里,它的数据「存在」不等于是最新的。

本文用 etcd 3.7.2 搭一个三节点集群,只切断节点之间的复制网络、保留客户端到每个节点的连接,看少数派节点和被隔离的 leader 上读写的真实表现。实验见文末。

隔离一个 follower把 leader 隔离到少数派多数派leader写 v2 成功follower读 v2少数派follower写入:失败线性一致读:失败可串行读:v1(旧)no leader多数派:新 leader,term 增大新 leader写 v3 成功follower少数派原 leader5 秒后下台写入:失败线性一致读:失败可串行读:v2一条写入经过:客户端 → leader 追加日志 → 复制到 follower → 多数派持久化即提交 → 各节点应用 → 回复成功少数派节点的 raft index 停在分区前;恢复连接后由 leader 补发日志追平(Pre-Vote 让它不打断新 leader)节点活着 ≠ 在多数派里;需要最新数据的读用线性一致读,接受旧值的读才用可串行读依赖选主结果操作外部系统时,携带单调递增的令牌,让外部系统拒绝旧主
图 1 · 实测(etcd 3.7.2):只切断节点之间的复制网络,客户端到每个节点的连接保持正常。多数派一侧照常选主、写入、线性一致读;少数派一侧的节点进程和端口都正常,但写入和线性一致读失败,可串行读返回分区前的旧值,状态接口报告 no leader。原 leader 被隔离 5 秒后主动下台,恢复后以 follower 身份追平。

一、一条写入要经过的状态 ​

在 Raft(etcd 使用的共识算法)里:

  1. 客户端的写请求只由 leader 处理;发给 follower 的写会被转发给 leader。
  2. leader 把这条请求追加到自己的日志里,并发送给所有 follower,这是复制。
  3. 当多数节点(三节点中的两个)把这条日志持久化之后,leader 认为它已提交,提交的日志即使 leader 宕机也不会丢失。
  4. 各节点按顺序把已提交的日志应用到状态机(etcd 的键值存储),之后读取才看得到。
  5. leader 在提交并应用之后才回复客户端成功。

etcd 的 endpoint status 能看到其中几个数字:raftTerm(当前任期)、raftIndex(日志里最后一条的编号)、raftAppliedIndex(已应用到哪一条)。正常时三个节点的这些数字接近;分区时,被隔离节点的 index 会停在分区前的位置。

二、隔离一个 follower ​

text
隔离一个 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 隔离到少数派 ​

text
原 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)。
  • 需要最新数据的读使用线性一致读;明确哪些场景接受可串行读的旧值。
  • 写入超时不等于写入失败;重试前确认写入是否已提交,或让写入幂等。
  • 依赖选主结果去操作外部系统时,携带单调递增的令牌,让外部系统拒绝旧主。
  • 失联判断与业务接管分开:接管前确保原节点不能再产生副作用。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权