服务注册和发现
服务注册和发现
简介
服务注册和发现(Service Registration and Discovery)是微服务架构中最核心的基础设施之一。在微服务架构中,一个大型系统通常会被拆分为数十乃至上百个独立的服务,这些服务实例动态部署、弹性伸缩,IP 和端口随时可能变化。如果仍然依靠静态配置来维护服务调用关系,将面临配置臃肿、变更困难、容错性差等问题。
服务注册和发现机制通过引入一个注册中心(Registry)组件,让服务提供者在启动时主动注册自己的地址信息,服务消费者在调用时动态查询可用的服务提供者列表,从而实现了服务调用的解耦。它解决的核心问题包括:
- 动态寻址 - 服务实例 IP/端口频繁变化时,消费者无需修改配置即可发现新的服务实例。
- 负载分散 - 消费者可以获取多个服务提供者实例,配合负载均衡策略将请求分散到不同节点。
- 健康感知 - 注册中心通过心跳机制感知服务实例的健康状态,自动剔除不可用节点,避免请求被路由到故障实例。
- 弹性伸缩 - 新增或下线服务实例时,注册中心会自动通知消费者,支撑业务的弹性扩缩容。
可以说,服务注册和发现是微服务治理的基石,后续的服务路由、负载均衡、流量控制、熔断降级等治理能力都依赖于它提供的可用服务节点列表。
特性
服务注册和发现机制具有以下核心特性:
| 特性 | 说明 |
|---|---|
| 动态注册 | 服务提供者启动时自动注册,停止时自动注销,无需人工干预 |
| 实时感知 | 通过心跳机制和事件通知,消费者能近实时感知服务实例的上下线变化 |
| 健康检查 | 注册中心主动探测服务实例存活状态,自动隔离故障节点 |
| 高可用 | 注册中心本身通过集群部署保证高可用,避免单点故障 |
| 去中心化订阅 | 消费者本地缓存服务列表,即使注册中心短暂不可用也能继续调用 |
| 多数据中心支持 | 支持跨机房、跨地域的服务注册和发现,满足异地多活需求 |
主流注册中心对比:
| 特性 | ZooKeeper | Eureka | Consul | Nacos | etcd |
|---|---|---|---|---|---|
| CAP 模型 | CP | AP | CP | AP/CP(可切换) | CP |
| 一致性协议 | ZAB | 无(去中心化复制) | Raft | Raft + Distro | Raft |
| 健康检查 | 长连接 Session | 心跳 | TCP/HTTP/Script | TCP/HTTP/心跳 | 长连接 Lease |
| 多数据中心 | 不支持 | 不支持 | 支持 | 支持 | 不支持 |
| 管理控制台 | 第三方 | 内置 | 内置 | 内置 | 第三方 |
| Spring Cloud 集成 | 支持 | 支持(已停止维护) | 支持 | 支持 | 支持 |
服务注册和发现的基本原理
服务定义是服务提供者和服务消费者之间的约定,但是在微服务架构中,如何达成这个约定呢?这就依赖于服务注册和发现机制。
注册和发现的角色
在微服务架构下,服务注册和发现机制中主要有三种角色:
- 服务提供者(RPC Server / Provider)
- 服务消费者(RPC Client / Consumer)
- 服务注册中心(Registry)
服务发现通常依赖于注册中心来协调服务发现的过程,其步骤如下:
- 服务提供者将接口信息注册到注册中心。
- 服务消费者从注册中心读取和订阅服务提供者的地址信息。
- 如果有可用的服务,注册中心会主动通知服务消费者。
- 服务消费者根据可用服务的地址列表,调用服务提供者的接口。
这个过程很像是生活中的房屋租赁,房东将租房信息挂到中介公司,房客从中介公司查找租房信息。房客如果想要租房东的房子,通过中介公司牵线搭桥,联系上房东,双方谈妥签订协议,就可以正式建立起租赁关系。

主流的服务注册与发现的解决方案,主要有两种:
- 应用内注册与发现:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。
- 应用外注册与发现:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。
应用内注册与发现
应用内注册与发现方案是:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。最典型的案例要属 Netflix 开源的 Eureka,官方架构图如下:
Eureka 的架构主要由三个重要的组件组成:
- Eureka Server:注册中心的服务端,实现了服务信息注册、存储以及查询等功能。
- 服务端的 Eureka Client:集成在服务端的注册中心 SDK,服务提供者通过调用 SDK,实现服务注册、反注册等功能。
- 客户端的 Eureka Client:集成在客户端的注册中心 SDK,服务消费者通过调用 SDK,实现服务订阅、服务更新等功能。
应用外注册与发现
应用外注册与发现方案是:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。最典型的案例是开源注册中心 Consul。

Consul 实现应用外服务注册和发现主要依靠三个重要的组件:
- Consul:注册中心的服务端,实现服务注册信息的存储,并提供注册和发现服务。
- Registrator:一个开源的第三方服务管理器项目,它通过监听服务部署的 Docker 实例是否存活,来负责服务提供者的注册和销毁。
- Consul Template:定时从注册中心服务端获取最新的服务提供者节点列表并刷新 LB 配置(比如 Nginx 的 upstream),这样服务消费者就通过访问 Nginx 就可以获取最新的服务提供者信息。
注册中心的基本功能
从服务注册和发现的流程,可以看出,注册中心是服务发现的核心组件。常见的注册中心组件有:Nacos、Consul、Zookeeper 等。
注册中心的实现主要涉及几个问题:注册中心需要提供哪些接口,该如何部署;如何存储服务信息;如何监控服务提供者节点的存活;如果服务提供者节点有变化如何通知服务消费者,以及如何控制注册中心的访问权限。
元数据定义
构建微服务的首要问题是:服务提供者和服务消费者通信时,如何达成共识。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。
常见的定义服务元数据的方式有:
- XML 文件 - 如果只是企业内部之间的服务调用,并且都是 Java 语言的话,选择 XML 配置方式是最简单的。
- IDL 文件 - 如果企业内部存在多个跨语言服务,建议使用 IDL 文件方式进行描述服务。
- REST API - 如果存在对外开放服务调用的情形的话,使用 REST API 方式则更加通用。
XML 文件
XML 配置方式通过在服务提供者和服务消费者之间维持一份对等的 XML 配置文件,来保证服务消费者按照服务提供者的约定来进行服务调用。在这种方式下,如果服务提供者变更了接口定义,不仅需要更新服务提供者加载的接口描述文件 server.xml,还需要同时更新服务消费者加载的接口描述文件 client.xml。但这种方式对业务代码侵入性比较高,XML 配置有变更的时候,服务消费者和服务提供者都要更新,所以适合公司内部联系比较紧密的业务之间采用。支持 XML 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:基于 Spring XML 开发微服务应用)、微博的 Motan。
XML 文件这种方式的服务发布和引用主要分三个步骤:
(1)服务提供者定义接口,并实现接口。
// The demo service definition.
service DemoService {
rpc sayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}(2)服务提供者进程启动时,通过加载 xml 配置文件将接口暴露出去。
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-provider"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:protocol name="dubbo" port="20890"/>
<bean id="demoService" class="org.apache.dubbo.samples.basic.impl.DemoServiceImpl"/>
<dubbo:service interface="org.apache.dubbo.samples.basic.api.DemoService" ref="demoService"/>
</beans>(3)服务消费者进程启动时,通过加载 xml 配置文件来引入要调用的接口。
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-consumer"/>
<dubbo:registry group="aaa" address="zookeeper://127.0.0.1:2181"/>
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.samples.basic.api.DemoService"/>
</beans>IDL 文件
IDL 就是接口描述语言(interface description language)的缩写,通过一种中立、通用的方式来描述接口,使得在不同的平台上运行的对象和不同语言编写的程序可以相互通信交流。也就是说,IDL 主要用于跨语言的服务之间的调用。支持 IDL 文件的主流 RPC 有:阿里的 Dubbo(XML 配置示例:IDL 定义跨语言服务),Facebook 的 Thrift,Google 的 gRPC 。
以 gRPC 协议为例,gRPC 协议使用 Protobuf 简称 proto 文件来定义接口名、调用参数以及返回值类型。比如文件 helloword.proto 定义了一个接口 SayHello 方法,它的请求参数是 HelloRequest,它的返回值是 HelloReply。
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}假如服务提供者使用的是 Java 语言,那么利用 protoc 插件即可自动生成 Server 端的 Java 代码。
private class GreeterImpl extends GreeterGrpc.GreeterImplBase {
@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
@Override
public void sayHelloAgain(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello again " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}假如服务消费者使用的也是 Java 语言,那么利用 protoc 插件即可自动生成 Client 端的 Java 代码。
public void greet(String name) {
logger.info("Will try to greet " + name + " ...");
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
try {
response = blockingStub.sayHelloAgain(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
}假如服务消费者使用的是其他语言,也可以利用相应的插件生成代码。
由此可见,gRPC 协议的服务描述是通过 proto 文件来定义接口的,然后再使用 protoc 来生成不同语言平台的客户端和服务端代码,从而具备跨语言服务调用能力。
有一点特别需要注意的是,在描述接口定义时,IDL 文件需要对接口返回值进行详细定义。如果接口返回值的字段比较多,并且经常变化时,采用 IDL 文件方式的接口定义就不太合适了。一方面可能会造成 IDL 文件过大难以维护,另一方面只要 IDL 文件中定义的接口返回值有变更,都需要同步所有的服务消费者都更新,管理成本就太高了。
REST API
REST API 方式主要被用作 HTTP 或者 HTTPS 协议的接口定义,即使在非微服务架构体系下,也被广泛采用。由于 HTTP 本身就是公开标准网络协议,所以几乎没有什么额外学习成本。支持 REST API 的主流 RPC 有:Eureka,下面以 Eureka 为例。
服务提供者定义接口
@RestController
public class ProviderController {
private final DiscoveryClient discoveryClient;
public ProviderController(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
@GetMapping("/send")
public String send() {
String services = "Services: " + discoveryClient.getServices();
System.out.println(services);
return services;
}
}服务消费者消费接口
@RestController
public class ConsumerController {
private final LoadBalancerClient loadBalancerClient;
private final RestTemplate restTemplate;
public ConsumerController(LoadBalancerClient loadBalancerClient,
RestTemplate restTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
}
@GetMapping("/recv")
public String recv() {
ServiceInstance serviceInstance = loadBalancerClient.choose("eureka-provider");
String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/send";
System.out.println(url);
return restTemplate.getForObject(url, String.class);
}
}元数据存储
注册中心本质上是一个用于保存元数据的分布式存储。你如果明白了这一点,就会了解实现一个注册中心的所有要点都是围绕这个目标去构建的。
想要构建微服务,首先要解决的问题是,服务提供者如何发布一个服务,服务消费者如何引用这个服务。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。
服务的元数据信息通常有以下信息:
- 服务节点信息,如 IP、端口等。
- 接口定义,如接口名、请求参数、响应参数等。
- 请求失败的重试次数
- 序列化方式
- 压缩方式
- 通信协议
- 等等
在具体存储时,注册中心一般会按照“服务 - 分组 - 节点信息”的层次化的结构来存储。以 ZooKeeper 为例:
- 在 ZooKeeper 中,数据按目录层级存储,每个目录叫作 znode,并且其有一个唯一的路径标识。
- znode 可以包含数据和子 znode。
- znode 中的数据可以有多个版本,比如某一个 znode 下存有多个数据版本,那么查询这个路径下的数据需带上版本信息。

注册中心 API
既然是分布式存储,势必要提供支持读写数据的接口,也就是 API,一般来说,需要支持以下功能:
- 服务注册接口:服务提供者通过调用服务注册接口来完成服务注册。
- 服务反注册接口:服务提供者通过调用服务反注册接口来完成服务注销。
- 心跳汇报接口:服务提供者通过调用心跳汇报接口完成节点存活状态上报。
- 服务订阅接口:服务消费者通过调用服务订阅接口完成服务订阅,获取可用的服务提供者节点列表。
- 服务变更查询接口:服务消费者通过调用服务变更查询接口,获取最新的可用服务节点列表。
除此之外,为了便于管理,注册中心还必须提供一些后台管理的 API,例如:
- 服务查询接口:查询注册中心当前注册了哪些服务信息。
- 服务修改接口:修改注册中心中某一服务的信息。
服务健康检测
注册中心除了要支持最基本的服务注册和服务订阅功能以外,还必须具备对服务提供者节点的健康状态检测功能,这样才能保证注册中心里保存的服务节点都是可用的。注册中心通常使用长连接或心跳探测方式检查服务健康状态。
还是以 ZooKeeper 为例,它是基于 ZooKeeper 客户端和服务端的长连接和会话超时控制机制,来实现服务健康状态检测的。在 ZooKeeper 中,客户端和服务端建立连接后,会话也随之建立,并生成一个全局唯一的 Session ID。服务端和客户端维持的是一个长连接,在 SESSION_TIMEOUT 周期内,服务端会检测与客户端的链路是否正常,具体方式是通过客户端定时向服务端发送心跳消息(ping 消息),服务器重置下次 SESSION_TIMEOUT 时间。如果超过 SESSION_TIMEOUT 后服务端都没有收到客户端的心跳消息,则服务端认为这个 Session 就已经结束了,ZooKeeper 就会认为这个服务节点已经不可用,将会从注册中心中删除其信息。
服务状态变更通知
一旦注册中心探测到有服务提供者节点新加入或者被剔除,就必须立刻通知所有订阅该服务的服务消费者,刷新本地缓存的服务节点信息,确保服务调用不会请求不可用的服务提供者节点。注册中心通常基于服务状态订阅来实现服务状态变更通知。
继续以 ZooKeeper 为例,基于 ZooKeeper 的 Watcher 机制,来实现服务状态变更通知给服务消费者的。服务消费者在调用 ZooKeeper 的 getData 方法订阅服务时,还可以通过监听器 Watcher 的 process 方法获取服务的变更,然后调用 getData 方法来获取变更后的数据,刷新本地缓存的服务节点信息。
集群部署
注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。根据 CAP 理论,三种特性无法同时达成,必须在可用性和一致性之间做取舍。于是,根据不同侧重点,注册中心可以分为 CP 和 AP 两个阵营:
- CP 型注册中心 - 牺牲可用性来换取数据强一致性,最典型的例子就是 ZooKeeper,etcd,Consul 了。ZooKeeper 集群内只有一个 Leader,而且在 Leader 无法使用的时候通过算法选举出一个新的 Leader。这个 Leader 的目的就是保证写信息的时候只向这个 Leader 写入,Leader 会同步信息到 Followers,这个过程就可以保证数据的强一致性。但如果多个 ZooKeeper 之间网络出现问题,造成出现多个 Leader,发生脑裂的话,注册中心就不可用了。而 etcd 和 Consul 集群内都是通过 Raft 协议来保证强一致性,如果出现脑裂的话, 注册中心也不可用。
- AP 型注册中心 - 牺牲一致性(只保证最终一致性)来换取可用性,最典型的例子就是 Eureka 了。对比下 Zookeeper,Eureka 不用选举一个 Leader,每个 Eureka 服务器单独保存服务注册地址,因此有可能出现数据信息不一致的情况。但是当网络出现问题的时候,每台服务器都可以完成独立的服务。
以开源注册中心 ZooKeeper 为例,ZooKeeper 集群中包含多个节点,服务提供者和服务消费者可以同任意一个节点通信,因为它们的数据一定是相同的,这是为什么呢?这就要从 ZooKeeper 的工作原理说起:
- 每个 Server 在内存中存储了一份数据,Client 的读请求可以请求任意一个 Server。
- ZooKeeper 启动时,将从实例中选举一个 leader(Paxos 协议)。
- Leader 负责处理数据更新等操作(ZAB 协议)。
- 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改 。
通过上面这种方式,ZooKeeper 保证了高可用性以及数据一致性。

注册中心的扩展功能
多注册中心
对于服务消费者来说,要能够同时从多个注册中心订阅服务;
对于服务提供者来说,要能够同时向多个注册中心注册服务。
并行订阅服务
如果只支持串行订阅,如果服务消费者订阅的服务较多,并且某些服务节点的初始化连接过程中出现连接超时的情况,则后续所有的服务节点的初始化连接都需要等待它完成,这就会导致消费者启动非常慢。
可以每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了 30 秒以内。
批量注销服务
在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”。
需要定时去清理注册中心中的“僵尸节点”,如果支持批量注销服务,就可以一次调用就把该节点上提供的所有服务同时注销掉。
服务变更信息增量更新
为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息。尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴。
心跳开关保护机制
在网络频繁抖动的情况下,注册中心中可用的节点会不断变化,这时候服务消费者会频繁收到服务提供者节点变更的信息,于是就不断地请求注册中心来拉取最新的可用服务节点信息。当有成百上千个服务消费者,同时请求注册中心获取最新的服务提供者的节点信息时,可能会把注册中心的带宽给占满,尤其是注册中心是百兆网卡的情况下。
所以针对这种情况,需要一种保护机制,即使在网络频繁抖动的时候,服务消费者也不至于同时去请求注册中心获取最新的服务节点信息。
我曾经就遇到过这种情况,一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给 10% 的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的 1/10。
当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在 10s 内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。
服务节点摘除保护机制
服务提供者在进程启动时,会注册服务到注册中心,并每隔一段时间,汇报心跳给注册中心,以标识自己的存活状态。如果隔了一段固定时间后,服务提供者仍然没有汇报心跳给注册中心,注册中心就会认为该节点已经处于“dead”状态,于是从服务的可用节点信息中移除出去。
如果遇到网络问题,大批服务提供者节点汇报给注册中心的心跳信息都可能会传达失败,注册中心就会把它们都从可用节点列表中移除出去,造成剩下的可用节点难以承受所有的调用,引起“雪崩”。但是这种情况下,可能大部分服务提供者节点是可用的,仅仅因为网络原因无法汇报心跳给注册中心就被“无情”的摘除了。
这个时候就需要根据实际业务的情况,设定一个阈值比例,即使遇到刚才说的这种情况,注册中心也不能摘除超过这个阈值比例的节点。
这个阈值比例可以根据实际业务的冗余度来确定,我通常会把这个比例设定在 20%,就是说注册中心不能摘除超过 20% 的节点。因为大部分情况下,节点的变化不会这么频繁,只有在网络抖动或者业务明确要下线大批量节点的情况下才有可能发生。而业务明确要下线大批量节点的情况是可以预知的,这种情况下可以关闭阈值保护;而正常情况下,应该打开阈值保护,以防止网络抖动时,大批量可用的服务节点被摘除。
白名单机制
在实际的微服务测试和部署时,通常包含多套环境,比如生产环境一套、测试环境一套。开发在进行业务自测、测试在进行回归测试时,一般都是用测试环境,部署的 RPC Server 节点注册到测试的注册中心集群。但经常会出现开发或者测试在部署时,错误的把测试环境下的服务节点注册到了线上注册中心集群,这样的话线上流量就会调用到测试环境下的 RPC Server 节点,可能会造成意想不到的后果。
为了防止这种情况发生,注册中心需要提供一个保护机制,你可以把注册中心想象成一个带有门禁的房间,只有拥有门禁卡的 RPC Server 才能进入。在实际应用中,注册中心可以提供一个白名单机制,只有添加到注册中心白名单内的 RPC Server,才能够调用注册中心的注册接口,这样的话可以避免测试环境中的节点意外跑到线上环境中去。
静态注册中心
因为服务提供者是向服务消费者提供服务的,服务是否可用,服务消费者应该比注册中心更清楚。因此,可以直接在服务消费者端,根据调用服务提供者是否成功来判定服务提供者是否可用。如果服务消费者调用某一个服务提供者节点连续失败超过一定次数,可以在本地内存中将这个节点标记为不可用。并且每隔一段固定时间,服务消费者都要向标记为不可用的节点发起保活探测,如果探测成功了,就将标记为不可用的节点再恢复为可用状态,重新发起调用。
应用场景
服务注册和发现在以下场景中被广泛使用:
- 微服务架构治理 - 任何采用微服务架构的系统,只要服务实例数量超过单机规模,都需要服务注册和发现来管理服务调用关系。典型如电商系统的订单服务、商品服务、用户服务之间的相互调用。
- 弹性伸缩 - 在大促、秒杀等流量高峰场景下,通过自动扩容增加服务实例,注册中心自动通知消费者新实例上线,流量高峰过后缩容下线实例,全程无需修改配置。
- 多机房异地多活 - 服务在多个机房部署,通过注册中心的多数据中心支持,实现跨机房的服务发现和流量调度,提升系统的容灾能力。
- 灰度发布与蓝绿部署 - 新版本服务实例注册到注册中心后,通过路由规则逐步将流量引导到新版本实例,实现平滑的应用版本升级。
- 容器化与 K8s 部署 - 容器编排系统(如 Kubernetes)中 Pod 的 IP 动态变化,服务注册和发现可以自动感知 Pod 的变化,配合 Service 机制提供服务访问能力。
最佳实践
案例 1:使用 Nacos 实现服务注册和发现
Nacos 是阿里巴巴开源的注册中心和配置中心,支持 AP 和 CP 两种模式切换,在国内微服务生态中使用广泛。下面演示 Spring Cloud 应用接入 Nacos 的完整流程。
(1)引入依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>2022.0.0.0</version>
</dependency>(2)服务提供者配置
spring:
application:
name: user-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
namespace: public
group: DEFAULT_GROUP
# 心跳间隔(秒)
heart-beat-interval: 5
# 心跳超时时间(秒)
heart-beat-timeout: 15import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceProviderApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceProviderApplication.class, args);
}
}import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/users/{id}")
public String getUser(@PathVariable String id) {
return "User: " + id;
}
}(3)服务消费者配置与调用
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@RestController
public class OrderController {
@Autowired
private DiscoveryClient discoveryClient;
@Autowired
private RestTemplate restTemplate;
@GetMapping("/orders/{userId}")
public String createOrder(@PathVariable String userId) {
// 从注册中心获取服务实例列表
List<ServiceInstance> instances = discoveryClient.getInstances("user-service");
if (instances.isEmpty()) {
return "用户服务不可用";
}
// 简单的轮询选择实例
ServiceInstance instance = instances.get(0);
String url = instance.getUri() + "/users/" + userId;
return restTemplate.getForObject(url, String.class);
}
}生产环境建议使用
OpenFeign或LoadBalancer替代手动选择实例,它们已经内置了负载均衡能力。
案例 2:使用 Eureka 实现服务注册和发现
虽然 Eureka 2.x 已停止维护,但 Eureka 1.x 仍然被许多遗留系统使用,其设计理念也有很高的学习价值。
(1)搭建 Eureka Server
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>server:
port: 8761
eureka:
instance:
hostname: localhost
client:
# Eureka Server 自身不注册
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
# 关闭自我保护模式(生产环境建议开启)
enable-self-preservation: false
# 清理无效节点的间隔(毫秒)
eviction-interval-timer-in-ms: 60000import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}(2)服务提供者注册
spring:
application:
name: payment-service
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
# 心跳间隔(秒)
lease-renewal-interval-in-seconds: 10
# 失效时间(秒),超过该时间未收到心跳则剔除
lease-expiration-duration-in-seconds: 30
# 优先使用 IP 注册
prefer-ip-address: true案例 3:使用 ZooKeeper 实现服务注册和发现
ZooKeeper 是一个典型的 CP 型注册中心,适合对一致性要求极高的场景。下面演示基于 Curator 框架的服务注册和发现实现。
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-x-discovery</artifactId>
<version>5.5.0</version>
</dependency>import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.x.discovery.ServiceDiscovery;
import org.apache.curator.x.discovery.ServiceDiscoveryBuilder;
import org.apache.curator.x.discovery.ServiceInstance;
import org.apache.curator.x.discovery.details.JsonInstanceSerializer;
import java.util.Collection;
public class ZookeeperServiceRegistry {
private static final String CONNECT_STRING = "127.0.0.1:2181";
private static final String REGISTRY_PATH = "/services";
private final CuratorFramework client;
private final ServiceDiscovery<ServicePayload> serviceDiscovery;
public ZookeeperServiceRegistry() throws Exception {
client = CuratorFrameworkFactory.builder()
.connectString(CONNECT_STRING)
.sessionTimeoutMs(60000)
.connectionTimeoutMs(15000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
client.start();
JsonInstanceSerializer<ServicePayload> serializer =
new JsonInstanceSerializer<>(ServicePayload.class);
serviceDiscovery = ServiceDiscoveryBuilder.builder(ServicePayload.class)
.client(client)
.basePath(REGISTRY_PATH)
.serializer(serializer)
.build();
serviceDiscovery.start();
}
/**
* 注册服务
*/
public void registerService(String serviceName, String host, int port) throws Exception {
ServiceInstance<ServicePayload> instance = ServiceInstance.<ServicePayload>builder()
.name(serviceName)
.address(host)
.port(port)
.payload(new ServicePayload("1.0.0"))
.build();
serviceDiscovery.registerService(instance);
System.out.println("服务注册成功: " + serviceName + " -> " + host + ":" + port);
}
/**
* 发现服务
*/
public ServiceInstance<ServicePayload> discoverService(String serviceName) throws Exception {
Collection<ServiceInstance<ServicePayload>> instances =
serviceDiscovery.queryForInstances(serviceName);
if (instances.isEmpty()) {
return null;
}
// 简单选取第一个实例(生产环境应使用负载均衡算法)
return instances.iterator().next();
}
public static class ServicePayload {
private String version;
public ServicePayload() {
}
public ServicePayload(String version) {
this.version = version;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
}
}ZooKeeper 基于临时节点(Ephemeral Node)实现服务健康检测:服务提供者创建的临时节点在 Session 断开后会自动被 ZooKeeper 删除,从而实现服务的自动下线。
常见问题
问题 1:服务上线后消费者无法发现新实例
问题描述:服务提供者新增了一个实例并注册到注册中心,但服务消费者始终没有调用到新实例,仍只调用旧的实例。
原因分析:
- 服务消费者本地缓存了服务实例列表,且没有及时刷新缓存。
- 注册中心的通知机制存在问题,没有推送变更事件给消费者。
- 服务提供者注册的元数据不正确(如注册的是内网 IP,消费者无法访问)。
解决方案:
- 检查消费者是否订阅了注册中心的服务变更通知,确保 Watcher 监听器正常工作。
- 调整消费者本地缓存的刷新间隔。以 Nacos 为例:
spring:
cloud:
nacos:
discovery:
# 缓存刷新间隔(毫秒)
cache-refresh-interval: 10000- 确保服务提供者注册的 IP 是消费者可达的地址。如果是容器化部署,需要配置
prefer-ip-address或指定注册的网卡:
spring:
cloud:
nacos:
discovery:
# 指定注册的 IP
ip: 192.168.1.100
# 或者指定网卡
network-interface: eth0问题 2:注册中心节点全部宕机导致服务不可用
问题描述:注册中心集群所有节点同时宕机,导致服务消费者无法获取服务实例列表,服务调用全部失败。
原因分析:
- 注册中心集群部署时未做高可用规划,所有节点部署在同一机房或同一物理机上。
- 消费者强依赖注册中心,本地没有缓存服务实例列表,注册中心不可用时无法降级。
解决方案:
- 注册中心集群必须分散部署在不同物理机、不同机架甚至不同机房,避免单点故障。
- 消费者应启用本地缓存兜底机制。当注册中心不可用时,使用本地缓存的服务实例列表继续提供服务。以 Nacos 为例,本地缓存文件默认存储在
${user.home}/nacos/naming/目录下:
spring:
cloud:
nacos:
discovery:
# 启用本地缓存
naming-load-cache-at-start: true
# 本地缓存目录
cache-dir: ${user.home}/nacos/naming- 消费者配合熔断降级机制,在注册中心不可用且本地缓存也失效时,返回降级响应而非抛出异常:
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class ResilientController {
@Autowired
private DiscoveryClient discoveryClient;
@GetMapping("/call")
@HystrixCommand(fallbackMethod = "fallback")
public String callService() {
List<org.springframework.cloud.client.ServiceInstance> instances =
discoveryClient.getInstances("target-service");
if (instances.isEmpty()) {
throw new RuntimeException("无可用服务实例");
}
return "调用成功";
}
public String fallback() {
return "服务暂时不可用,请稍后重试";
}
}问题 3:网络抖动导致大量服务实例被误剔除
问题描述:机房网络出现短暂抖动,导致大量服务提供者的心跳上报失败,注册中心将这些实例全部标记为不可用并剔除,引发服务调用雪崩。
原因分析:
- 注册中心没有启用服务节点摘除保护机制,在网络抖动时将所有心跳失败的节点全部剔除。
- 心跳超时时间设置过短,网络稍有延迟就触发剔除。
解决方案:
- 启用注册中心的心跳保护机制。以 Eureka 的自我保护模式为例,当 15 分钟内心跳失败比例超过 85% 时,注册中心会保留所有实例信息不再剔除:
eureka:
server:
# 启用自我保护模式
enable-self-preservation: true
# 自我保护模式的阈值比例
renewal-percent-threshold: 0.85- 合理设置心跳间隔和超时时间,避免过短:
eureka:
instance:
# 心跳间隔(秒),建议 10-30 秒
lease-renewal-interval-in-seconds: 15
# 失效时间(秒),建议为心跳间隔的 3 倍以上
lease-expiration-duration-in-seconds: 90- 对于自研注册中心,实现节点摘除阈值保护:即使遇到网络问题,注册中心也不能摘除超过阈值比例(如 20%)的节点:
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class RegistryProtection {
/**
* 最大允许摘除的节点比例
*/
private static final double MAX_EVICT_RATIO = 0.2;
/**
* 尝试剔除不可用节点
*
* @param allNodes 所有节点
* @param deadNodes 不可用节点
* @return 实际被剔除的节点数
*/
public int evictDeadNodes(List<String> allNodes, List<String> deadNodes) {
int totalSize = allNodes.size();
int maxEvictCount = (int) Math.ceil(totalSize * MAX_EVICT_RATIO);
int actualEvictCount = Math.min(deadNodes.size(), maxEvictCount);
System.out.printf("总节点数:%d,不可用节点数:%d,最大允许剔除:%d,实际剔除:%d%n",
totalSize, deadNodes.size(), maxEvictCount, actualEvictCount);
return actualEvictCount;
}
}