楼层: 首页/ 软件技术/ 中间件全景/ 服务发现与协调:ZooKeeper / etcd / Consul
17

服务发现与协调:ZooKeeper / etcd / Consul

Service Discovery & Coordination · ZooKeeper / etcd / Consul

微服务一上规模,第一个现实问题就来了:订单服务要知道库存服务在哪。而库存服务今天 3 个实例、明天扩到 8 个、IP 还都是容器网络的随机地址。把 IP 写在配置文件里?那你每次扩容都要改配置、重启上游。服务发现就是来解决这个问题的。而 ZooKeeper、etcd、Consul 这三兄弟,除了"告诉你去哪找服务",还顺手包办了选主、分布式锁、配置热更新、集群成员管理——所以它们的正式名字叫"分布式协调服务"。这一章把三者讲透,并回答一个 2026 年的现实问题:有了 Kubernetes,还需要注册中心吗?

16.1 服务发现要解决的三件事

① 注册
实例启动时把「我是谁、IP:Port、元数据」写进注册中心
→
② 健康检查
心跳 / TTL 续约 / 主动探测;实例挂了就被摘掉
→
③ 订阅与推送
调用方拿到实时列表,变化时被通知,而不是自己轮询
两种发现模式:谁来"算"出该调哪个实例
模式怎么工作优劣
客户端发现
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 帮你封装了自动重注册。)

# 用 zkCli 手动体验一下(生产排障也常用) zkCli.sh -server 192.168.1.20:2181 # 创建持久节点(服务注册的"目录") create /services/order "" # 创建临时顺序节点(实例注册):客户端断开后节点会自动消失 create -e -s /services/order/instance- "10.0.1.11:8080" # Created /services/order/instance-0000000001 # 看有哪些实例 ls /services/order # [instance-0000000001, instance-0000000002, instance-0000000003] # 看节点内容与元信息(dataVersion/cversion 可以做乐观锁) get -s /services/order/instance-0000000001 # 注册一次监听(只通知一次!) get -w /services/order # 四字命令看集群状态(谁是 leader、有没有 follower 掉队) echo stat | nc 192.168.1.20 2181 echo mntr | nc 192.168.1.20 2181 # 关注 zk_avg_latency / zk_outstanding_requests / zk_followers

两个最经典的用法:选主(Leader 选举)与分布式锁

// 用 Curator:临时顺序节点里序号最小的那个就是 Leader public class LeaderElection { public static void main(String[] args) throws Exception { CuratorFramework client = CuratorFrameworkFactory.newClient( "192.168.1.20:2181,192.168.1.21:2181,192.168.1.22:2181", new ExponentialBackoffRetry(1000, 3)); // 断线自动重连 client.start(); LeaderLatch latch = new LeaderLatch(client, "/election/order-consumer"); latch.start(); // await 会在自己成为 leader 时返回;别人成为 leader 时继续等待 latch.await(); System.out.println("我成了 leader,开始跑定时任务"); } } // 分布式锁:InterProcessMutex 内部就是"临时顺序节点 + 监听前一个节点" InterProcessMutex lock = new InterProcessMutex(client, "/locks/order-1001"); if (lock.acquire(3, TimeUnit.SECONDS)) { try { doBusiness(); } finally { lock.release(); } // 务必 finally 释放 }

论为什么"临时顺序节点"能做出公平锁和选主

每个竞争者都在同一父节点下创建一个临时顺序节点,然后看自己的序号:如果是所有子节点里最小的,就拿到锁(成为 leader);否则只监听排在自己前一位的那个节点。

这套设计有三个精妙之处:① 天然公平(先来的序号小);② 只监听前一个,避免"羊群效应"(如果用 watch 监听父节点,一次变更会唤醒所有竞争者,几百个客户端同时抢 ZK,ZK 会被打挂);③ 进程崩溃时临时节点自动消失,后面的人自然补上,不会出现"锁被死人抱着"。

分布式锁的"惊群"和"锁泄漏"这两大难题,到这里就都有了干净的答案——这也是 ZK 至今仍被用来做选主的原因。

ZooKeeper 的五个坑

① 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 相比 ZK 的关键设计差异
特性etcdZooKeeper
共识协议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
# 1) 建一个 lease(TTL 10 秒),后续所有 key 绑在它上面 etcdctl lease grant 10 # lease 694d7a3b0e1f2c8a granted with TTL(10s) # 2) 注册服务:把实例信息写进 key,并绑定 lease etcdctl put /services/order/10.0.1.11:8080 '{"weight":100,"version":"v2"}' --lease=694d7a3b0e1f2c8a # 3) 保活:实例要定期续约(客户端 SDK 一般自动做) etcdctl lease keep-alive 694d7a3b0e1f2c8a # 4) 流式 watch:一个连接持续接收变更,别人注册/下线立刻能看到 etcdctl watch --prefix /services/order/ # 5) 按前缀拉当前列表 etcdctl get --prefix /services/order/ # 6) 看集群健康与成员 etcdctl endpoint health --cluster etcdctl endpoint status --cluster -w table # 关注 DB SIZE / RAFT TERM / LEADER / IS LEADER

Go 客户端:注册 + 自动续约 + watch(K8s / Go 微服务里最常见的写法)

// 注册服务并自动保活(简化版,生产要处理错误和重试) func register(cli *clientv3.Client, addr string) { // 建一个租约,10 秒 TTL lease, _ := cli.Grant(ctx, 10) key := "/services/order/" + addr cli.Put(ctx, key, `{"weight":100}`, clientv3.WithLease(lease.ID)) // KeepAlive 返回一个 channel,SDK 会在后台自动续约 ch, _ := cli.KeepAlive(ctx, lease.ID) go func() { for range ch { // 消费这个 channel 才能触发续约,不读会被取消! log.Println("lease renewed") } }() } // 调用方:监听前缀,维护本地实例列表 func watch(cli *clientv3.Client, onChange func([]string)) { rch := cli.Watch(ctx, "/services/order/", clientv3.WithPrefix()) for wresp := range rch { for _, ev := range wresp.Events { // ev.Type 是 PUT 或 DELETE,本地缓存据此增删 log.Printf("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value) } onChange(listInstances(cli)) } }
etcd 的五个坑(第三个最容易被忽略,后果最严重)

① 默认请求体上限 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 注册)

# /etc/consul.d/order-service.json —— 放进配置目录后 consul reload 即可 { "service": { "name": "order-service", "id": "order-10.0.1.11-8080", "address": "10.0.1.11", "port": 8080, "tags": ["v2", "shop"], "meta": { "weight": "100" }, "checks": [ { "http": "http://10.0.1.11:8080/healthz", "interval": "5s", "timeout": "2s" }, { "tcp": "10.0.1.11:8080", "interval": "10s" } ] } }
# 用 DNS 接口发现服务:这是 Consul 最实用的能力(无需任何 SDK) dig @127.0.0.1 -p 8600 order-service.service.consul # 返回所有健康实例的 A 记录;不健康的实例默认不会出现在结果里 # 只查某个 tag 的实例:按版本做灰度(v2 的新实例) dig @127.0.0.1 -p 8600 v2.order-service.service.consul SRV # HTTP API 查询(可以拿到完整的元数据和健康状态) curl "http://127.0.0.1:8500/v1/health/service/order-service?passing=true" # KV 存储:常用来放动态配置(改完所有应用能立刻感知) consul kv put config/order/timeout_ms "3000" consul kv get config/order/timeout_ms # 集群与成员状态 consul members # 看所有节点的角色和存活状态(gossip 层) consul operator raft list-peers # 看谁是 Raft leader consul catalog services # 看注册了哪些服务
Consul 的四个坑

① 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 全景对比
维度ZooKeeperetcdConsul
共识协议ZABRaftRaft(+ 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)
一致性定位CPCPCP(默认一致性读;可选 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 自带的服务发现 vs 独立注册中心
能力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)通常还兼任"配置中心"——把动态配置热更新和服务发现放在一个组件里,是很多国内团队的实用选择。

注册中心的四个共性坑(ZK / etcd / 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 已经覆盖了大部分服务发现需求;跨集群、混合云、业务级路由、配置中心这几件事,才是独立注册中心真正的价值所在。

⑥ 无论选哪个:多节点 + 客户端本地缓存 + 优雅上下线这三件事不做,注册中心迟早会变成全站的故障放大器。