SpringCloud 微服务注册中心 Eureka - Client
前言
前段时间业务工作太忙,很久没有学习技术了~ 碰巧前几天工作中遇到一个问题需要研究注册中心和 OpenFeign
源码才能解决,由于 OpenFeign
源码我已经比较熟悉了,所以今天来分享一下 Eureka Client
向 Server
注册实例的原理。只有懂原理才能进行扩展,对项目进行不断优化。
也许很多人会说 Eureka
已经过时了,我又何尝不知道相对来说 Nacos
更好用,而且 Eureka
已经不更新了,但是没有办法呀,公司用的就是 Eureka
。不过大体思想都是相同的,只是 Nacos
在 Eureka
的基础上做了一些优化让服务注册中心更加完善。读完 Eureka
源码还会怕 Nacos
么?
注册中心简介
回想微服务架构流行之前,在没有注册中心的时候两个系统的交互通常是通过 http 请求远程调用。这样会存在一些问题,需要写死服务提供方的 ip端口或者域名。随着业务规模发展,系统应用越来越多,复杂的业务交互让我们每个应用都不得不维护其他应用的ip端口信息。
假设有上图的九个应用,由于他们之间都可能存在交互,那么每个应用都要在自己本地写一份其他八个应用的地址端口信息,这无疑给维护带来巨大的工作量。如果每个系统都有域名的话还好,互相写死域名地址就行,新增实例的时候只需要在 Nginx 配置文件添加负载均衡节点就行。
如果系统没有域名那就苦逼了,每个服务都得存其他应用的 IP端口信息
private Map<String,List<InstanceInfo>> map; //key 是应用名,value 是实例集群
新增、减少实例的时候也得去改动这个 map
,那么最简单的方式就是把这个 map 从配置文件 application.yml
读取,方便动态修改。但是每个应用都得这样操作无疑也是很大的工作量,于是我们想是否可以将所有应用的实例信息放在一个公共的地方来维护,于是服务注册中心诞生了。
服务注册中心就是解决这个问题的,独立出一个单独的微服务来存储所有微服务应用的实例信息,并且它是存储在内存中,当微服务上线、下线的时候实时修改这份共享的数据,确保客户端来这里查询其他服务实例信息的时候能够获取到最准确的信息。
搭建 Eureka Server
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
配置文件
eureka:
server:
enable-self-preservation: false
eviction-interval-timer-in-ms: 5000
instance:
hostname: localhost
prefer-ip-address: true
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
启动类添加 @EnableEurekaServer
搭建 Eureka Client
引入 Eureka Client
依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
配置文件
eureka:
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${spring.application.name}:${server.port}
lease-expiration-duration-in-seconds: 15 #eureka服务收到最后一次心跳后等待时间上限,默认值 90
lease-renewal-interval-in-seconds: 10 #向eureka服务发送心跳的时间间隔,默认值 30
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/ # eureka server 地址
registry-fetch-interval-seconds: 3 #3秒钟去eureka拿一次微服务注册实例,默认值 30
老版本还需要在启动类使用 @EnableEurekaClient
注解,新版本中可以省略。这样一个 Eureka Client
就完成了。
可以发现 Eureka
的 Server
和 Client
搭建非常简单易用。
Eureka Client 启动流程源码分析
看源码的技巧
看源码是有技巧的,怎么看?从哪看?看什么?
- 先找到入口
- 然后从宏观维度去看,这个类大致干了什么,那个类大致干了什么,尽量避免一开始就死怼一个方法扣细节。
- 有个大体的了解,草稿上记录下来一堆类之间的关系
- 开始从方法层面逐行 debug
PS 其实我不太喜欢看源码,这个我很早就在文章里面说了,但凡是看源码的都是工作中遇到了问题,需要去看源码怎么实现的,懂了源码的实现,才能在此基础上去扩展出符合本公司业务的组件。我个人也建议大家一定是带着问题去看源码,有针对性的看,才能事半功倍。
寻找入口
首先我们需要找到 Eureka Client
的启动入口,一贯作风,通过配置文件中的 lease-renewal-interval-in-seconds
,定位到配置类,再根据 getLeaseRenewalIntervalInSeconds()
方法和不断的 debug 一路向上寻找,最终找到了入口在 EurekaAutoServiceRegistration.start()
。当然我们从组件 META-INF
下的 spring.factories
中的自动配置类去寻找也是一个不错的选择。
耐心
你看我的描述非常简单,通过这个属性就找到了入口,但其实并非如此,这个还是需要一定的基本功和耐心的......慢慢的打断点,不停的 F8、F7、shift+F7、F9
最终贯穿源码。
SmartLifecycle
观察 EurekaAutoServiceRegistration
发现它实现了 SmartLifecycle
接口,我们应该知道实现了该接口的 Spring Bean
会在所有没被 @Lazy
标注的 Bean
加载和初始化完毕执行。(这意味着它是一个隐形的入口,如果不通过 debug 是不太方便找到的)
EurekaServiceRegistry
观察 EurekaAutoServiceRegistration
声明的地方发现 EurekaServiceRegistry
它实现了 ServiceRegistry
接口
public interface ServiceRegistry<R extends Registration> {
void register(R registration);
void deregister(R registration);
观察该接口源码发现了两个核心方法,该接口就是用来向服务注册中心注册和销毁实例的,并且它是属于 spring-cloud-commons
包的。由于我们当前使用的注册中心产品是 Eureka
所以实现类是 EurekaServiceRegistry
,由此可以扩展当我们后面切换 Nacos
时,也会有一个 NacosServiceRegistry
,这就是我们的扩展点。
CloudEurekaClient
在 EurekaServiceRegistry.register()
中通过动态代理执行被 @Lazy
标注的 CloudEurekaClient
Bean 声明方法
EurekaRegistration.getEurekaClient()
public CloudEurekaClient getEurekaClient() {
if (this.cloudEurekaClient.get() == null) {
try {
this.cloudEurekaClient.compareAndSet(null, getTargetObject(eurekaClient, CloudEurekaClient.class));
}
catch (Exception e) {
log.error("error getting CloudEurekaClient", e);
}
}
return this.cloudEurekaClient.get();
}
protected <T> T getTargetObject(Object proxy, Class<T> targetClass) throws Exception {
if (AopUtils.isJdkDynamicProxy(proxy)) {
return (T) ((Advised) proxy).getTargetSource().getTarget();
}
//...
}
getTargetObject()
方法内部代码会执行到 EurekaClientAutoConfiguration.RefreshableEurekaClientConfiguration.eurekaClient()
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(value = EurekaClient.class, search = SearchStrategy.CURRENT)
@RefreshScope
@Lazy
public EurekaClient eurekaClient(ApplicationInfoManager manager, EurekaClientConfig config,
EurekaInstanceConfig instance, @Autowired(required = false) HealthCheckHandler healthCheckHandler) {
//...
CloudEurekaClient cloudEurekaClient = new CloudEurekaClient(appManager, config, this.optionalArgs, this.context);
cloudEurekaClient.registerHealthCheck(healthCheckHandler);
return cloudEurekaClient;
}
通过源码发现它继承了原生 Netflix
的 DiscoveryClient
,是 SpringCloud
整合 Netflix
的一套桥梁,我们都知道 SpringCloud
自己写了一套组件 spring-cloud-starter-netflix-eureka-client/server
去整合 Netflix Eureka
。核心的服务注册逻辑还是在原生 DiscoveryClient
构造方法中。
DiscoveryClient
这个类是服务注册的核心,在它的构造方法中有几处关键的地方
1. 服务实例信息拉取
if (clientConfig.shouldFetchRegistry()) {
//...
boolean primaryFetchRegistryResult = fetchRegistry(false);
//...
}
如果我们配置了去服务注册中心获取已注册服务信息,这里会去 Eureka Server
获取所有服务实例信息存到内存。
2. 初始化定时任务
initScheduledTasks();
初始化以下三个定时任务
- 定时缓存刷新 (CacheRefreshThread):在配置的规则下每隔一段时间去eureka server 拉取一次服务实例
- 定时心跳检测 (HeartbeatThread):在配置的规则下每隔一段时间向 eureka server 发送心跳表明自己正常
- 定时信息上报 (InstanceInfoReplicator):在配置的规则下每隔一段时间向 eureka server 同步信息
3. 状态改变监听器
statusChangeListener = new ApplicationInfoManager.StatusChangeListener() {
@Override
public String getId() {
return "statusChangeListener";
}
@Override
public void notify(StatusChangeEvent statusChangeEvent) {
logger.info("Saw local status change event {}", statusChangeEvent);
instanceInfoReplicator.onDemandUpdate();
}
};
状态改变监听器的匿名实现,当状态发生改变时会走到这,将信息上报到 Eureka Server
4. 发布状态改变事件
ApplicationInfoManager.setInstanceStatus
中调用了通知方法
public synchronized void setInstanceStatus(InstanceStatus status) {
InstanceStatus next = instanceStatusMapper.map(status);
if (next == null) {
return;
}
InstanceStatus prev = instanceInfo.setStatus(next);
if (prev != null) {
for (StatusChangeListener listener : listeners.values()) {
try {
listener.notify(new StatusChangeEvent(prev, next));
} catch (Exception e) {
logger.warn("failed to notify listener: {}", listener.getId(), e);
}
}
}
}
该方法在以下几个方法中都会被调用
EurekaServiceRegistry.register()
EurekaServiceRegistry.deregister()
DiscoveryClient.shutdown()
DiscoveryClient.refreshInstanceInfo()
执行 notify()
时会走到第3步声明的匿名实现类,将服务信息、状态实时同步到 Eureka Server
。
DiscoveryClient.register()
最终会调用该方法到 Eureka Server
进行真正的注册,这里 Eureka
是使用 jersey-client
发起一个 Http 请求
httpResponse = eurekaTransport.registrationClient.register(instanceInfo);
这里之后 Eureka Server
端就会保存我们这个 Eureka Client
的服务实例信息,换句话说我们的服务就在注册中心里了。并且我们当前的 Eureka Client
也拿到了所有被 Eureka Server
管理的其他 Eureka Client
。
发布事件
完成注册之后会发布 InstanceRegisteredEvent
,如果我们有需要可以监听该事件进行一些处理逻辑
this.context.publishEvent(new InstanceRegisteredEvent<>(this, this.registration.getInstanceConfig()));
总结
通过文章的形式无法表现出完整的源码流程,这个需要读者自己去动手 debug 源码才能有切身的体会,文章已经列出了关键代码节点。这里附一个大致的时序图(非 UML 专业人士,能看懂就行了。。)
不过博主后面也会考虑做视频~~

定时缓存刷新
缓存的微服务实例信息用途
观察源码我们会发现从 Server
端查询的微服务实例信息存储在 DiscoveryClient
的成员变量 localRegionApps
中。
private final AtomicReference<Applications> localRegionApps = new AtomicReference<>();
这东西是干什么用的?其实这个是给负载均衡的时候选择请求实例用的,使用 OpenFeign
跨服务调用的时候,负载均衡组件(例如Netflix Ribbon、SpringCloud LoadBalancer
)会从localRegionApps
中拿到所有目标服务的实例集群,根据负载均衡算法选择出一个实例。
关于负载均衡组件 SpringCloud LoadBalancer
会在后面的文章中介绍(我阅读 Eureka 源码的其实就是因为它)。
为什么需要定时刷新
为什么 Eureka Client
要定时从 Server
端查询所有暴露的微服务实例信息。其实这很容易理解,前面我们知道 Client
在启动的时候会从 Server
查询一次微服务实例存到内存,但是当微服务上线、下线的时候并不会来通知我们,所以我们得有一个定时机制去获取刷新,否则会请求到已经下线的实例。
定时刷新的弊端
假设我们配置的是 30S
去 Server
拉取一次服务实例信息,假设在 30S
内有服务挂了呢?那么此时我们这个 localRegionApps
存储的数据还没有更新,我们是有可能在负载均衡的时候被选到这台已经挂掉的服务实例的,如下图
这样就会导致这次请求失败。那么有没有可能让 Eureka Server
接受到服务上下线的时候主动去推送消息给所有 Client
避免这个问题 ?
剧透一下,即使可以主动推送,也无法避免这个问题。因为负载均衡组件是把
localRegionApps
缓存了一份的,而不是直接从localRegionApps
实时读取,所以即使它客户端实时更新了,在负载均衡组件的缓存失效期间,仍然无法避免,除非不用缓存。
为什么 Eureka Server 不主动推送?
针对上述问题我们可以思考一下,为什么 Eureka
的设计不是当 Server
收到服务上线和下线的时候去推送给所有 Client
,而是让所有 Client
自己定时去 Server
拉取?如果是主动推送,对于 Client
端来说实时性非常高,几乎不会出现缓存的微服务实例脏数据的情况,也就是不会请求到下线的实例。
正常的服务下线是完全可行的,我们只要在 Spring
容器销毁的时候做这个操作就行了,但是有一种情况是异常宕机,这种情况我们是没有办法通知到 Eureka Server
的,那么自然 Eureka Server
也就没法主动通知其他 Client
。
没有办法优化吗?其实是有的,在 Nacos
后续的版本中就做了改善,因为 Eureka
已经停止更新了,所以后面也不会去优化了,等学习 Nacos
的时候我们再详细介绍。
定时心跳检测
关于定时心跳检测很容易理解,就是定时告诉 Eureka Server
,当前实例还是正常在线的,定期进行一个重新续约
定时上报服务信息
DiscoveryClient.initScheduledTasks()
中最后有一行代码
instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());//默认是 40
public void start(int initialDelayMs) {
if (started.compareAndSet(false, true)) {
instanceInfo.setIsDirty(); // for initial register
Future next = scheduler.schedule(this, initialDelayMs, TimeUnit.SECONDS);
scheduledPeriodicRef.set(next);
}
}
这里我们可以看到在 schedule()
的第一个参数传了 this
,所以 InstanceInfoReplicator
类一定是实现了 Runnable
接口,直接看 run()
方法。
public void run() {
try {
discoveryClient.refreshInstanceInfo();
Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
if (dirtyTimestamp != null) {
discoveryClient.register();
instanceInfo.unsetIsDirty(dirtyTimestamp);
}
} catch (Throwable t) {
logger.warn("There was a problem with the instance info replicator", t);
} finally {
Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
scheduledPeriodicRef.set(next);
}
}
可以看到 run()
方法中第一步是刷新实例信息,然后就是调用注册方法提交给 Eureka Server
,阅读 discoveryClient.refreshInstanceInfo()
源码可以发现,其实就是获取当前实例的配置信息(例如心跳时间等)、IP等信息,如果发生变更可以及时的同步给 Eureka Server
通过 Zone 划分服务区域
相信大家都能遇到这个问题,前提条件我们有一个 develop
开发环境,这个环境有一整套微服务应用。当我们在本机进行开发调试的时候会在 IDEA 中启动应用,例如是 hosjoy-b2b-product
。此时 Eureka
注册中心里面就会有两个商品服务,一个是我们本机 IDEA 启动的,一个是 develop 环境的,当我们本机再启动一个 hosjoy-b2b-order
,然后从本机 hosjoy-b2b-order
调用 hosjoy-b2b-product
的时候会触发负载均衡调用,一个请求到 develop 环境,一个请求到本机,
但是这不是我们的本意,我们肯定希望每次调用都能精准的调用到本机的服务,方便我们调试问题。
上图 192.168.1.131
是我们本机,我们希望达到的效果是,当我本机启动了应用,那么优先调用我本机的,当我本机没启动时就调用开发环境里面的。
Eureka
提供了一个区域 Zone
的配置,我们可以将某些实例划分到一个 Zone
下,这样在跨服务调用的时候会优先选择相同 Zone
的服务进行调用,如果没有相同 Zone
的会随机调用。
所有微服务都在同一个机器上
这样情况所有微服务都拥有相同的 IP ,我们本机启动的服务肯定也是相同的 IP,可以直接使用 IP 来做 zone
。
eureka:
instance:
metadata-map:
zone: ${spring.cloud.client.ip-address}
微服务部署在多个机器上
这种情况我们就不能使用 IP 来控制了,可以在 develop 环境配置一个固定的 zone = develop.yinshantech.cn
,然后在本机我们配置 zone = ${spring.cloud.client.ip-address}
,然后让本机配置覆盖配置中心的配置,这样 develop 环境的微服务拥有相同的 zone ,我们本机启动的应用拥有相同的 zone ,还是实现了上面的功能。
结语
本篇文章介绍了注册中心, Eureka Client
端的启动流程。下一篇会介绍 Eureka Server
端的一些细节。
如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!
转载自:https://juejin.cn/post/7161695824173334541