- 推荐序一
- 推荐序二
- 推荐序三
- 推荐语
- 前言
- 第1章 基础知识
- 第2章 微服务构建:Spring Boot
- 第3章 服务治理:Spring Cloud Eureka
- 第4章 客户端负载均衡:Spring Cloud Ribbon
- 第5章 服务容错保护:Spring Cloud Hystrix
- 第6章 声明式服务调用:Spring Cloud Feign
- 第7章 API网关服务:Spring Cloud Zuul
- 第8章 分布式配置中心:Spring Cloud Config
- 第9章 消息总线:Spring Cloud Bus
- 第10章 消息驱动的微服务:Spring Cloud Stream
- 附录 A Starter POMs
- 后记
Eureka 详解
在上一节中,我们通过一个简单的服务注册与发现示例,构建了Eureka服务治理体系中的三个核心角色:服务注册中心、服务提供者以及服务消费者。通过上述示例,相信读者对于Eureka的服务治理机制已经有了一些初步的认识。至此,我们已经学会了如何构建服务注册中心(包括单节点和高可用部署),也知道了如何使用 Eureka 的注解和配置将Spring Boot应用纳入Eureka的服务治理体系,成为服务提供者或是服务消费者。同时,对于客户端负载均衡的服务消费也有了一些简单的接触。但是,在实践中,我们的系统结构往往都要比上述示例复杂得多,如果仅仅依靠之前构建的服务治理内容,大多数情况是无法完全直接满足业务系统需求的,我们还需要根据实际情况来做一些配置、调整和扩展。所以,在本节中,我们将详细介绍Eureka的基础架构、节点间的通信机制以及一些进阶的配置等。
基础架构
在“服务治理”一节中,我们所讲解的示例虽然简单,但是麻雀虽小、五脏俱全。它已经包含了整个Eureka服务治理基础架构的三个核心要素。
- 服务注册中心: Eureka提供的服务端,提供服务注册与发现的功能,也就是在上一节中我们实现的eureka-server。
- 服务提供者: 提供服务的应用,可以是Spring Boot应用,也可以是其他技术平台且遵循Eureka通信机制的应用。它将自己提供的服务注册到Eureka,以供其他应用发现,也就是在上一节中我们实现的HELLO-SERVICE应用。
- 服务消费者: 消费者应用从服务注册中心获取服务列表,从而使消费者可以知道去何处调用其所需要的服务,在上一节中使用了Ribbon来实现服务消费,另外后续还会介绍使用Feign的消费方式。
很多时候,客户端既是服务提供者也是服务消费者。
服务治理机制
在体验了 Spring Cloud Eureka 通过简单的注解配置就能实现强大的服务治理功能之后,我们来进一步了解一下Eureka基础架构中各个元素的一些通信行为,以此来理解基于Eureka实现的服务治理体系是如何运作起来的。以下图为例,其中有这样几个重要元素:
- “服务注册中心-1”和“服务注册中心-2”,它们互相注册组成了高可用集群。
- “服务提供者”启动了两个实例,一个注册到“服务注册中心-1”上,另外一个注册到“服务注册中心-2”上。
- 还有两个“服务消费者”,它们也都分别只指向了一个注册中心。
根据上面的结构,下面我们来详细了解一下,从服务注册开始到服务调用,及各个元素所涉及的一些重要通信行为。
服务提供者
服务注册
“服务提供者”在启动的时候会通过发送REST请求的方式将自己注册到Eureka Server上,同时带上了自身服务的一些元数据信息。Eureka Server接收到这个REST请求之后,将元数据信息存储在一个双层结构Map中,其中第一层的key是服务名,第二层的key是具体服务的实例名。(我们可以回想一下之前在实现Ribbon负载均衡的例子中,Eureka信息面板中一个服务有多个实例的情况,这些内容就是以这样的双层Map形式存储的。)
在服务注册时,需要确认一下 eureka.client.register-with-eureka=true参数是否正确,该值默认为true。若设置为false将不会启动注册操作。
服务同步
如架构图中所示,这里的两个服务提供者分别注册到了两个不同的服务注册中心上,也就是说,它们的信息分别被两个服务注册中心所维护。此时,由于服务注册中心之间因互相注册为服务,当服务提供者发送注册请求到一个服务注册中心时,它会将该请求转发给集群中相连的其他注册中心,从而实现注册中心之间的服务同步。通过服务同步,两个服务提供者的服务信息就可以通过这两台服务注册中心中的任意一台获取到。
服务续约
在注册完服务之后,服务提供者会维护一个心跳用来持续告诉Eureka Server:“我还活着”,以防止 Eureka Server 的“剔除任务”将该服务实例从服务列表中排除出去,我们称该操作为服务续约(Renew)。
关于服务续约有两个重要属性,我们可以关注并根据需要来进行调整:
eureka.instance.lease-renewal-interval-in-seconds=30
eureka.instance.lease-expiration-duration-in-seconds=90
eureka.instance.lease-renewal-interval-in-seconds 参数用于定义服务续约任务的调用间隔时间,默认为30秒。eureka.instance.lease-expirationduration-in-seconds参数用于定义服务失效的时间,默认为90秒。
服务消费者
获取服务
到这里,在服务注册中心已经注册了一个服务,并且该服务有两个实例。当我们启动服务消费者的时候,它会发送一个 REST 请求给服务注册中心,来获取上面注册的服务清单。为了性能考虑,Eureka Server会维护一份只读的服务清单来返回给客户端,同时该缓存清单会每隔30秒更新一次。
获取服务是服务消费者的基础,所以必须确保eureka.client.fetch-registry=true参数没有被修改成false,该值默认为true。若希望修改缓存清单的更新时间,可以通过eureka.client.registry-fetch-interval-seconds=30参数进行修改,该参数默认值为30,单位为秒。
服务调用
服务消费者在获取服务清单后,通过服务名可以获得具体提供服务的实例名和该实例的元数据信息。因为有这些服务实例的详细信息,所以客户端可以根据自己的需要决定具体调用哪个实例,在Ribbon中会默认采用轮询的方式进行调用,从而实现客户端的负载均衡。
对于访问实例的选择,Eureka中有Region和Zone的概念,一个Region中可以包含多个Zone,每个服务客户端需要被注册到一个Zone中,所以每个客户端对应一个Region和一个Zone。在进行服务调用的时候,优先访问同处一个 Zone 中的服务提供方,若访问不到,就访问其他的Zone,更多关于Region和Zone的知识,我们会在后续的源码解读中介绍。
服务下线
在系统运行过程中必然会面临关闭或重启服务的某个实例的情况,在服务关闭期间,我们自然不希望客户端会继续调用关闭了的实例。所以在客户端程序中,当服务实例进行正常的关闭操作时,它会触发一个服务下线的REST请求给Eureka Server,告诉服务注册中心:“我要下线了”。服务端在接收到请求之后,将该服务状态置为下线(DOWN),并把该下线事件传播出去。
服务注册中心
失效剔除
有些时候,我们的服务实例并不一定会正常下线,可能由于内存溢出、网络故障等原因使得服务不能正常工作,而服务注册中心并未收到“服务下线”的请求。为了从服务列表中将这些无法提供服务的实例剔除,Eureka Server在启动的时候会创建一个定时任务,默认每隔一段时间(默认为60秒)将当前清单中超时(默认为90秒)没有续约的服务剔除出去。
自我保护
当我们在本地调试基于Eureka的程序时,基本上都会碰到这样一个问题,在服务注册中心的信息面板中出现类似下面的红色警告信息:
EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP WHEN THEY'RE NOT.RENEWALS ARE LESSER THAN THRESHOLD AND HENCE THE INSTANCES ARE NOT BEING EXPIRED JUST TO BE SAFE.
实际上,该警告就是触发了Eureka Server的自我保护机制。之前我们介绍过,服务注册到Eureka Server之后,会维护一个心跳连接,告诉Eureka Server自己还活着。Eureka Server在运行期间,会统计心跳失败的比例在15分钟之内是否低于85%,如果出现低于的情况(在单机调试的时候很容易满足,实际在生产环境上通常是由于网络不稳定导致),Eureka Server 会将当前的实例注册信息保护起来,让这些实例不会过期,尽可能保护这些注册信息。但是,在这段保护期间内实例若出现问题,那么客户端很容易拿到实际已经不存在的服务实例,会出现调用失败的情况,所以客户端必须要有容错机制,比如可以使用请求重试、断路器等机制。
由于本地调试很容易触发注册中心的保护机制,这会使得注册中心维护的服务实例不那么准确。所以,我们在本地进行开发的时候,可以使用 eureka.server.enableself-preservation=false参数来关闭保护机制,以确保注册中心可以将不可用的实例正确剔除。
源码分析
上面,我们对 Eureka 中各个核心元素的通信行为做了详细的介绍,相信大家已经对Eureka的运行机制有了一定的了解。为了更深入地理解它的运作和配置,下面我们结合源码来分别看看各个通信行为是如何实现的。
在看具体源码之前,我们先回顾一下之前所实现的内容,从而找到一个合适的切入口去分析。首先,对于服务注册中心、服务提供者、服务消费者这三个主要元素来说,后两者(也就是Eureka客户端)在整个运行机制中是大部分通信行为的主动发起者,而注册中心主要是处理请求的接收者。所以,我们可以从Eureka的客户端作为入口看看它是如何完成这些主动通信行为的。
我们在将一个普通的Spring Boot应用注册到Eureka Server或是从Eureka Server中获取服务列表时,主要就做了两件事:
- 在应用主类中配置了@EnableDiscoveryClient注解。
- 在 application.properties中用eureka.client.serviceUrl.defaultZone
参数指定了服务注册中心的位置。
顺着上面的线索,我们来看看@EnableDiscoveryClient的源码,具体如下:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(EnableDiscoveryClientImportSelector.class)
public @interface EnableDiscoveryClient {
}
从该注解的注释中我们可以知道,它主要用来开启DiscoveryClient的实例。通过搜索DiscoveryClient,我们可以发现有一个类和一个接口。通过梳理可以得到如下图所示的关系:
其中,左边的org.springframework.cloud.client.discovery.DiscoveryClient是Spring Cloud的接口,它定义了用来发现服务的常用抽象方法,通过该接口可以有效地屏蔽服务治理的实现细节,所以使用Spring Cloud构建的微服务应用可以方便地切换不同服务治理框架,而不改动程序代码,只需要另外添加一些针对服务治理框架的配置即可。org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient是对该接口的实现,从命名来判断,它实现的是对 Eureka 发现服务的封装。所以EurekaDiscoveryClient 依赖了 Netflix Eureka 的 com.netflix.discovery.EurekaClient接口,EurekaClient继承了LookupService接口,它们都是Netflix开源包中的内容,主要定义了针对Eureka的发现服务的抽象方法,而真正实现发现服务的则是Netflix包中的com.netflix.discovery.DiscoveryClient类。
接下来,我们就来详细看看DiscoveryClient类吧。先解读一下该类头部的注释,注释的大致内容如下所示:
这个类用于帮助与Eureka Server互相协作。
Eureka Client负责下面的任务:
-向Eureka Server注册服务实例
-向Eureka Server服务租约
-当服务关闭期间,向Eureka Server取消租约
-查询Eureka Server中的服务实例列表
Eureka Client还需要配置一个Eureka Server的URL列表。
在具体研究Eureka Client负责完成的任务之前,我们先看看在哪里对Eureka Server的URL列表进行配置。根据我们配置的属性名eureka.client.serviceUrl.defaultZone,通过 serviceUrl 可以找到该属性相关的加载属性,但是在 SR5版本中它们都被@Deprecated标注为不再建议使用,并@link到了替代类com.netflix.discovery.endpoint.EndpointUtils,所以我们可以在该类中找到下面这个函数:
public static Map<String,List<String>> getServiceUrlsMapFromConfig(
EurekaClientConfig clientConfig,String instanceZone,boolean
preferSameZone){
Map<String,List<String>> orderedUrls=new LinkedHashMap<>();
String region=getRegion(clientConfig);
String[]availZones=clientConfig.getAvailabilityZones(clientConfig.getRegion());
if(availZones==null || availZones.length==0){
availZones=new String[1];
availZones[0]=DEFAULT_ZONE;
}
……
int myZoneOffset=getZoneOffset(instanceZone,preferSameZone,availZones);
String zone=availZones[myZoneOffset];
List<String> serviceUrls=clientConfig.getEurekaServerServiceUrls(zone);
if(serviceUrls !=null){
orderedUrls.put(zone,serviceUrls);
}
……
return orderedUrls;
}
Region、Zone
在上面的函数中,可以发现,客户端依次加载了两个内容,第一个是Region,第二个是Zone,从其加载逻辑上我们可以判断它们之间的关系:
- 通过getRegion函数,我们可以看到它从配置中读取了一个Region返回,所以一个微服务应用只可以属于一个Region,如果不特别配置,默认为default。若我们要自己设置,可以通过eureka.client.region属性来定义。
public static String getRegion(EurekaClientConfig clientConfig){
String region=clientConfig.getRegion();
if(region==null){
region=DEFAULT_REGION;
}
region=region.trim().toLowerCase();
return region;
}
- 通过 getAvailabilityZones 函数,可以知道当我们没有特别为Region 配置Zone 的时候,将默认采用 defaultZone,这也是我们之前配置参数 eureka.client.serviceUrl.defaultZone的由来。若要为应用指定Zone,可以通过eureka.client.availability-zones属性来进行设置。从该函数的return内容,我们可以知道 Zone 能够设置多个,并且通过逗号分隔来配置。由此,我们可以判断Region与Zone是一对多的关系。
public String[]getAvailabilityZones(String region){
String value=this.availabilityZones.get(region);
if(value==null){
value=DEFAULT_ZONE;
}
return value.split(",");
}
serviceUrls
在获取了Region和Zone的信息之后,才开始真正加载Eureka Server的具体地址。它根据传入的参数按一定算法确定加载位于哪一个Zone配置的serviceUrls。
int myZoneOffset=getZoneOffset(instanceZone,preferSameZone,availZones);
String zone=availZones[myZoneOffset];
List<String> serviceUrls=clientConfig.getEurekaServerServiceUrls(zone);
具体获取serviceUrls的实现,我们可以详细查看getEurekaServerServiceUrls函数的具体实现类 EurekaClientConfigBean,该类是 EurekaClientConfig 和EurekaConstants接口的实现,用来加载配置文件中的内容,这里有非常多有用的信息,我们先说一下此处我们关心的,关于defaultZone的信息。通过搜索defaultZone,我们可以很容易找到下面这个函数,它具体实现了如何解析该参数的过程,通过此内容,我们就可以知道,eureka.client.serviceUrl.defaultZone 属性可以配置多个,并且需要通过逗号分隔。
当我们在微服务应用中使用Ribbon来实现服务调用时,对于Zone的设置可以在负载均衡时实现区域亲和特性:Ribbon 的默认策略会优先访问同客户端处于一个 Zone 中的服务端实例,只有当同一个Zone中没有可用服务端实例的时候才会访问其他Zone中的实例。所以通过Zone属性的定义,配合实际部署的物理结构,我们就可以有效地设计出对区域性故障的容错集群。
服务注册
在理解了多个服务注册中心信息的加载后,我们再回头看看DiscoveryClient类是如何实现“服务注册”行为的,通过查看它的构造类,可以找到它调用了下面这个函数:
从上面的函数中,可以看到一个与服务注册相关的判断语句 if(clientConfig.shouldRegisterWithEureka())。在该分支内,创建了一个 InstanceInfoReplicator类的实例,它会执行一个定时任务,而这个定时任务的具体工作可以查看该类的run()函数,具体如下所示:
相信大家都发现了discoveryClient.register();这一行,真正触发调用注册的地方就在这里。继续查看register()的实现内容,如下所示:
通过属性命名,大家基本也能猜出来,注册操作也是通过 REST 请求的方式进行的。同时,我们能看到发起注册请求的时候,传入了一个 com.netflix.appinfo.InstanceInfo对象,该对象就是注册时客户端给服务端的服务的元数据。
服务获取与服务续约
顺着上面的思路,我们继续来看DiscoveryClient的initScheduledTasks函数,不难发现在其中还有两个定时任务,分别是“服务获取”和“服务续约”:
private void initScheduledTasks(){
if(clientConfig.shouldFetchRegistry()){
//registry cache refresh timer
int registryFetchIntervalSeconds=clientConfig.getRegistryFetchIntervalSeconds();
int expBackOffBound=clientConfig.getCacheRefreshExecutorExponentialBackOffBound();
scheduler.schedule(
new TimedSupervisorTask(
"cacheRefresh",
scheduler,
cacheRefreshExecutor,
registryFetchIntervalSeconds,
TimeUnit.SECONDS,
expBackOffBound,
new CacheRefreshThread()
),
registryFetchIntervalSeconds,TimeUnit.SECONDS);
}if(clientConfig.shouldRegisterWithEureka()){
int renewalIntervalInSecs=instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
int expBackOffBound=clientConfig.getHeartbeatExecutorExponentialBackOffBound();
logger.info("Starting heartbeat executor: "+"renew interval is: "+renewalIntervalInSecs);
//Heartbeat timer
scheduler.schedule(
new TimedSupervisorTask(
"heartbeat",
scheduler,
heartbeatExecutor,
renewalIntervalInSecs,
TimeUnit.SECONDS,
expBackOffBound,
new HeartbeatThread()
),
renewalIntervalInSecs,TimeUnit.SECONDS);
//InstanceInfo replicator
……
}
}
从源码中我们可以发现,“服务获取”任务相对于“服务续约”和“服务注册”任务更为独立。“服务续约”与“服务注册”在同一个if逻辑中,这个不难理解,服务注册到Eureka Server 后,自然需要一个心跳去续约,防止被剔除,所以它们肯定是成对出现的。从源码中,我们更清楚地看到了之前所提到的,对于服务续约相关的时间控制参数:
eureka.instance.lease-renewal-interval-in-seconds=30
eureka.instance.lease-expiration-duration-in-seconds=90
而“服务获取”的逻辑在独立的一个 if 判断中,其判断依据就是我们之前所提到的eureka.client.fetch-registry=true 参数,它默认为true,大部分情况下我们不需要关心。为了定期更新客户端的服务清单,以保证客户端能够访问确实健康的服务实例,“服务获取”的请求不会只限于服务启动,而是一个定时执行的任务,从源码中我们可以看到任务运行中的registryFetchIntervalSeconds参数对应的就是之前所提到的eureka.client.registry-fetch-interval-seconds=30配置参数,它默认为30秒。
继续向下深入,我们能分别发现实现“服务获取”和“服务续约”的具体方法,其中“服务续约”的实现较为简单,直接以REST请求的方式进行续约:
boolean renew(){
EurekaHttpResponse<InstanceInfo>httpResponse;
try {
httpResponse=eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(),instanceInfo.getId(),instanceInfo,null);
logger.debug("{}-Heartbeat status: {}",PREFIX+appPathIdentifier,httpResponse.getStatusCode());
if(httpResponse.getStatusCode()==404){
REREGISTER_COUNTER.increment();
logger.info("{}-Re-registering apps/{}",PREFIX+appPathIdentifier,instanceInfo.getAppName());
return register();
}
returnhttpResponse.getStatusCode()==200;
} catch(Throwable e){
logger.error("{}-was unable to send heartbeat! ",PREFIX+appPathIdentifier,e);
return false;
}
}
而“服务获取”则复杂一些,会根据是否是第一次获取发起不同的 REST 请求和相应的处理。具体的实现逻辑跟之前类似,有兴趣的读者可以继续查看服务客户端的其他具体内容,以了解更多细节。
服务注册中心处理
通过上面的源码分析,可以看到所有的交互都是通过 REST 请求来发起的。下面我们来看看服务注册中心对这些请求的处理。Eureka Server对于各类REST请求的定义都位于com.netflix.eureka.resources包下。
以“服务注册”请求为例:
@POST
@Consumes({"application/json","application/xml"})
public Response addInstance(InstanceInfo info,
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION)String
isReplication){
logger.debug("Registering instance {}(replication={})",info.getId(),
isReplication);
//validate that the instanceinfo contains all the necessary required fields
...
//handle cases where clients may be registering with bad DataCenterInfo with missing data
DataCenterInfo dataCenterInfo=info.getDataCenterInfo();
if(dataCenterInfo instanceof UniqueIdentifier){
String dataCenterInfoId=((UniqueIdentifier)dataCenterInfo).getId();
if(isBlank(dataCenterInfoId)){
boolean experimental="true".equalsIgnoreCase(
serverConfig.getExperimental("registration.validation.
dataCenterInfoId"));
if(experimental){
String entity="DataCenterInfo of type "+dataCenterInfo.getClass()+" must contain a valid id";
return Response.status(400).entity(entity).build();
} else if(dataCenterInfo instanceof AmazonInfo){
AmazonInfo amazonInfo=(AmazonInfo)dataCenterInfo;
String effectiveId=amazonInfo.get(AmazonInfo.MetaDataKey.instanceId);
if(effectiveId==null){
amazonInfo.getMetadata().put(
AmazonInfo.MetaDataKey.instanceId.getName(),info.getId());
}
} else {
logger.warn("Registering DataCenterInfo of type {} without an
appropriate id",
dataCenterInfo.getClass());
}
}
}
registry.register(info,"true".equals(isReplication));
return Response.status(204).build(); //204 to be backwards compatible
}
在对注册信息进行了一堆校验之后,会调用 org.springframework.cloud.netflix.eureka.server.InstanceRegistry 对象中的 register(InstanceInfo info,int leaseDuration,boolean isReplication)函数来进行服务注册:
public void register(InstanceInfo info,int leaseDuration,boolean isReplication){
if(log.isDebugEnabled()){
log.debug("register "+info.getAppName()+",vip "+info.getVIPAddress()
+",leaseDuration "+leaseDuration+",isReplication "
+isReplication);
}
this.ctxt.publishEvent(new EurekaInstanceRegisteredEvent(this,info,
leaseDuration,isReplication));
super.register(info,leaseDuration,isReplication);
}
在注册函数中,先调用 publishEvent 函数,将该新服务注册的事件传播出去,然后调用 com.netflix.eureka.registry.AbstractInstanceRegistry 父类中的注册实现,将InstanceInfo中的元数据信息存储在一个ConcurrentHashMap对象中。正如我们之前所说的,注册中心存储了两层 Map 结构,第一层的 key 存储服务名:InstanceInfo中的appName属性,第二层的key存储实例名:InstanceInfo中的instanceId属性。
服务端的请求和接收非常类似,对于其他的服务端处理,这里不再展开叙述,读者可以根据上面的脉络来自己查看其内容(这里包含很多细节内容)来帮助和加深理解。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论