文档版本 v3.7-DRAFT 处于 草稿 状态。如需获取最新的稳定版文档,请参阅 v3.6。
etcd 客户端设计
etcd 客户端设计
Gyuho Lee(github.com/gyuho,Amazon Web Services, Inc.),Joe Betz(github.com/jpbetz,Google Inc.)
简介
etcd 服务器经过多年故障注入测试,已证明其稳健性。大多数复杂的应用逻辑已由 etcd 服务器及其数据存储处理(例如集群成员关系对客户端透明,通过 Raft 层将提案转发给领导者)。尽管服务器组件是正确的,但与客户端的组合需要另一套复杂的协议,以在故障条件下保证其正确性和高可用性。理想情况下,etcd 服务器提供一个由多台物理机器组成的逻辑集群视图,而客户端则实现副本之间的自动故障转移。本文档说明了客户端的架构决策及其具体实现细节。
术语表
clientv3:etcd v3 API 的官方 Go 客户端。
clientv3-grpc1.0:官方客户端实现,基于 grpc-go v1.0.x,用于最新的 etcd v3.1 版本。
clientv3-grpc1.7:官方客户端实现,基于 grpc-go v1.7.x,用于最新的 etcd v3.2 和 v3.3 版本。
clientv3-grpc1.23:官方客户端实现,基于 grpc-go v1.23.x,用于最新的 etcd v3.4 版本。
负载均衡器(Balancer):etcd 客户端的负载均衡器,实现了重试和故障转移机制。etcd 客户端应能自动在多个端点之间平衡负载。
端点(Endpoints):客户端可以连接的一组 etcd 服务器端点。通常是一个 etcd 集群的 3 或 5 个客户端 URL。
固定端点(Pinned endpoint):当配置了多个端点时,<= v3.3 版本的客户端负载均衡器仅选择一个端点建立 TCP 连接,以减少对 etcd 集群的总连接数。从 v3.4 开始,负载均衡器在每次请求时轮询选择不同的固定端点,从而更均匀地分发负载。
客户端连接(Client Connection):通过 gRPC Dial 建立的、与 etcd 服务器之间的 TCP 连接。
子连接(Sub Connection):gRPC SubConn 接口。每个子连接包含一个地址列表。负载均衡器根据解析出的地址列表创建 SubConn。gRPC ClientConn 可映射到多个 SubConn(例如 example.com 解析为两个子连接 10.10.10.1 和 10.10.10.2)。etcd v3.4 的负载均衡器使用内部解析器为每个端点建立一个子连接。
临时断开连接(Transient disconnect):当 gRPC 服务器返回状态错误 code Unavailable 时。
客户端需求
正确性(Correctness):在服务器发生故障时,请求可能会失败。但绝不会违反一致性保证:如全局顺序性、不会写入损坏的数据、可变操作具有“最多一次”语义、watch 不会观察到部分事件等。
活跃性(Liveness):服务器可能失败或短暂断开连接。无论哪种情况,客户端都应继续推进工作。除非显式配置等待,否则客户端应永不死锁地等待服务器恢复上线。理想情况下,客户端应通过 HTTP/2 ping 检测不可用的服务器,并以清晰的错误信息自动故障转移到其他节点。
有效性。客户端应以最少的资源高效运行:在端点切换后,之前的 TCP 连接应被优雅关闭。故障转移机制应能有效预测下一个要连接的副本,避免在已失败的节点上浪费性地重试。
可移植性。官方客户端应具备清晰的文档,并且其实现应适用于其他语言绑定。不同语言绑定之间的错误处理应保持一致。由于 etcd 完全基于 gRPC,其实现应与 gRPC 的长期设计目标紧密对齐(例如,可插拔的重试策略应与gRPC 重试兼容)。两个客户端版本之间的升级应无中断。
客户端概述
etcd 客户端实现了以下组件:
- 一个与 etcd 集群建立 gRPC 连接的负载均衡器,
- 一个向 etcd 服务器发送 RPC 请求的 API 客户端,以及
- 一个决定是否重试失败请求或切换端点的错误处理器。
不同语言在建立初始连接(例如配置 TLS)、编码并发送 Protocol Buffer 消息到服务器、处理流式 RPC 等方面可能有所不同。然而,从 etcd 服务器返回的错误应保持一致,因此错误处理和重试策略也应保持一致。
例如,etcd 服务器可能返回 "rpc error: code = Unavailable desc = etcdserver: request timed out",这是一种预期可重试的临时性错误;也可能返回 rpc error: code = InvalidArgument desc = etcdserver: key is not provided,表示请求无效,不应重试。Go 客户端可通过 google.golang.org/grpc/status.FromError 解析错误,Java 客户端则使用 io.grpc.Status.fromThrowable。
clientv3-grpc1.0:负载均衡器概述
clientv3-grpc1.0 在配置多个 etcd 端点时会维持多个 TCP 连接,然后选择其中一个地址用于发送所有客户端请求。该固定地址将一直保留,直到客户端对象被关闭(见图 1)。当客户端收到错误时,它会随机选择另一个地址并重试。

clientv3-grpc1.0:负载均衡器限制
clientv3-grpc1.0 维持多个 TCP 连接虽然可能加快负载均衡器的故障转移速度,但会消耗更多资源。该负载均衡器不了解节点的健康状态或集群成员信息,因此有可能陷入某个已失效或网络分区的节点而无法恢复。
clientv3-grpc1.7:负载均衡器概述
clientv3-grpc1.7 仅维护到所选 etcd 服务器的一个 TCP 连接。当配置了多个集群端点时,客户端会首先尝试连接所有端点,一旦其中一个连接成功建立,负载均衡器便会锁定该地址并关闭其他连接(见图 2)。该锁定地址将持续使用,直到客户端对象被关闭。若来自服务器或客户端网络出现错误,则会将错误发送至客户端错误处理器(见图 3)。


客户端错误处理器接收来自 gRPC 服务器的错误,并根据错误码和错误消息决定是重试同一端点,还是切换到其他地址(见图 4 和 图 5)。


对于 Watch 和 KeepAlive 等流式 RPC 请求,通常不设置超时。客户端可通过定期发送 HTTP/2 ping 来检测所固定端点的状态;如果服务器未响应 ping,负载均衡器将切换到其他端点(见图 6)。

clientv3-grpc1.7:负载均衡器限制
clientv3-grpc1.7 负载均衡器会发送 HTTP/2 keepalive 来检测流式请求的断开连接。这是一种简单的 gRPC 服务器 ping 机制,不涉及集群成员关系的判断,因此无法检测网络分区。由于发生分区的 gRPC 服务器仍可能响应客户端 ping 请求,负载均衡器可能会卡在已分区的节点上。理想情况下,keepalive ping 应能检测到分区并在请求超时前触发端点切换(参见 etcd#8673 和 图 7)。

clientv3-grpc1.7 负载均衡器维护了一个不可用端点列表。断开的地址会被加入“不健康”列表,并在等待持续时间结束前被视为不可用,该等待时间硬编码为拨号超时,默认值为 5 秒。负载均衡器可能对端点健康状态产生误判。例如,端点 A 可能在被列入黑名单后立即恢复,但在接下来的 5 秒内仍然不可用(参见 图 8)。
clientv3-grpc1.0 也存在上述相同的问题。

上游 gRPC Go 已经迁移到新的负载均衡器接口。例如,clientv3-grpc1.7 底层的负载均衡器实现使用了新的 gRPC 负载均衡器,并尝试与旧版行为保持一致。尽管其兼容性总体维持得较好,etcd 客户端仍然遭受了一些细微但破坏性的变更。此外,gRPC 维护者建议不要依赖旧的负载均衡器接口。通常来说,为了获得上游更好的支持,最好与最新的 gRPC 版本保持同步。而且新功能(例如重试策略)可能不会回传到 gRPC 1.7 分支。因此,etcd 服务端和客户端都必须迁移到最新的 gRPC 版本。
clientv3-grpc1.23:负载均衡器概述
clientv3-grpc1.7 与旧版 gRPC 接口耦合过于紧密,导致每一次 gRPC 依赖升级都会破坏客户端行为。大部分开发和调试工作都用于修复这些客户端行为变化。结果是其实现变得异常复杂,并对服务器连接性做出了许多错误假设。
clientv3-grpc1.23 的主要目标是简化负载均衡器的故障转移逻辑;不再维护可能已过期的不健康端点列表,而是在客户端与当前端点断开时直接轮询下一个端点。它不对端点状态做任何假设,因此不再需要复杂的狀態跟踪(参见 图 8 及上文)。升级到 clientv3-grpc1.23 不应存在问题;所有更改均为内部实现,同时保留了全部向后兼容性。
在内部,当提供多个端点时,clientv3-grpc1.23 会创建多个子连接(每个端点对应一个子连接),而 clientv3-grpc1.7 仅创建到一个固定端点的单一连接(参见图 9)。例如,在一个 5 节点集群中,clientv3-grpc1.23 的负载均衡器需要 5 个 TCP 连接,而 clientv3-grpc1.7 只需要一个。通过维护 TCP 连接池,clientv3-grpc1.23 可能消耗更多资源,但提供了更灵活的负载均衡机制,并具备更好的故障转移性能。默认的负载均衡策略是轮询(round robin),但也易于扩展以支持其他类型的负载均衡器(例如:二选一、选择领导者等)。clientv3-grpc1.23 使用 gRPC 解析器组并实现负载均衡选择器策略,从而将复杂的均衡逻辑委托给上游 gRPC 处理。另一方面,clientv3-grpc1.7 需要手动管理每个 gRPC 连接和负载均衡的故障转移,这使得实现更为复杂。clientv3-grpc1.23 在 gRPC 拦截器链中实现了重试机制,可自动处理 gRPC 内部错误,并支持更高级的重试策略(如退避重试),而 clientv3-grpc1.7 则需手动解析 gRPC 错误以决定是否重试。

clientv3-grpc1.23:负载均衡器限制
可以通过缓存各个端点的状态来进一步优化。例如,负载均衡器可以预先对每个服务器进行探测,维护一份健康候选节点列表,并在执行轮询时利用该信息;或者在发生断连时,优先选择健康的端点。这可能会增加负载均衡器实现的复杂性,因此可在后续版本中逐步实现。
客户端的 keepalive 探测仍无法感知网络分区情况。流式请求可能因连接到已分区的节点而卡住。需要实现更高级的健康检查服务,以了解集群成员状态(详见etcd#8673)。

目前,重试逻辑是作为拦截器手动实现的。未来可通过官方 gRPC 重试机制简化这一流程。