服务发现与协调:ZooKeeper / etcd / Consul
微服务一上规模,第一个现实问题就来了:订单服务要知道库存服务在哪。而库存服务今天 3 个实例、明天扩到 8 个、IP 还都是容器网络的随机地址。把 IP 写在配置文件里?那你每次扩容都要改配置、重启上游。服务发现就是来解决这个问题的。而 ZooKeeper、etcd、Consul 这三兄弟,除了"告诉你去哪找服务",还顺手包办了选主、分布式锁、配置热更新、集群成员管理——所以它们的正式名字叫"分布式协调服务"。这一章把三者讲透,并回答一个 2026 年的现实问题:有了 Kubernetes,还需要注册中心吗?
16.1 服务发现要解决的三件事
| 模式 | 怎么工作 | 优劣 |
|---|---|---|
| 客户端发现 Client-side | 调用方从注册中心拉全量列表,自己按负载均衡算法挑一个再直连。Eureka + Ribbon、Nacos + Spring Cloud LoadBalancer、gRPC + 自研 resolver 都是这一类 | 少一跳、性能好、可以自定义复杂路由(按机房、按版本)。代价:每个语言都要实现一遍客户端逻辑,也拿不到注册中心统一的服务端治理 |
| 服务端发现 Server-side | 调用方只认一个固定地址(网关 / K8s Service / Consul 的 DNS 名),由中间那层去查注册中心并转发。K8s 的 ClusterIP + kube-proxy、Nginx + Consul 模板、API 网关都属这类 | 客户端极简、与语言无关、能做统一治理。代价:多一跳(可能成为瓶颈)、中间层必须高可用 |
论为什么这几件事需要"协调服务"而不是"一个数据库"
你可能会想:注册中心不就是一张表(服务名 → 实例列表)吗?用 MySQL 存不行吗?行,但会很难受,因为服务发现对存储提出了四个特殊要求:
① 极高的读并发 + 极低的读延迟。每次调用都可能要读一次服务列表(客户端发现模式下更是每次请求都要),高峰时是几十万 QPS。这种读写比可以用"几乎全是读"来形容,且必须毫秒级。
② 必须能"监听变化"(watch),而不是轮询。服务列表变了要立刻让所有调用方知道。用数据库只能定时轮询,延迟大且浪费。
③ 必须支持"会话/租约"这种临时状态。实例的注册信息应该在实例死亡后自动消失。关系数据库没有"连接断开就自动删行"的语义——这正是 ZooKeeper 的临时节点、etcd 的 lease、Consul 的 TTL check 要解决的问题。
④ 自己绝对不能不一致。如果两个调用方拿到了互相矛盾的服务列表,问题会非常难查。所以"谁挂谁不死"这种信息必须由多数派共识来裁定,不能有脑裂。
这四条决定了它必须是 CP 型(一致性优先)的共识系统——这正是 ZK(ZAB)、etcd(Raft)、Consul(Raft)的共同底座。代价也由此而来:少数派分区时它们会拒绝服务(宁可不答,也不答错)。
16.2 ZooKeeper:Java 世界的老牌协调者
ZooKeeper 是 Hadoop 生态带出来的(当前稳定版 3.8 / 3.9 一线,以官网为准)。它的角色像"分布式系统里的一本被严格管理的笔记本"——大家把状态写在上面,谁写、写到哪一步、有没有变化,全都有明确定义。
论三个关键概念:ZNode、临时节点、Watch
① ZNode 是一棵树。数据模型像文件系统:/services/order/instance-0001。每个 ZNode 能存少量数据(上限 1MB,实际应该只存几百字节),还能有子节点。所有更新都带版本号(version),可以做乐观锁(Compare-And-Set)。
② 节点类型决定了它的"生命"。持久节点一直在;临时节点(Ephemeral)绑在创建它的会话上,会话一断(客户端挂了/网络断了)节点自动删除——这就是"实例自动下线"的实现方式;顺序节点(Sequential)会自动在名字后加一个全局单调递增的序号,用它做队列和选主。
③ Watch 是"一次性通知"。客户端对某个节点注册 watch,节点变化时收到一次通知:之后就失效了,想持续监听必须重新注册。(这是 ZK 最常被误解的点,第 3 代客户端 Curator 帮你封装了自动重注册。)
两个最经典的用法:选主(Leader 选举)与分布式锁
论为什么"临时顺序节点"能做出公平锁和选主
每个竞争者都在同一父节点下创建一个临时顺序节点,然后看自己的序号:如果是所有子节点里最小的,就拿到锁(成为 leader);否则只监听排在自己前一位的那个节点。
这套设计有三个精妙之处:① 天然公平(先来的序号小);② 只监听前一个,避免"羊群效应"(如果用 watch 监听父节点,一次变更会唤醒所有竞争者,几百个客户端同时抢 ZK,ZK 会被打挂);③ 进程崩溃时临时节点自动消失,后面的人自然补上,不会出现"锁被死人抱着"。
分布式锁的"惊群"和"锁泄漏"这两大难题,到这里就都有了干净的答案——这也是 ZK 至今仍被用来做选主的原因。
① Watch 只触发一次。收到通知后不重新注册,你就永远收不到后续变化了。用原生 API 时这是必踩的坑,用 Curator 会好很多。
② Session 超时会导致"假死摘除"。客户端发生长时间 Full GC 停顿(或网络抖动),ZK 认为会话超时,临时节点被删掉,实例被当成下线;等 GC 结束,客户端其实还活着,但已经在注册中心"消失"了。这是注册中心最经典的故障——解法是把 session timeout 设得比最长 GC 停顿更长(如 30 秒),并监控 GC;同时应用侧要有"发现自己被摘除就主动退出"的自检。
③ 集群必须是奇数个,且至少 3 个。ZAB 需要过半写入才算成功(3 节点容忍挂 1 台,5 节点容忍挂 2 台)。2 个节点的可用性还不如 1 个(挂任何一个都无法过半)。
④ 别把 ZK 当数据存储用。1MB 的节点上限、写操作要全集群过半确认(写很慢很贵)、没有真正的事务查询能力。它只适合存"小而关键的元数据"(配置、状态、锁、列表)。把 ZK 当数据库用,是所有误用的第一名。
⑤ 大集群的 Watch 风暴。一个热门节点上有几千个 watcher,一次变更会引发几千次通知和重新注册,ZK 瞬间压力暴涨。要做批量合并 + 客户端本地缓存 + 退避重试,并控制 watch 的粒度。
16.3 etcd:云原生时代的事实标准
etcd(稳定版 3.5 / 3.6 一线,以官网为准)是 CoreOS 为容器时代造的协调服务,现在是 Kubernetes 的唯一数据底座——你在 K8s 里 kubectl apply 的一切,最终都写进了 etcd。它的设计比 ZK 更"现代":Raft 共识、gRPC 接口、MVCC 存储、租约(lease)与真正的流式 watch。
| 特性 | etcd | ZooKeeper |
|---|---|---|
| 共识协议 | Raft(论文清晰,容易理解和实现变异) | ZAB(类似 Paxos 的原子广播) |
| 接口 | gRPC + HTTP/JSON,多语言客户端质量高 | 自有 TCP 协议(Jute),生态以 Java 最强 |
| Watch | 真正的流式 watch:一个长连接持续接收变更,还支持从历史 revision 开始补看 | 一次性 watch,需反复注册 |
| 租约 | lease 是一等对象:多个 key 可以绑定同一个 lease,续约一次全部保活 | 临时节点绑会话,粒度较粗 |
| 多版本 | MVCC,每个写操作产生全局递增的 revision,可以读"任意历史版本" | 每个节点有 dataVersion,但没有全局历史读 |
| 数据模型 | 扁平的 key-value(key 用 /services/order/10.0.1.11:8080 这种前缀路径组织) | 树形 ZNode |
Go 客户端:注册 + 自动续约 + watch(K8s / Go 微服务里最常见的写法)
① 默认请求体上限 1.5MiB。etcd 不是数据库,etcdserver: request is too large 这个报错就是存了过大的 value。etcd 里只放元数据(几 KB 以内),业务数据放数据库或对象存储。
② 不做 compact + defrag,数据库会无限膨胀。etcd 的 MVCC 会保留历史版本,不压缩的话文件只涨不跌,最终耗尽磁盘。运维要定期执行 etcdctl compact(K8s 的 apiserver 会自动做)和 etcdctl defrag(需要逐个节点执行,会有短暂阻塞,要低峰做)。上线 etcd 时必须把这两条写进运维手册。
③ 丢了多数派,集群就"不可写",K8s 会跟着瘫。3 节点的 etcd 挂 2 台,剩余节点无法达成共识,所有写操作失败(读可能还可以从旧 leader 提供,取决于配置)。表现是 K8s 里 Pod 起不来、Service 改不了、kubectl 报 etcdserver timeout。更要紧的是:etcd 的磁盘 IO 必须好,用机械盘或与其他 IO 密集服务混部,会导致 leader 频繁切换——etcd 官方明确要求使用 SSD 并保证 fsync 延迟低于 10ms。
④ KeepAlive 返回的 channel 不消费,续约会停止。Go 客户端里如果拿到 channel 就不管了,租约会过期、服务会被"莫名摘除"。这个坑非常隐蔽(性能测试时没暴露,低流量时反而出问题)。
⑤ 不要跨机房部署单个 etcd 集群。Raft 对网络延迟敏感,跨机房(延迟几毫秒到几十毫秒)会让写延迟显著上升,一旦跨机房网络抖动就容易选主失败。正确做法是每个机房一套独立集群(K8s 也建议这样)。
16.4 Consul:自带 DNS 和多数据中心的那一个
Consul(HashiCorp 出品,稳定版 1.2x 一线,以官网为准)的思路是"开箱即用的一整套服务网络解决方案":服务发现 + 健康检查 + KV 存储 + 多数据中心 + 服务网格(Connect)+ 访问控制(ACL)。
论Consul 的分层架构:gossip 管成员,Raft 管一致
① 用 Serf(gossip 协议)做成员管理和故障检测。每个节点随机和几个邻居交换状态,信息以指数级速度扩散。它的好处是分散、去中心、极快发现"谁掉线了",且集群规模可以很大(几万个节点没问题)。
② 用 Raft 做一致性存储。只有 Server 节点(建议 3 或 5 个)参与 Raft,存放服务目录、KV、ACL 等关键数据;Client 节点只负责转发请求和执行健康检查,不参与共识,所以可以随意扩缩。
③ 多数据中心是一等公民。每个数据中心有自己的 Server 集群(DC 内 Raft),DC 之间用 WAN gossip 交换信息。关键设计:查询默认只在本地 DC 完成,不会跨 DC 请求——跨机房的调用要显式写 service.dc2.consul。这样单个 DC 的网络问题不会拖垮其他 DC。
④ DNS 接口是它最实用的特色。任何服务都能直接用 dig 或系统解析器拿到实例列表,不需要引入任何 SDK——老系统(Java、C、Perl 脚本都行)零改造成本接入服务发现。
注册一个带健康检查的服务(也可以让服务自己通过 HTTP API 注册)
① TTL 型健康检查必须由服务主动"续命"。用 ttl 检查(而不是 http 检查)时,服务要定期 PUT /v1/agent/check/pass/:check_id。忘了发心跳,服务会被标记为 critical 并摘除——这个坑在"服务看起来很健康但注册中心里已经挂了"时特别难查。
② DNS 缓存会让"摘除"延迟很久。操作系统、JVM(networkaddress.cache.ttl 默认在部分实现里是"永不失效")、语言运行时都会缓存 DNS 结果。实例下线了,客户端还在往旧 IP 发请求,报连接超时。解法:把客户端 DNS TTL 调小(如 10 秒),或用 HTTP API + 客户端缓存(Consul 默认返回 TTL 0,但仍受本地缓存影响)。
③ 跨数据中心不要做"强一致读"。Consul 的 DC 间是异步同步的,跨 DC 查询会变慢且可能读到旧数据。多活架构里,每个 DC 只信任自己的数据,跨 DC 的强一致需求应该用别的手段(比如数据库同步)。
④ ACL 不开等于门户大开。Consul 默认(旧版本行为)允许匿名读写 KV 和注册服务。生产必须开启 ACL:给每个服务发 token,只允许它写自己的注册信息、读它需要的服务。否则任何人能注册一个伪造的服务、或读到你的全部配置。
16.5 三者对比:怎么选
| 维度 | ZooKeeper | etcd | Consul |
|---|---|---|---|
| 共识协议 | ZAB | Raft | Raft(+ Serf gossip 管成员) |
| 数据模型 | 树形 ZNode(含临时/顺序节点) | 扁平 KV + MVCC revision | 服务目录 + KV + 多 DC |
| Watch 机制 | 一次性,需重新注册 | 流式长连接 + 可查历史 | HTTP blocking query + 长轮询 |
| 健康检查 | 无内置,靠会话/临时节点 | 无内置,靠 lease 续约 | 内置丰富(http/tcp/ttl/script/docker) |
| DNS 接口 | 无 | 无 | 有(service.consul) |
| 多数据中心 | 需自己拼(跨 DC 部署很麻烦) | 不擅长(不建议跨机房) | 原生支持,DC 间 WAN gossip |
| 服务网格 | 无 | 无(但 K8s 生态围绕它) | 有 Connect(内置 mTLS 代理) |
| 典型使用方 | Hadoop/HBase/Kafka 旧版、Java 生态的选主与锁 | Kubernetes、Go 微服务、配置中心(如 APISIX) | 多机房微服务、需要 DNS 接入的老系统、HashiCorp 全家桶(Nomad/Vault) |
| 一致性定位 | CP | CP | CP(默认一致性读;可选 stale 读换性能) |
选怎么选:三个判断
① 你在 K8s 上,且只需要"服务发现"。直接用 K8s 的 Service + DNS 就够了,不要再引入注册中心(见 16.6)。如果是"辅助 K8s 之外的东西",或者需要 watch/选主/分布式锁,那就 etcd(如果集群里已经有 etcd,复用它比新装一套 ZK 划算得多)。
② 你是 Java 老体系,需要选主、分布式锁、配置管理。ZooKeeper 依然是稳妥选择(Curator 封装得非常成熟),但要知道它的 watch 是一次性的、别拿它当数据库。
③ 你有多个机房/混合云,需要"跨 DC 的服务发现 + 健康检查 + ACL"。Consul 的一体化能力最省事,DNS 接口还能让不支持 SDK 的老系统零改造接入。
顺带说一句选型趋势:新项目里 etcd 和 Nacos(国内常用,AP/CP 可切、带配置中心)的出现频率已经明显超过 ZK;ZK 更多出现在"已有 Hadoop/Kafka 生态"的场景中。不用为了"技术先进"去换掉一个跑得很稳的 ZK。
16.6 有了 Kubernetes,还需要注册中心吗
| 能力 | K8s 原生(Service + Endpoints + DNS) | 独立注册中心 |
|---|---|---|
| 服务注册 | 自动:Pod ready 即进入 Endpoints | 要应用显式注册(或用 sidecar/agent 代注册) |
| 健康检查 | readiness / liveness 探针 | 更丰富的检查策略 + 业务自定义元数据 |
| 负载均衡 | kube-proxy 转发到 ClusterIP(服务端发现) | 客户端发现 + 自定义算法(按权重、按机房、按版本) |
| 跨集群 / 混合云 | 不支持(Service 只在集群内有效) | 强项:Consul/Nacos 天然跨集群、跨机房 |
| 业务级路由 | 无(只有 IP/端口层面的转发) | 可按实例元数据路由(灰度标签、权重、地域亲和) |
论结论:K8s 覆盖了 80%,但不是全部
如果:单一 K8s 集群 + 服务不需要业务级路由,那么 Service + DNS 完全够用,额外装注册中心是纯增加复杂度。
如果出现下面任意一种情况,独立注册中心依然有价值:① 跨多个 K8s 集群或多机房做统一发现;② 有非 K8s 的老服务(虚拟机、物理机)要互相调用;③ 需要按业务元数据做精细路由(灰度版本、权重、同机房优先);④ 需要"注册中心"这种与编排平台解耦的能力(换 K8s 发行版、上云下云时不用改代码)。
还有一个容易被忽略的分工:K8s 的 Service 解决的是"Pod 到 Pod",而注册中心(Nacos/Consul)通常还兼任"配置中心"——把动态配置热更新和服务发现放在一个组件里,是很多国内团队的实用选择。
① "假死"摘除。进程没死,但长时间 GC / 线程卡住导致心跳超时,被注册中心摘除,流量瞬间切走——正在处理的请求会失败,且实例恢复后要重新预热。缓解:心跳超时留足余量(大于最长 GC 停顿)、优雅下线(先摘除再停服)、消费方对失败做重试到其他实例。
② "摘除后立刻切走"导致雪崩。一个实例下线,它的流量全部涌向剩余实例,如果剩余实例本来就接近满载,会连锁崩溃(经典级联故障)。缓解:客户端做慢启动预热(warm-up)+ 最小实例数保护 + 熔断限流。
③ watch 风暴 / 通知放大。一次大规模发布(几百个实例同时上下线)会触发海量通知与本地列表重建。缓解:变更合并(批量推送)、客户端拉取限流、错峰发布。
④ 注册中心本身成了单点。它挂了,整个集群"失去自我认知"。所以:必须多节点、CP 系统必须过半可用、客户端必须有本地缓存(注册中心短时间不可用时,用上一次的列表继续工作)。这一条是设计时的硬要求,不是优化项。
① 服务发现要解决三件事:注册、健康检查、订阅推送。它们必须放在 CP 型共识系统里,因为"谁活着"绝对不能有分歧。
② ZooKeeper:ZNode 树 + 临时顺序节点,选主和分布式锁的经典方案。记住 watch 是一次性的、session 超时会假死摘除、只有 1MB 别当数据库。
③ etcd:Raft + lease + 流式 watch + MVCC,是 K8s 的底座。记住 1.5MiB 上限、必须 compact + defrag、丢多数派就不可写、必须用 SSD。
④ Consul:gossip 管成员 + Raft 管数据 + 原生多数据中心 + DNS 接口(老系统零改造接入)。注意 TTL 检查要主动续命、DNS 缓存会让摘除延迟、ACL 必须开。
⑤ K8s 已经覆盖了大部分服务发现需求;跨集群、混合云、业务级路由、配置中心这几件事,才是独立注册中心真正的价值所在。
⑥ 无论选哪个:多节点 + 客户端本地缓存 + 优雅上下线这三件事不做,注册中心迟早会变成全站的故障放大器。