bigywater.tech
微服务 · 47

注册中心不可达时:脑裂、网络分区与客户端缓存还能撑多久

注册中心挂了,服务调用方还能撑多久?刷新失败该保留旧列表还是清空?注册中心不可用时实例死了怎么办?etcd 失去多数时读写和续约各是什么表现?这篇用 3 节点 etcd 3.7.2 逐项量出来。

40 / 0 对 0 / 40
注册中心整体不可用几秒后,刷新失败保留旧列表与清空列表的调用方每秒的成功 / 失败数
80 对 1
注册中心不可用期间一个实例崩溃时,没有被动摘除与有被动摘除的调用方失败的请求数(共 400 个)
3 / 3
etcd 失去多数 8 秒后恢复,3 个登记的实例中仍在的个数;lease 剩余 TTL 是 19、18、18 秒

缓存与失败

实测 · 本机执行输出
  调用方           | 第 0 秒 成功/失败 | 第 1 秒 成功/失败 | 第 2 秒 成功/失败 | 第 3 秒 成功/失败 | 第 4 秒 成功/失败 | 第 5 秒 成功/失败 | 第 6 秒 成功/失败 | 第 7 秒 成功/失败 | 第 8 秒 成功/失败 | 第 9 秒 成功/失败
  --------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+------------
  (a) 刷新失败保留旧列表 | 40/0        | 40/0        | 40/0        | 40/0        | 40/0        | 40/0        | 40/0        | 40/0        | 40/0        | 40/0       
  (b) 刷新失败清空列表  | 40/0        | 40/0        | 1/39        | 0/40        | 0/40        | 0/40        | 0/40        | 0/40        | 0/40        | 0/40       
  注册中心整体挂掉时,后端实例其实都还活着:保留旧列表的调用方什么都没感觉;把“拿不到列表”当成“没有实例”的调用方,在下一次刷新失败时(最多一个刷新周期之后)所有请求都失败——为了“一致”而丢掉可用的缓存,把一个控制面故障放大成了数据面故障

刷新失败保留旧列表,控制面故障就不会影响数据面;把拿不到当成没有会放大故障。

不可用期间的实例死亡

实测 · 本机执行输出
  做法                 | 第 0 秒 | 第 1 秒 | 第 2 秒 | 第 3 秒 | 第 4 秒 | 第 5 秒 | 第 6 秒 | 第 7 秒 | 第 8 秒 | 第 9 秒 | 失败 / 总数 
  -------------------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+---------
  (a) 无被动摘除          | 40/0  | 40/0  | 40/0  | 40/0  | 27/13 | 26/14 | 27/13 | 27/13 | 26/14 | 27/13 | 80 / 400
  (b) 请求失败就本地摘除 30 s | 40/0  | 40/0  | 40/0  | 40/0  | 39/1  | 40/0  | 40/0  | 40/0  | 40/0  | 40/0  | 1 / 400 
  实例死了而注册中心恰好也不可用时,没有人能把它从列表里去掉:没有被动摘除的调用方会一直把约三分之一的请求发给死实例;被动摘除让调用方靠自己的调用结果修正缓存(第 4 秒之后很快就不再失败)

没有被动摘除就会一直把请求发给死实例;调用方要靠调用结果自己修正。

少数派

实测 · 本机执行输出
  操作(只剩 1 个节点,约 2 秒后开始)  | 结果                                                               
  -----------------------+------------------------------------------------------------------
  线性一致读(默认)              | 失败:TimeoutError(2002 ms)                                         
  serializable 读(本地读)    | 成功(1 ms),读到 /svc/ 下 6 个 key(3 个是本节的 j*,其余是前面几节登记后 lease 还没过期的 i*)
  写入                     | 失败:TimeoutError(2002 ms)                                         
  lease 续约               | 失败:TimeoutError(2003 ms)                                         
  恢复多数之后 /svc/j* 还在的实例   | 3 / 3                                                            
  恢复之后各 lease 的剩余 TTL(秒) | 19, 18, 18                                                       
  少数派节点上:线性一致读和写入都不可用(等不到多数),serializable 读还能读本地的数据(可能是旧的);lease 续约要经过 leader,也不可用。恢复多数、选出新 leader 之后,实例的 key 还在,lease 的剩余时间被重置(见第 41 篇),不会因为故障期间没续上约而大批过期

线性一致读、写入、续约不可用,serializable 读可用;恢复后 key 和 lease 都在。

分步演示

注册中心不可达
本步的对象

    本文用到的工具与边界

    • 本机 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,数字每次运行会有小幅抖动。3 节点 etcd 3.7.2(自己起的进程,每次运行前清空数据目录),通过 HTTP/JSON 网关依次尝试 3 个端点;“杀掉节点”是 SIGKILL 进程;实例是本进程里的 Python 服务,登记为带 20 s lease 的 key,每 2 s 续约;调用方的列表刷新周期是 1 s;我最初在第三节重复调用了 start_node,多起出一个占不到端口的进程,导致后面杀不掉真正的节点、少数派实验的结果是错的,已经改正(第三节依赖第二节最后已经拉起了 3 个节点)
    • 没有验证:etcd 客户端库(clientv3)的 endpoint 重试与负载均衡;Nacos / Eureka 的自我保护模式;DNS 级别的注册中心降级;注册中心恢复后大量实例同时重新注册的冲击(见第 49 篇);服务端侧的缓存(如 sidecar 的本地副本)
    京ICP备2026067502号-1京公网安备11010502063487号