bigywater.tech
微服务 · 48

服务发现的一致性取舍与注册中心选型:AP 与 CP,etcd、Consul、Nacos、ZooKeeper 对照

服务发现该选 CP 还是 AP?分区时 CP 系统怎么表现、AP 系统又丢了什么?etcd 和 Consul 的故障转移要多久?这篇用 etcd 3.7.2、Consul 2.0.4 的真实集群和一个很小的 AP 复制模型对照。

HTTP 500 对 200
Consul 只剩 1 个 server 时,默认读与加 ?stale 的读的状态码(写入也是 500)
2.08 对 11.11 s
leader 被杀后,etcd 与 Consul(默认参数)恢复写入的中位数时间
4 / 4 对 丢 1 个
AP 模型分区期间的写入成功率,以及愈合后被最后写入者胜覆盖掉的写入数

CP 在少数派

实测 · 本机执行输出
  操作(只剩 1 个 server) | 结果                                                                           
  ------------------+------------------------------------------------------------------------------
  读 KV(默认)          | HTTP 500(rpc error getting client: failed to get conn: dial tcp 127.0) · 1 ms
  读 KV(?stale)      | HTTP 200,读到 1 · 0 ms                                                         
  读 KV(?consistent) | HTTP 500(rpc error getting client: failed to get conn: dial tcp 127.0) · 0 ms
  写 KV              | HTTP 500(rpc error getting client: failed to get conn: dial tcp 127.0) · 0 ms
  服务目录(默认)          | HTTP 500(rpc error getting client: failed to get conn: dial tcp 127.0) · 1 ms
  服务目录(?stale)      | HTTP 200,读到 1 · 1 ms                                                         
  Consul 默认的读要经过 leader,没有 leader 就失败;加 ?stale 之后任何 server 都可以回答(可能是旧数据),所以剩下的节点还能返回服务目录;写入需要多数,失败。和 etcd 的 serializable 读是同一个思路:用“可能旧”换“可用”,由调用方显式选择

默认读和写都失败;显式选 stale 读才能读到可能旧的数据。

故障转移

实测 · 本机执行输出
  系统     | 4 次:杀掉 leader 到写入恢复                  | 中位数    
  -------+--------------------------------------+--------
  etcd   | 2.08 s / 2.07 s / 2.08 s / 2.07 s    | 2.08 s 
  consul | 9.52 s / 11.94 s / 12.24 s / 10.28 s | 11.11 s
  两个 CP 系统在 leader 挂掉之后都要先重新选主才能继续写,期间(这里是秒级)写入不可用;时间取决于选举超时等参数(这里用的都是默认值,不同的调参结果不同)

选主期间不可写,时长取决于默认参数。

AP

实测 · 本机执行输出
  时刻         | /svc/web                       | /svc/a                      | /svc/b                        
  -----------+--------------------------------+-----------------------------+-------------------------------
  分区期间 R0 读到 | from-R0                        | only-R0                     | NULL                          
  分区期间 R1 读到 | from-R12                       | NULL                        | only-R12                      
  分区期间 R2 读到 | from-R12                       | NULL                        | only-R12                      
  愈合后三个副本读到  | from-R12 / from-R12 / from-R12 | only-R0 / only-R0 / only-R0 | only-R12 / only-R12 / only-R12
  分区期间 4 次写入全部成功(4 / 4),两边都能读也都能写——可用性是 100%,代价是两边读到不一样的值(/svc/web 在 R0 一侧是 from-R0,在另一侧是 from-R12;/svc/a、/svc/b 只在各自一侧可见)。愈合后 0.28 s 三个副本收敛到同一个值,/svc/web 留下的是 from-R12,被覆盖(丢掉)的写入是 from-R0;/svc/a 和 /svc/b 因为 key 不同,都保留了下来。对照:CP 系统在少数派一侧(第 1 节)拒绝写入,没有分叉,也没有丢更新;AP 系统用冲突解决(这里是最后写入者胜)换来了分区期间的可用

分区期间两边都可用、读到不同的值;愈合后用冲突解决合并,会丢更新。

分步演示

服务发现的一致性取舍
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。etcd 3.7.2 和 Consul 2.0.4(Homebrew cask,3 个 server 都在 127.0.0.1 的不同端口,Raft)都是自己起的真实集群;“分区”用杀掉其余节点只剩一个来模拟,没有做对称的网络分区;AP 一侧是我写的 Python 复制模型(多主、最后写入者胜、反熵),不是 Nacos 的 Distro 协议,更不是 Eureka;故障转移时间用的是两个系统各自的默认参数,Consul 的 11 秒左右没有调参也没有分析原因
    • 没有验证:Nacos 的 AP 模式(Distro)与 CP 模式(Raft)的真实行为(见后面 Nacos 的篇目);ZooKeeper 的对照(见 ZooKeeper 系列第 108、110 篇);Consul 的 autopilot 与 raft 参数调优;网络分区时两侧都有节点(对称分区)的行为;etcd 的 learner / 非投票成员
    京ICP备2026067502号-1京公网安备11010502063487号