Talk is cheap,show me the code. --LinusTorvalds
继上一篇博文 浅谈微服务之Springcloud ,讲述到微服务的背景以及它的一些核心理念,本篇博文着重从代码和理论两方面对springcloud服务治理的核心组件–Eureka做一个详细的讲解。
Eureka的前世今生
在上一篇博文当中提及服务治理以及它主要解决的问题,那么当我们选用SpringCloud的架构模式来搭建自己的微服务的时候,Eureka无疑是首当其冲的选择,当然除了Eureka,目前还有几种服务治理服务器在此就不一一描述。
Eureka是Netflix开源微服务框架中一系列项目中的一个。Netflix是国外一家主要提供视频网站技术的公司,这个有翻过墙的小伙伴应该知道一些。SpringCloud对Eureka进行了二次封装,形成了Spring Cloud Netflix子项目,但是并没有对Netflix微服务实现原理进行更改,换个说法是新瓶装旧药,只是把标签和厂家给换了,对Eureka进行了SpringBoot化,使Java开发人员更为容易上手和理解。
在Eureka当中,有主要三种角色:
- 服务治理服务器
- 服务注册代理
- 服务发现客户端。
在不使用Eureka的时候,可能你的服务工作模式是酱紫:
A-> http://ip+port/…… -> B
使用Eureka的工作模式:
A-> http://B服务名/…… ->B
最典型的例子就是DNS服务器。对于DNS服务不了解的同学可以思考一个问题,当你的浏览器输入https://www.baidu.com的时候究竟发生了什么。
补充一句,要学习SpringCloud,首先需要了解SpringBoot,Cloud必须依赖Boot环境才可以搭建,而Boot可以与其他任何第三方单独的做成服务。
OK,话不多说,上码。
搭建一个多模块的简易SpringBoot项目:
父pom
<!-- springboot版本-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.2.RELEASE</version>
</parent>
<!--统一版本 -->
<properties>
<!-- Environment Settings -->
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 这里选择的是Finchley -->
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
</properties>
<!--依赖版本统一控制 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<!-- 记住这边type 和scope -->
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Eureka服务器模块项目:
pom
<dependencies>
<!-- Spring Boot Begin -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot End -->
<!-- Spring Cloud Begin (服务端Eureka核心依赖) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<!-- 这边我是引入了eureka安全认证,后面描述如何使用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Cloud End -->
</dependencies>
启动类:
/**
* @author xiejiarong
*/
/*SpringBoot启动类注解 */
@SpringBootApplication
/* 标志这是Eureka服务器*/
@EnableEurekaServer
public class EurekaServerApplication{
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class,args);
}
}
application.yml 配置文件:
spring:
application:
name: eureka-server #微服务进行调用的契约,必须定义名字
#eureka安全验证(相当于给注册中心多了一层保护)
security:
user:
password: #input your password
name: # input your loginname
server:
port: 8761 #端口号
#以下是Eureka配置信息
eureka:
instance:
hostname: localhost #实例主机名
client:
register-with-eureka: false #实例是否将自己注册到服务中心
fetch-registry: false #实例是否从服务中心拉取服务信息
server:
enable-self-preservation: false #禁用自我保护模式(后面详细描述)
eviction-interval-timer-in-ms: 10000 #eureka服务器剔除失效服务的间隔
为了实现Eureka注册中心的安全保护,还需要开启用户认证.
/**
* @author xiejiarong
* @description 解决eureka开启用户认证,服务端注册不了的问题(关闭CSRF)
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// Configure HttpSecurity as needed (e.g. enable http basic).
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER);
http.csrf().disable();
//注意:为了可以使用 http://${user}:${password}@${host}:${port}/eureka/ 这种方式登录,所以必须是httpBasic,
// 如果是form方式,不能使用url格式登录
http.authorizeRequests().anyRequest().authenticated().and().httpBasic();
}
}
准备差不多,直接一键Run,看看效果。
在设置了用户认证之后,访问本地 http://localhost:8761 会出现需要输入用户名与密码, 这是一种不错的安全防护手段。
进入之后的主界面:
Eureka注册中心的界面相对来说还是比较简洁友好的,启动完一个注册中心之后,接下来我们来以类似的方式搭建两个服务,作为服务提供与消费者。
服务提供者:
pom.xml
<dependencies>
<!-- Spring Boot Begin -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 加入spring的web模块,方便待会服务调用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot End -->
<!-- Spring Cloud Begin (Eureka 客户端核心依赖)-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Spring Cloud End -->
<!--阿里巴巴json-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.38</version>
</dependency>
<!-- 代码自动生成-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.10</version>
</dependency>
</dependencies>
application.yml
server:
port: 8800 #服务端口
spring:
application:
name: provider #服务名
provider:
name: A #自定义属性
eureka:
client:
serviceUrl:
defaultZone: http://xiejiarong:123456@localhost:8761/eureka/ #一般注册地址是以ip+端口号的方式进行注册,但是由于上面我们采用了用户安全控制,所以需要在前面加上username:password
#表示将自身注册到eureka服务器上
register-with-eureka: true
#本例只提供服务,所以可以不从服务器上同步其他服务节点
fetch-registry: false
instance:
lease-renewal-interval-in-seconds: 1 #Eureka客户端向服务端发送心跳的时间间隔,单位为秒(客户端告诉服务端自己会按照该规则)
lease-expiration-duration-in-seconds: 10 #Eureka服务端在收到最后一次心跳之后等待的时间上限,单位为秒,超过则剔除(客户端告诉服务端按照此规则等待自己)
prefer-ip-address: true #设置eureka客户端以IP地址方式进行注册
ip-address: localhost
instance-id: ${eureka.instance.hostname}:${server.port}:${spring.application.name} #实例名,也就是接下来在Eureka注册中心面板看到的服务注册全称
hostname: localhost #主机名
启动类:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
/**
* @author xiejiarong
*/
@SpringBootApplication
@EnableDiscoveryClient //这边跟注册中心不同,标识启动的是Eureka客户端
public class ProviderApplication {
public static void main(String[] args) {
SpringApplication.run(ProviderApplication.class,args);
}
}
服务基本启动环境搭建好,我们就对外提供一个简单的接口。
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author xiejiarong
*/
@RestController
@RequestMapping("/provide")
@Slf4j
public class ProviderController {
@Value("${server.port}") //注入当前端口号
private String serverPort;
@Value("${provider.name}") //注入当前服务名,A
private String providerName;
//提供一个GET请求接口,实现返回入参与服务提供者的信息
@GetMapping("/hello")
public String hello(String message){
return String.format("消息:%s,服务提供者:%s,当前端口号:%s",message,providerName,serverPort);
}
}
服务提供者(由于本篇博文着重介绍Eureka工作原理,所以关于Feign与Ribbon等服务调用组件暂且略)
pom.xml
<dependencies>
<!--eureka客户端 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- feign依赖引入(实现服务调用的组件) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--web -->
<!-- Spring Boot Begin -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot End -->
<!--阿里巴巴json-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.38</version>
</dependency>
</dependencies>
提供一个微服务调用接口:
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @author xjr
*/
//这个注解的意思是,我是Feign对外服务调用接口,如果当前项目需要调用其他服务,那么这个springcloud就会使用动态代理去实现它,本质上就是一个HTTP客户端,例如我们这里就表示代理的是provider 服务,fallback 的意思是熔断和降级处理逻辑。
@FeignClient(value = "provider",fallback=FeignServiceHystrix.class)
public interface FeignService {
//在Fegin组件接口当中,可以使用springmvc的注解,你可以理解为当前接口代理一个微服务,抽象方法使用spingmvc注解代理微服务的web接口。
@RequestMapping(value = "/provide/hello",method = RequestMethod.GET)
public String feignGet(@RequestParam("message") String message);
}
需要注意的是:如果feign代理的是get请求的话,那么每一个参数都必须加上@requestaram注解,否则默认是发送post请求,会导致调用失败,并且@requestparam注解的参数名不能省略
同样的也提供一个http请求入口:
import com.zoe.springcloud.service.FeignService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author xjr
*/
@RestController
@RequestMapping("/feignConsumer")
public class FeignController {
//笔者在spring的IOC特性之下,更偏爱于使用此种构造器注入。具体原因可以参考Spring5 官方团队给出的理由
private final FeignService feignService;
public FeignController(FeignService feignService) {
this.feignService = feignService;
}
//发送消息,调用feign组件,去实现访问A服务的目的
@GetMapping("/send")
public String send(String message){
return feignService.feignGet(message);
}
}
启动类:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* @author xjr
*/
@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients //相较于普通Eureka客户端,多了这个注解
public class FeignConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(FeignConsumerApplication.class,args);
}
}
OK,至此两个服务项目搭建完毕。全部启动,并且来到Eureka注册中心面板看一下结果:
看到列表当中注册实例有了两个实例,就是我们上面注册的两个服务。那么,现在直接打开浏览器或者postman 请求一下消费者的接口,测试消费者可不可以消费A服务提供的服务:
惊喜的发现,成功拿到了A服务返回的响应值。 到这边,我们就完成一个相对简单的服务注册、发现、消费的流程。
OK,在通过简单粗暴的代码演示过后,我们需要静下心来思考一下,Eureka究竟是如何工作的?为什么明明我没有写物理地址却能调用到我所需的服务呢?
Eureka几个重要的概念
1.服务注册(Register)
在微服务架构中,一个服务提供者本质上也是一个Eureka客户端。启动时,会调用Eureka所提供的服务注册相关方法,向Eureka服务器注册自己的信息。同时,在Eureka服务器会维护一个已注册的服务列表。注册列表使用一个嵌套的HashMap保存信息,数据接口如下:
- HashMap的第一层为服务应用名称和对应的服务实例。
- HashMap的第二层为服务实例及其对应的注册信息,包括了宿主服务IP地址、服务端口、运行状况指示符、URL等数据。
首先A服务启动的时候,就会根据配置文件将自身的实例信息注册到Eureka注册中心当中。并且当自身的实例状态发生变化时,就会向Eureka服务器更新自己的服务状态。
但是!如果我们在配置文件中将eureka.client.register-with-eureka 属性设置为false时,这表示当前eureka客户端不会执行以上操作。
2.服务续约(Renew)
当服务启动并成功注册到Eureka服务器后,不妨设想这样一个问题?在第一篇博文当中提到的服务治理有一个需要解决的问题就是:服务实例可能在不停的上线下线,那么如何保证注册中心可以感知到实例的这种变化呢? Eureka客户端决定以一种契约的方式与服务器协定好更新自身信息的方式:心跳机制。心跳机制,顾名思义,就是隔一段时间发出一个讯号来证明自己是否存活。它的时间间隔默认是30s(这一点可以通过在配置文件中修改eureka.instance的值来修改),我们可以通过阅读Eureka源码来查看:
当不设置此值的时候,它默认是:
。发送心跳起始起始就是执行服务续约操作,避免自己的注册信息被Eureka服务器剔除。那么在什么时候注册中心会判定当前实例已经过期呢?它的阈值官方定义是90s。也就是当注册中心连续3次没有收到Eureka客户端的心跳时,则会将该服务实例从所维护的服务注册列表当中剔除,以禁止流向该实例的流量。这一点我们可以通过修改配置文件参数:eureka.instance.lease-expiration-duration-on-seconds来指定时间。
注意,如果剔除实例间隔时间修改过大,即使服务实例已经不存在,也可能会有流向流向该实例,造成调用失败;如果设置太小,很可能因为网络问题导致误删信息。因此Eureka官方建议我们最好不好去修改这两个参数。
3.服务下线与剔除
当服务实例关闭的时候,服务实例首先会向Eureka注册中心发送服务下线请求,并且将其从注册列表当中删除。还是要注意的是,前提是服务实例正常关闭的情况下,以下会描述非正常关闭的情况会发生什么事情。
4.获取服务
服务注册到中心之后,其他客户端在启动的时候自然会从注册中心当中获取注册表信息,并将其缓存!缓存!缓存!(素质三连)。在使用的时候就会从缓存当中查找该服务的信息进行调用。这个时间可以通过 eureka.client.registry-fetch-interval-seconds进行修改。
Eureka自我保护机制
AS we all know,AP canot live with CP。 CAP是分布式架构中三个比较经典的定理。
- 一致性 (Consistency) 同一个数据在集群中的所有节点,同一时刻是否都是同样的值。
- 可用性(Availability):集群中一部分节点故障之后,集群整体是否还能处理客户端的请求。
- 分区容忍性(Partition tolerance):是否允许数据的分区,数据分区的意思就是允许集群中的节点之间无法通信。
这三个特性没有办法在分布式架构同时满足。对于微服务的治理而言,核心就是服务的注册于发现。所以对于服务发现场景来说,针对同一个服务,即使注册中心的不同节点保存的信息存在偏差,也不会造成灾难性的后果。因为对于服务消费者来说,能消费才是最为重要的,即使拿到的可能是不正确的服务实例信息,都需要have a try。用我们家乡话来说就是:无鱼虾也好。所以Eureka遵守的是AP原则,也就是我宁愿放过一个错误的服务,也不愿轻易将它扼杀。
上面提到的服务下线与剔除中,都是在服务正常关闭的情况之下,注册中心会得到迅速的响应。 但是!如果是因为杀死进程、宕机等等意外情况,那么就会是处于非正常关闭的情况,设想如果因为网络原因,Eureka注册中心在短时间之内丢失过多的服务心跳,从而剔除了这些实例信息,在网络恢复之后,Eureka客户端并不会重新将自己注册上去,这样就会导致了因为外在因素错误的删除过多实例而引起的服务调用失败。
Eureka服务器自我保护模式开启的条件是:当Eureka服务器每分钟收到心跳续租的数量低于一个阈值,就会触发。当心跳值重新恢复到正常数值时,才会关闭。这个心跳阈值的计算公式如下:
服务实例总数量x(60/每个实例心跳间隔秒数)x自我保护系数(0.85)
当自我保护机制开启的时候,Eureka注册中心面板会出现如以下:
在出现自我保护机制之后,Eureka不会再继续剔除因为心跳超时的服务信息,选择将其保存下来。可以通过eureka.server.enable-self-preservation属性设置为false来禁用。(本地开发建议关闭它,可以方便感知服务信息,方便调试定位问题)
服务的感知(终点)
Eureka的工作原理介绍的差不多,给大家看一张我在上家公司曾经的某次培训会议上整理的一个模型图:
服务注册上线之后,将自己的信息注册进注册列表(UI面板)和读写缓存(readWriteCache)当中,这一个过程是迅速的。然后读写缓存作为二级缓存,在其下游有一个只读缓存(readonlycache)作为一级缓存,两个缓存之间会定时同步信息,默认为30S。 服务消费者也注册到注册中心之后,第一次它会全量的的从只读缓存中获取所有的服务实例列表,并且同步到自己的客户端缓存当中,这个客户端缓存与只读缓存同步时间默认也是30s。 可能大家会有点晕了,why? Eureka搞这么多缓存做煤啊,我每次都从列表中获取最新的不就好了。 其实不是这样的,你可以这样想,假如你在每次调用服务的时候,由于没有这几个缓存的存在,那么每次都需要先向注册中心发起请求,拿到服务实例信息,再发起调用,那么这无形当中会增加服务器的压力,而且对于性能来说也是一个不小的威胁。在Eureka服务器中,有一个定时器,专门主动用来清理服务实例,默认是每隔60s.可以通过配置参数 eureka.server.evictionIntervalTimerInMs进行修改。
那么,让我们设想一个极端的理想情况下,当一个服务意外下线,那么Eureka消费者客户端在多少时间之后可以感知到呢?
- 服务在第0s下线,无法发送正常服务。 在第29s时,第一次发送心跳失败,违约第一次;
- 第59s,第二次续约失败
- 第89s,续约失败
- 由于timer存在,时间过去60s之后,也就是在第89s的时候,这时候eureka中心检测到此服务已经失效,主动将其从注册列表和读写缓存中剔除。
- 由于读写缓存与只读缓存之间存在同步间隔,所以过去30s也就是在第119s的时候,只读缓存更新到服务信息。
- 由于eureka客户端与只读缓存之间也有一个间隔30s的同步机制,所以在第149s的时候感知到服务下线,缓存刷新。
- 如果考虑上ribbon的缓存的话,需要额外再加上30s,179s
也就是说,当一个服务下线之后,消费者可能需要最多180s的时间才可以感知到。不过这都是建立在一切都是理想情况下,就像物理现象中的绝对光滑与匀速运动一样,理想情况,毕竟还是难得。
不知不觉时间已近深夜两点,这次关于Eureka的分析就差不多到此结束了。通过总结,收获更多,在之后会更新更多有关springcloud微服务构建的其他组件实战与分析,有什么不对的地方望请指正(码字不易,觉得不错的话就给打赏一下哈)。
Bye