一个朴素到常被忽略的问题,服务 A 要调用服务 B,但 B 有 10 个实例且 IP 经常变,A 怎么知道该调哪个 IP。在单体时代这不是问题,所有模块在一个进程里,直接方法调用。微服务化之后,每个服务独立部署、独立扩缩容、实例随时上下线,调用方不能再把目标 IP 写死,否则一次扩容或一次机器迁移就要改一堆配置然后重启。
注册中心就是回答这个问题的组件。它维护一份服务名到实例列表的动态映射,服务启动时把 IP 和端口注册进去,调用方从注册中心拿到实例列表,自己负载均衡挑一个调。听起来简单,但这个最基础的组件藏着不少分布式系统的经典难题,CAP 取舍、健康检查的语义、上下线的平滑性。这篇把这些拆开讲。
两种发现模式
服务发现有两种模式,区别在负载均衡由谁做。
客户端发现模式下,消费者直接从注册中心拉取实例列表,缓存在本地,自己挑一个实例调用。Ribbon 和 gRPC 自带的负载均衡走这条路。优点是少一跳网络,调用方直连目标实例,性能好。缺点是客户端逻辑重,每个客户端都要实现一套负载均衡和实例缓存逻辑,且客户端语言不同时各搞各的。
服务端发现模式下,请求先打到独立负载均衡器,负载均衡器查注册中心后转发到目标实例。客户端无感知,只管把请求发给 LB。Istio 这类 Service Mesh 走这条路,负载均衡能力下沉到基础设施,业务代码完全不用关心。代价是多一跳网络,LB 成了新的依赖。
Spring Cloud 默认客户端发现,Service Mesh 是服务端发现。选哪种取决于团队的基础设施成熟度,Mesh 化越深越倾向服务端发现。
图:客户端发现由消费者自行负载均衡直连,服务端发现由独立 LB 转发,区别在 LB 由谁做
注册中心的核心机制
注册中心的运转逻辑是一个发布订阅模型。服务提供者启动时向注册中心注册,把自己的服务名和 IP 端口写进去。注册中心维护这份映射,并在实例列表变化时通知订阅了这个服务的消费者。消费者拿到列表后缓存到本地,调用时直接用本地缓存,不再每次都查注册中心。
健康保活靠心跳。提供者定期向注册中心发心跳,注册中心一段时间没收到某实例的心跳,就判定它宕机,从列表里摘除,并把这个摘除事件推给消费者。实例主动下线时,向注册中心注销,注册中心推送下线通知。
这套机制的稳定性依赖于两个动作,注册中心要能及时感知实例变化,消费者要能及时收到通知。延迟出在哪一环,调用方就可能调到一个已经不存在的实例。
图:发布订阅模型——提供者注册与心跳,注册中心在实例变化时推送给消费者,调用用本地缓存
心跳的局限和探针的补充
纯心跳机制有个盲区。心跳只能证明进程还活着,不能证明服务能正常处理请求。一个进程线程池满了、DB 连接池耗尽了、依赖的外部服务挂了,进程还在发心跳,注册中心以为它健康,还在往它身上导流量,结果所有打到它身上的请求都超时。
更完善的健康检查是探针机制。存活探针判断进程是否还活着,通常用 HTTP 或 TCP 探测。就绪探针判断服务是否能正常处理请求,会检查依赖状态、连接池是否可用、缓存是否就绪。
这两个探针的语义必须分清。存活探针失败,意味着进程本身有问题,需要重启。就绪探针失败,意味着进程活着但暂时不能服务,应该摘除流量但不重启。把两者搞混会出大问题。把存活失败当成就绪处理,只摘流量不重启,一个僵死的进程一直占着资源不回收。把就绪失败当成存活处理去重启,重启后依赖还没恢复,就绪又失败,又被重启,陷入无限重启循环。
存活探针失败触发重启,就绪探针失败只摘流量不重启。这两个语义搞混,要么该重启的僵死进程一直不回收,要么不该重启的服务陷入无限重启循环。配探针时务必想清楚这个检查失败后想要的是摘流量还是重启。
CAP 取舍,服务发现为什么选 AP
注册中心是一个分布式的存储系统,绕不开 CAP 定理。C 是强一致性,A 是可用性,P 是分区容忍,网络分区时只能在 C 和 A 之间选一个。
CP 型注册中心选强一致,ZooKeeper、etcd、Consul 的强一致模式走这条路。数据强一致,所有节点看到的数据一样,但网络分区时,少数派节点为了不破坏一致性会拒绝服务,导致部分请求不可用。AP 型注册中心选高可用,Eureka、Nacos 的 AP 模式走这条路。分区时各节点都尽力服务,数据可能短暂不一致,但服务发现功能本身不停。
服务发现这个场景,业界共识是选 AP。原因是代价不对称。读到稍微过期的实例列表,比如一个刚下线的实例还在列表里,调用方调它失败重试一次就好,代价很小。但为了强一致导致注册中心在网络抖动时不可用,整个服务发现瘫痪,所有调用方都拿不到实例列表,代价大得多。一个偶发的失败重试,远比一个全局瘫痪容易接受。所以 Eureka、Nacos 默认 AP 模式。
推拉结合的通知机制
实例列表变化后,怎么通知消费者,有三种做法。
纯拉模式,消费者定时轮询注册中心拉取列表。实现简单,但延迟大,实例下线后要等下一个轮询周期消费者才知道,期间还会往死实例上导流量。纯推模式,注册中心主动推送变更。实时性好,但要维护大量长连接,注册中心的连接压力随消费者数量线性增长。
生产里常用的是推拉结合。注册中心只推一个有变更的轻量信号,消费者收到信号后主动拉取完整的新列表。这样推送的数据量小,长连接负担轻,又能保证消费者及时感知变更。哪怕推送丢了,消费者还有定时拉取兜底,最终会拉到最新列表。Nacos 默认就是推拉结合。
优雅上下线防止请求落空
实例上下线不是注销或注册一下就完事,过渡期的流量处理决定了会不会有请求落空。
优雅上线,新实例注册后不能立刻接全量流量。JIT 还没编译热点代码,缓存还没预热,连接池还没建好,这时一上来就接大流量,响应慢且容易出问题。正确做法是先让就绪探针通过,再逐步加大流量权重,给预热留时间。
优雅下线更要小心。实例主动从注册中心注销,但注销通知传播到所有消费者有延迟,这段延迟里仍有消费者往这个实例发请求。规范的下线流程是,实例先向注册中心注销,同时本地标记自己拒绝新请求,已经收到的存量请求继续处理完,等存量处理完再退出进程。这样既不会被新请求打扰,也不会让进行中的请求中断。
图:上线先就绪探针通过再逐步加权重;下线先注销+拒新请求+等存量处理完再退出
直接 kill -9 一个实例,存量请求立即中断,正在处理的用户请求直接失败。规范的下线是注销加拒绝新请求加等待存量处理完加优雅退出,整套走完才安全。生产环境禁止对有流量的实例用 kill -9,必须走优雅停机。
选型对比
主流注册中心的能力差异,主要在 CAP 取向和附加能力上。ZooKeeper 是 CP,用 ZAB 协议,适合强一致场景如选主和分布式锁,但做服务发现偏重。etcd 是 CP,用 Raft 协议,是 Kubernetes 的原生组件,云原生场景的首选。Consul 可 CP 可 AP,支持多数据中心,功能全面但相对重。Eureka 是 AP,纯 Java,和 Spring Cloud 集成好,但已停止维护。Nacos 可 AP 可 CP,AP 模式用 Distro 协议做服务发现,CP 模式用 Raft 做配置和元数据,还能兼做配置中心,阿里生态。
我的实战选择是新项目首选 Nacos。AP 模式适合服务发现的语义,又能兼做配置中心,一套基础设施解决注册和配置两个问题,运维成本最低。强一致诉求比如选主、分布式锁才考虑 ZooKeeper。Eureka 已停止维护,新项目不要选,没有人在为你兜底了。







