艾编程架构课程第五十八节笔记未完待续
- SpringCloud服务治理(一)
- SpringCloud负载均衡和远程调用(二)
- SpringCloud服务降级熔断(三)
- SpringCloud服务熔断详解(四)
- SpringCloud配置中心服务
- SpringCloud配置中心&消息总线(五)
- 1. 配置中心的信息加密方式
- 2. 总线式配置架构思考
- 3. 消息总线在微服务中的应用
- 4. BUS和消息队列的集成方式
- 5. 实现总线式架构的配置中心
- 6. GitHub配置更改后自动同步更新
- SpringCloud服务网关(六)
- 1. 服务网关在微服务中的应用
- 2. 第二代网关Gateway
- 3. Gateway快速落地实施
- 4. 路由功能详解
- 5. 断言功能详解
- 6. 实现断言的配置
- 7. 通过After断言实现定时秒杀
- 8. 过滤器原理和生命周期
- 9. 自定义过滤器实现接口计时功能
- 10. 权限认证方案分析
- SpringCloud服务网关&调用链追踪(七)
- 1. 实现服务网关层JWT鉴权
- 2. 实现服务网关层统一异常返回
- 3. 实现服务网关限流
- 4. 链路追踪在微服务中的作用
- 5. 链路追踪的基本功能
- 6. Sleuth的链路追踪介绍
- SpringCloud服务调用链追踪(八)
- 1. Sleuth的体系架构设计原则
- 2. 调用链路数据模型
- 3. 整合Sleuth追踪调用链路
- 4. 什么是Zipkin
- 5. 搭建配置Zipkin服务
- 6. 实现Zipkin服务高可用
- 7. Sleuth集成ELK实现日志检索
- SpringCloud消息组件-Stream(九)
- 1. 消息驱动在微服务中的应用
- 2. Stream体系结构
- 3. Stream快速落地
- 4. 实现Stream的消息发布订阅
- 5. 消费组和消息分区详解
- 6. Stream实现延时消息
- 7. Stream实现本地重试
- 8. Stream实现消息重新入队
- 9. 异常情况导致消息无法消费的解决方案
- 10. Stream借助死信队列实现异常处理
- 11. 消息驱动中的降级和接口升版
SpringCloud服务治理(一)
1. 什么是服务治理
- 高可用性:除了服务本身要可用,服务治理的框架也要高可用
- 分布式调用:异地灾备,服务治理框架还需要在复杂网络环境下做到精确服务的定位
- 生命周期的管理:服务治理的框架还要管理好服务的上下线
- 健康度检查:对于服务的是否能够正常工作要能定期检查
服务治理的解决方案:
- 服务注册:服务提供方自报家门
- 服务发现:服务消费者需要拉取服务注册列表
- 心跳检测、服务续约、服务剔除:由服务注册中心和服务提供方配合实现
- 服务下线:服务提供方发起主动下线
2. 服务治理组件选型比较
Eureka:Netflix公司
Consul:Spring开源组织直接贡献
Nacos:阿里服务治理中间件
Eureka | Consul | Nacos | |
---|---|---|---|
一致性 | 弱一致性(AP) | 弱一致性(AP) | AP/CP |
性能 | 快 | 慢(RAFT协议Leader选举) | 快 |
网络协议 | HTTP | HTTP&DNS | HTTP/DNS/UDP |
应用广度 | 主流 | 小众一些 | 发展中 |
3. 构建Eureka-Server模块
spring-cloud-learn父工程的POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.icodingedu.springcloud</groupId>
<artifactId>spring-cloud-learn</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>
创建子项目eureka-server的POM文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-learn</artifactId>
<groupId>com.icodingedu.springcloud</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>eureka-server</artifactId>
<name>eureka-server</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
</project>
application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
//注册中心的服务
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(EurekaServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
properties的设置
# 切记一定要加
spring.application.name=eureka-server
server.port=20001
eureka.instance.hostname=localhost
# 是否发起服务器注册,服务端关闭
eureka.client.register-with-eureka=false
# 是否拉取服务注册表,服务端是生成端不用拉取
eureka.client.fetch-registry=false
4. 构建Eureka-Client模块
创建Eureka-Client的项目模块
引入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-learn</artifactId>
<groupId>com.icodingedu.springcloud</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>eureka-client</artifactId>
<name>eureka-client</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class EurekaClientApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(EurekaClientApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
controller服务提供内容
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.pojo.PortInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@RestController
@Slf4j
public class EurekaClientController {
@Value("${server.port}")
private String port;
@GetMapping("/sayhello")
public String sayHello(){
return "my port is "+port;
}
@PostMapping("/sayhello")
public PortInfo sayPortInfo(@RequestBody PortInfo portInfo){
log.info("you are "+portInfo.getName()+" is "+portInfo.getPort() );
return portInfo;
}
}
pojo
package com.icodingedu.springcloud.pojo;
import lombok.Data;
@Data
public class PortInfo {
private String name;
private String port;
}
application配置
spring.application.name=eureka-client
server.port=20002
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
5. 构建Eureka-Consumer模型
创建一个Eureka-Consumer的模块
引入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-learn</artifactId>
<groupId>com.icodingedu.springcloud</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>eureka-consumer</artifactId>
<name>eureka-consumer</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication
@EnableDiscoveryClient
public class EurekaConsumerApplication {
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(EurekaConsumerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
controller实现调用
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.pojo.PortInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
@Slf4j
public class ConsumerController {
//Consumenr+RestTemplate
@Autowired
private RestTemplate restTemplate;
//Eureka+Ribbon
@Autowired
private LoadBalancerClient client;
@GetMapping("/hello")
public String hello(){
ServiceInstance instance = client.choose("eureka-client");
if(instance==null){
return "No available instance";
}
String target = String.format("http://%s:%s/sayhello",instance.getHost(),instance.getPort());
log.info("url is {}",target);
return restTemplate.getForObject(target,String.class);
}
@PostMapping("/hello")
public PortInfo portInfo(){
ServiceInstance instance = client.choose("eureka-client");
if(instance==null){
return null;
}
String target = String.format("http://%s:%s/sayhello",instance.getHost(),instance.getPort());
log.info("url is {}",target);
PortInfo portInfo = new PortInfo();
portInfo.setName("gavin");
portInfo.setPort("8888");
return restTemplate.postForObject(target,portInfo,PortInfo.class);
}
}
pojo
package com.icodingedu.springcloud.pojo;
import lombok.Data;
@Data
public class PortInfo {
private String name;
private String port;
}
application的配置文件
spring.application.name=eureka-consumer
server.port=20003
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
启动进行consumer测试:server、client、consumer
6. Eureka心跳检测与服务剔除
6.1. 心跳检测的机制
- 客户端发起:心跳是由服务节点根据配置时间主动发起
- 同步状态:还要告知注册中心自己的状态(UP、DOWN、STARTING、OUT_OF_SERVICE,UNKNOW)
- 服务剔除:服务中心剔除,主动剔除长时间没有发心跳的节点
- 服务续约
# 两个核心的Eureka-Client配置
# 每隔10秒向Eureka-Server发送一次心跳包
eureka.instance.lease-renewal-interval-in-seconds=10
# 如果Eureka-Server在这里配置的20秒没有心跳接收,就代表这个节点挂了
eureka.instance.lease-expiration-duration-in-seconds=20
# 这两个参数是配置在服务提供节点
6.2. 服务剔除
-
1、启动定时任务来轮询节点是否正常,默认60秒触发一次剔除任务,可以修改间隔
# 配置在Eureka-Server上 eureka.server.eviction-interval-timer-in-ms=30000
-
2、调用evict方法来进行服务剔除
如果自保开启,注册中心就会中断服务剔除操作
-
3、遍历过期服务,如和判断服务是否过期,以下任意一点满足即可
- 已被标记为过期
- 最后一次心跳时间+服务端配置的心跳间隔<当前时间
-
4、计算可剔除的服务总数,所有的服务是否能被全部剔除?当然不能!设定了一个稳定系数(默认0.85),这个稳定系数就是只在注册的服务总数里只能剔除:总数*85%个,比如当前100个服务,99个已经over了,只能剔除85个over,剩下的14个over的不会剔除
eureka.server.renewal-percent-threshold=0.85
-
5、乱序剔除服务:随机到哪个过期服务就把他踢下线
7. Eureka服务续约机制
7.1. 续约和心跳的关系
**同步时间:**心跳、续约、剔除
我们先来说说续约和心跳的关系,服务续约分为两步
- 第一步 是将服务节点的状态同步到注册中心,意思是通知注册中心我还可以继续工作,这一步需要借助客户端的心跳功能来主动发送。
- 第二步 当心跳包到达注册中心的时候,那就要看注册中心有没有心动的感觉了,他有一套判别机制,来判定当前的续约心跳是否合理。并根据判断结果修改当前instance在注册中心记录的同步时间。
接下来,服务剔除并不会和心跳以及续约直接打交道,而是通过查验服务节点在注册中心记录的同步时间,来决定是否剔除这个节点。
所以说心跳,续约和剔除是一套相互协同,共同作用的一套机制
7.2. 发送Renew续约请求
接下来,就是服务节点向注册中心发送续约请求的时候了
-
服务续约请求 在前面的章节里我们讲到过,客户端有一个DiscoverClient类,它是所有操作的门面入口。所以续约服务就从这个类的renew方法开始
-
发送心跳
服务续约借助心跳来实现,因此发给注册中心的参数和上一小节的心跳部分写到的一样,两个重要参数分别是服务的状态(UP)和lastDirtyTimeStamp
- 如果续约成功,注册中心则会返回200的HTTP code
- 如果续约不成功,注册中心返回404,这里的404并不是说没有找到注册中心的地址,而是注册中心认为当前服务节点并不存在。这个时候再怎么续约也不灵验了,客户端需要触发一次重新注册操作。
-
在重新注册之前,客户端会做下面两个小操作,然后再主动调用服务册流程。
- 设置lastDirtyTimeStamp 由于重新注册意味着服务节点和注册中心的信息不同步,因此需要将当前系统时间更新到“lastDirtyTimeStamp”
- 标记自己为脏节点
-
当注册成功的时候,清除脏节点标记,但是lastDirtyTimeStamp不会清除,因为这个属性将会在后面的服务续约中作为参数发给注册中心,以便服务中心判断节点的同步状态。
7.3. 续约校验
注册中心开放了一系列的HTTP接口,来接受四面八方的各种请求,他们都放在com.netflix.eureka.resources这个包下。只要客户端路径找对了,注册中心什么都能帮你办到
-
接受请求 InstanceResource下的renewLease方法接到了服务节点的续约请求。
-
尝试续约
服务节点发起续约请求。注册中心进行校验,从现在算到下一次心跳间隔时间,如果你没来renew,就当你已经死掉了。注册中心此时会做几样简单的例行检查,如果没有通过,则返回404,不接受反驳
- 你以前来注册过吗?没有?续约失败!带齐资料工作日前来办理注册!
- 你是Unknown状态?回去回去,重新注册!
-
脏数据校验 如果续约校验没问题,接下来就要进行脏数据检查。到了服务续约最难的地方了,脏数据校验逻辑之复杂,如同这皇冠上的明珠。往细了说,就是当客户端发来的lastDirtyTimeStamp,晚于注册中心保存的lastDirtyTimeStamp时(每个节点在中心都有一个脏数据时间),说明在从服务节点上次注册到这次续约之间,发生了注册中心不知道的事儿(数据不同步)。这可不行,这搞得我注册中心的工作不好有序开展,回去重新注册吧。续约不通过,返回404。
8. Eureka服务自保机制
- 以下两个Eureka的服务机制是不能共存的,注册中心在统一时刻只能实行以下一种方法
- 服务剔除
- 服务自保
服务自保:把当前系统所有的节点保留,一个都不能少,即便服务节点挂了也不剔除
服务自保的触发机关
- 服务自保由两个机关来触发
- 自保机制在服务启动的15分钟内自动触发检查,如果成功续约的节点低于限定值(默认85%)就开启自保服务,自保服务开启就中断服务剔除操作
手动关闭服务自保
配置强行关闭服务自保,即便上面的自动开关被触发,也不能开启自保功能了
eureka.server.enable-self-preservation=false
9. Eureka启用心跳和健康检查验证
在eureka-client里配置
# 测试设置,生产环境第一个值要比第二个小
eureka.instance.lease-renewal-interval-in-seconds=30
eureka.instance.lease-expiration-duration-in-seconds=5
在eureka-server配置
# 测试设置
eureka.server.enable-self-preservation=false
eureka.server.eviction-interval-timer-in-ms=10000
10. 服务注册中心的高可用架构
微服务架构中每一个较大业务领域都有自己的注册中心
eureka-server如果挂了,consumer依然可以使用server挂掉前的服务列表进行访问,但新的服务无法进行治理了
如何确保服务中心的高可用呢
如果要实现HA,就是通过镜像节点,我们再copy一个eureka-server
# eureka-server配置互相注册的节点即可
eureka.client.service-url.defaultZone=http://eurekaserver2:20011/eureka/
# eureka-client和eureka-consumer调用可以用csv确保调用的HA
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/,http://localhost:20002/eureka/
SpringCloud负载均衡和远程调用(二)
1. Ribbon体系架构分析
负载均衡(Load Balance)
- 客户端负载均衡
- 由调用方进行负载判断,这就需要一个服务的注册列表来进行选择和访问
- 通过本地指定的负载均衡策略来调用服务列表中的哪个服务
- 对开发人员友好,完全由开发来控制,运维成本低,但强强依赖服务注册中心
- 客户端一般使用微服务框架实现(Ribbon)
- 服务端负载均衡
- 在客户端和服务端之间架设了一个负载均衡服务组件。通过这个服务组件进行负载均衡
- 通过是在一个服务应用,进行负载均衡的配置,通常不依赖服务注册中心
- 通过使用Nginx、HAProxy、Lvs、F5这些负载均衡器来实现
Ribbon的体系结构分析
- IPing:是Ribbon的一套健康检查机制
- IRule:这就是Ribbon负载均衡的组件库,所有经过Ribbon的请求都会经过IRule获取负载均衡的机器
2. 基于Ribbon的应用
创建一个带ribbon的eureka-consumer应用,可以直接复制之前的eureka-consumer项目
POM里加入ribbon的依赖功能
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
在Application里给RestTemplate增加LoadBalance的注解
@SpringBootApplication
@EnableDiscoveryClient
public class RibbonConsumerApplication {
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(RibbonConsumerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
Controller进行一下修改,直接调用client服务即可
package com.icodingedu.springcloud.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
@Slf4j
public class ConsumerController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/hello")
public String hello(){
return restTemplate.getForObject("http://eureka-client/sayhello",String.class);
}
}
Properties的配置
spring.application.name=ribbon-consumer
server.port=30099
# 服务提供者连接的注册中心
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
Ribbon是在第一次加载的时候才会去初始化LoadBanlancer,第一次不仅包好HTTP连接和业务请求还包含LoadBanlancer的创建耗时,假如你的方法本身就比较耗时,并且你设置的超时时间不是很长,就很有可能导致第一次HTTP调用失败,这是ribbon的懒加载模式导致的,默认就是懒加载的
# ribbon开启饥饿加载模式,在启动时就加载LoadBanlancer配置
ribbon.eager-load.enabled=true
# 指定饥饿加载的服务名称
ribbon.eager-load.clents=ribbon-consumer
3. Ribbon负载均衡策略配置
3.1. 负载均衡的策略
- 1、轮询(RoundRobinRule):轮询有一个上限,当轮询了10个服务节点都不可用,轮询个结束
- 2、随机(RandomRule):使用jdk自带的随机数生产工具,生成一个随机数,当前节点不可用继续随机,直到随机到可用服务为止
- 3、可用过滤策略(AvailabilityFilteringRule):过滤掉连接失败的和高并发的,然后从健康的节点中使用轮询策略选出一个节点
- 4、轮询失败重试(RetryRule):使用轮询策略,如果第一个节点失败,会retry下一个节点,如果还失败就返回失败
- 5、并发量最小可用策略(BestAvailableRule):会轮询所有节点获取并发量,在其中选择一个最小的进行服务调用,优点:将服务打到最小并发节点,缺点:需要获取所有节点的并发量,比较耗时
- 6、响应时间权重策略(WeightedResponseTimeRule):根据响应时间,分配一个weight权重,最长权重越小,被选中的可能性就越低,服务刚启动由于信息量不足,会使用轮询方式,待信息充足后切换
- 7、ZoneAvoidanceRule:复合判断server所在区域的性能和server可用性来选择server
3.2. 负载均衡配置
全局的负载均衡策略
package com.icodingedu.springcloud.config;
import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.RoundRobinRule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RibbonConfiguration {
@Bean
public IRule defaultLBStrategy(){
return new RoundRobinRule();
}
}
指定服务的负载均衡配置
方法一:properites里指定服务名对应的负载均衡策略
eureka-client.ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.RandomRule
方法二:在configuration上注解实现
@Configuration
@RibbonClient(name = "eureka-client",configuration = com.netflix.loadbalancer.RandomRule.class)
public class RibbonConfiguration {
}
3.3. 负载均衡的选择
在Ribbon里有两个时间和空间密切相关的负载均衡策略:BestAvailableRule(BA)、WeightedResponseTimeRule(WRT)共同目标都是需要负载选择压力小的服务节点,BA选择并发量最小的机器也就是空间选择,WRT根据时间选择响应最快的服务
对于连接敏感型的服务模型,使用BestAvailableRule策略最合适
对于响应时间敏感的服务模型,使用WeightedResponseTimeRule策略最合适
如果使用了熔断器,用AvailabilityFilteringRule进行负载均衡
4. Feign进行远程调用的机制
Eureka:http://ip:port/path
Ribbon:http://serviceName/path
引入Fegin组件来进行远程调用,这两个组件也一并被引入
-
Ribbon:利用负载均衡策略进行目标机器选择
-
Hystrix:根据熔断状态的开启状态,决定是否发起远程调用
-
发送请求时有两个核心的点
- 重试
- 降级
5. Feign远程调用实例
建立一个feign的文件夹并创建一个feign-consumer的应用
添加POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>feign-consumer</artifactId>
<name>feign-consumer</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--服务配置,bus推送都要依赖这个actuator-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
启动类实现
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class FeignConsumerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(FeignConsumerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建调用的Service接口引用实现
package com.icodingedu.springcloud.service;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
//eureka服务提供者的service-name
//这个注解的意思是IService这个接口的调用都发到eureka-client这个服务提供者上
@FeignClient("eureka-client")
public interface IService {
@GetMapping("/sayhello")
String sayHello();
}
创建一个controller实现
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.service.IService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FeignController {
@Autowired
private IService service;
@GetMapping("/sayhi")
public String sayHi(){
return service.sayHello();
}
}
创建配置properties
spring.application.name=feigon-consumer
server.port=40001
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
6. 理想的Feign风格项目结构
6.1. 抽取一个公共接口层
在feigon目录下创建项目feigon-client-intf
POM里仅保持最低限度依赖,不要添加过多依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>feign-client-intf</artifactId>
<name>feign-client-intf</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
建立接口层,并将实体对象放进来
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.pojo.PortInfo;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
//这里不能再使用eureka-client的了,需要使用自己的
//如果提供给的下游应用没有使用feign就不加注解@FeignClient,@GetMapping,@PostMapping,就是一个简单的接口,让下游自己实现即可
@FeignClient("feign-client")
public interface IService {
@GetMapping("/sayhello")
String sayHello();
@PostMapping("/sayhello")
PortInfo sayHello(@RequestBody PortInfo portInfo);
}
pojo实体对象
package com.icodingedu.springcloud.pojo;
import lombok.Data;
@Data
public class PortInfo {
private String name;
private String port;
}
6.2. 新建服务提供者
在feign目录中创建项目feign-client-advanced
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>feign-client-advanced</artifactId>
<name>feign-client-advanced</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>feign-client-intf</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
创建application的启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnableDiscoveryClient
@SpringBootApplication
public class FeignClientAdvancedApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(FeignClientAdvancedApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
在Controller里实现feign-client-intf里的IService
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.pojo.PortInfo;
import com.icodingedu.springcloud.service.IService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class FeignController implements IService {
@Value("${server.port}")
private String port;
@Override
public String sayHello() {
return "my port is "+port;
}
@Override
public PortInfo sayHello(@RequestBody PortInfo portInfo) {
log.info("you are "+portInfo.getName());
portInfo.setName(portInfo.getName());
portInfo.setPort(portInfo.getPort());
return portInfo;
}
}
配置Properties
spring.application.name=feign-client
server.port=40002
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
6.3. 改进版的消费者
创建feign-consumer-advanced
设置POM文件,可以从feign-client-advanced里取
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>feign-consumer-advanced</artifactId>
<name>feign-consumer-advanced</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>feign-client-intf</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
创建启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
//这个地方要注意IService所在包路径,默认是扫当前包com.icodingedu.springcloud
//如果接口不在同一个包下就需要把包路径扫进来
//@EnableFeignClients(basePackages = "com.icodingedu.*")
@EnableFeignClients
public class FeignConsumerAdvancedApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(FeignConsumerAdvancedApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
controller实现
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.pojo.PortInfo;
import com.icodingedu.springcloud.service.IService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class FeignController {
@Autowired
private IService service;
@GetMapping("/sayhi")
public String sayHi(){
return service.sayHello();
}
@PostMapping("/sayhi")
public PortInfo sayHello(@RequestBody PortInfo portInfo){
return service.sayHello(portInfo);
}
}
properties的实现配置
spring.application.name=feign-consumer-advanced
server.port=40003
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
7. Feign服务调用超时重试机制
# feign的调用超时重试机制是由Ribbon提供的
# feign-server-proivder是指你的服务名
feign-server-proivder.ribbon.OkToRetryOnAllOperations=true
feign-server-proivder.ribbon.ConnectTimeout=1000
feign-server-proivder.ribbon.ReadTimeout=2000
feign-server-proivder.ribbon.MaxAutoRetries=2
feign-server-proivder.ribbon.MaxAutoRetriesNextServer=2
上面的参数设置了feign服务的超时重试策略
OkToRetryOnAllOperations:比如POST、GET、DELETE这些HTTP METHOD哪些可以Retry,设置为true表示都可以,这个参数是为幂等性设计的,默认是GET可以重试
ConnectTimeout:单位ms,创建会话的连接时间
ReadTimeout:单位ms,服务的响应时间
MaxAutoRetries:当前节点重试次数,访问次数等于首次访问+这里配置的重试次数
MaxAutoRetriesNextServer:当前机器重试超时后Feign将连接新的机器节点访问的次数
按照上面配置的参数,最大超时时间是?
(1000+2000) x (1+2) x (1+2) = 27000ms
总结一下极值函数
MAX(Response Time)=(ConnectTimeout+ReadTimeout)*(MaxAutoRetries+1)*(MaxAutoRetriesNextServer+1)
8. 配置Feign超时重试验证
在feign-consumer-advanced里进行配置即可
# feign-client:这个是自己定义的服务名
# 每台机器最大的重试次数
feign-client.ribbon.MaxAutoRetries=2
# 可以重试的机器数量
feign-client.ribbon.MaxAutoRetriesNextServer=2
# 连接请求超时的时间限制ms
feign-client.ribbon.ConnectTimeout=1000
# 业务处理的超时时间ms
feign-client.ribbon.ReadTimeout=2000
# 默认是false,默认是在get上允许重试
# 这里是在所有HTTP Method进行重试,这里要谨慎开启,因为POST,PUT,DELETE如果涉及重试就会出现幂等问题
feign-client.ribbon.OkToRetryOnAllOperations=true
配置完毕后进行测试验证
在feign-client-intf里增加接口retry接口
@GetMapping("/retry")
String retry(@RequestParam(name = "timeout") int timeout);
在feign-client-advanced里实现这个接口
@Override
public String retry(@RequestParam(name="timeout") int timeout) {
try {
while (timeout-- > 0) {
Thread.sleep(1000);
}
}catch (Exception ex){
ex.printStackTrace();
}
log.info("retry is "+port);
return port;
}
在feign-consumer-advanced的controller里实现
@GetMapping("/retry")
public String retry(@RequestParam(name = "timeout") Integer timeout){
return service.retry(timeout);
}
启动三个feign-client-advanced
进行超时验证并看控制台数据是是每个节点输出三次,重试三个机器
SpringCloud服务降级熔断(三)
1. 服务故障场景分析
缓存雪崩我们在之前的课程中都已经掌握过了,我们看一下另一个雪崩,那就服务雪崩
1.1. 服务雪崩的场景分析
先来看一下这个场景:
- 首先我们有三组服务对外提供服务,服务C的业务压力最小所以只有一个节点
- 从访问的线程池中来了5个请求到达服务,互相的调用关系如图所示,直接访问服务或间接调用
目前异常场景出现了:
- 如果我们的服务C由于代码原因或数据库连接池耗光导致访问非常慢,那么调用服务C的业务就会收到timeout的响应
- 这是所有到服务C的业务都timeout了,如果我们不做管控,这个timeout就会蔓延到服务B,进而到服务A,整条链路就会蔓延整个服务集群,所有请求的5个request直接团灭
- 这个时候如果不对服务做任何处理,新进来的业务请求也只是送人头而已
1.2. 线程池耗尽场景分析
场景分析:
- 请求从Tomcat中发起进行服务调用,会同时调用多个
- 这个时候服务C的应用处于一种时好时坏的状态,调用他的请求就会被夯在那里进行等待
- 虽然在调用C的同时也会调用其他服务,但由于C调用等待时间较长,这就导致请求一直挂起
- 挂起的请求多了后,并发的连接增大,Tomcat的连接池资源就会耗尽
这个时候如何处理,就需要通过线程隔离来解决
1.3. 生产环境故障发生流程
在生产环境中系统故障发生的严重状态一般有以下几步
- 访问延迟
- 服务不可用404/503/504
- 造成资损
这里就要再次提到主链路的概念了,如果商品详情页的评价模块挂了几分钟,这没有问题,但如果商品下单流程挂了几分钟,那就会导致资金损失了
熔断和降级的核心目的就是为了保障系统的主链路稳定
如何降低故障影响
- 隔离异常服务:降低串联影响做到线程级隔离
- 服务减压:服务快速失败触发熔断机制
- 备选方案:通过降级机制确保主链路可用的情况下实现系统最小可用性
2. Hystrix体系架构核心功能解析
《断舍离》,是日本作家山下英子的著作,这本书传达了一种生活理念。断=不买、不收取不需要的东西。舍=处理掉堆放在家里没用的东西。离=舍弃对物质的迷恋,让自己处于宽敞舒适,自由自在的空间。
对过往不迷恋,拿得起放得下,这样的生活哲学确实可以帮助人们度过一些困难时期。我们知道Hystrix也是为了帮服务节点度过他们的困难时期(缓解异常、雪崩带来的影响),它也有同样一套佛系的设计理念,分别对应Hystrix中三个特色功能
- 断 - 熔断
- 舍 - 降级
- 离 - 线程隔离
2.1. 服务降级(舍)
微服务架构强调高可用,但并非高一致性,在一致性方面远比不上银行的大型机系统。也就是说,在日常服务调用阶段会出现一系列的调用异常,最常见的就是服务下线。举个例子:重启服务节点的时候,服务下线指令发送到注册中心后,这时还没来得及通过服务发现机制同步到客户端,因此某些服务调用请求依然会发送到这台机器,但由于服务已经下线,最终调用方只能无功而返,404 Not Found。
再举一个破坏力更大的例子。前面我们讲到了服务的雪崩效应,大家可能只听说过缓存雪崩,其实雪崩效应不仅仅针对缓存,它更大的危害是在大规模分布式应用上。我们举一个真实的案例,电商系统很多模块都依赖营销优惠服务,比如商品详情页、搜索列表页、购物车页和下单页面都依赖营销服务来计算优惠价格,因此这个服务承载的负载压力可谓非常之高。我们设想,假如这个服务出现了异常,导致响应超时,那么所有依赖它的下游系统的响应时间都会被拉长,这就引发了一个滚雪球的雪崩效应,由最上游的系统问题,引发了一系列下游系统响应超时,最终导致整个系统被拖垮。
服务降级用来应对上面的几种情况再合适不过了,假如HystrixClient调用目标请求的时候发生异常(exception),这时Hystrix会自动把这个请求转发到降级逻辑中,由服务调用方来编写异常处理逻辑。对响应超时的场景来说,我们可以通过配置Hystrix的超时等待时间(和Ribbon的timeout是两个不同配置),把超时响应的服务调用也当做是异常情况,转发到fallback逻辑中进行处理。
2.2. 服务熔断(断)
服务熔断是建立在服务降级之上的一个异常处理措施,你可以将它看做是服务降级的升级版。服务降级需要等待HTTP请求从服务节点返回异常或超时,再转向fallback逻辑,但是服务熔断引入了一种叫“断路器/熔断器”的机制,当断路器打开的时候,对服务的调用请求不会发送到目标服务节点,直接转向fallback逻辑。
断路器的打开/关闭有很多的判断标准,我们在服务熔断小节里再深入探讨。(好吧,这里我先剧透一点好了,比如我们可以这样设置:每10个请求,失败数量达到8个的时候打开断路器)。
同学可能会问了,假如断路器打开之后,就这么一直开着吗?当然不是了,一直开着多浪费电啊。服务一时失败,不代表一直失败,Hystrix也有一些配置规则,会主动去判断断路器关闭的时机。在后续章节,我们再来深入学习断路器的状态流转过程,我会带大家通过Turbine监控大盘,查看Hystrix断路器的开启关闭。
断路器可以显著缓解由QPS(Query Per Second,每秒访问请求,用来衡量系统当前压力)激增导致的雪崩效应,由于断路器打开后,请求直接转向fallback而不会发起服务调用,因此会大幅降低承压服务的系统压力。
2.3. 线程隔离(离)
大家知道Web容器通常有一个线程池来接待来访请求,如果并发量过高,线程池被打满了就会影响后面请求的响应。在我们应用内部,假如我们提供了3个微服务,分别是A,B,C。如果请求A服务的调用量过多,我们会发现所有可用线程都会逐渐被Service A占用,接下来的现象就是服务B和服务C没有系统资源可供调用。
Hystrix通过线程隔离的方案,将执行服务调用的代码与容器本身的线程池(比如tomcat thread pool)进行隔离,我们可以配置每个服务所需线程的最大数量,这样一来,即便一个服务的线程池被吃满,也不会影响其他服务。
与线程隔离相类似的还有“信号量”技术,稍后的小节我们会对两个技术做一番对比,看看这两个技术方案适合在哪些业务场景里应用。
3. 服务降级的常用方案
3.1. 静默处理
所谓的静默处理,就是什么也不干,在fallback逻辑中直接返回一个空值Null。
同学们可能会问,那我用try-catch捕捉异常不也是能达到一样的效果吗?其实不然,首先try-catch只能处理异常抛出的情况,并不能做超时判定。其次,使用try-catch就要在代码里包含异常处理块,我们在程序设计时讲究单一职责和开闭原则。既然有了专门的fallback处理类,这个工作还是交给fallback来吧,这样你的业务代码也会很清爽。
3.2. 默认值处理
默认值处理实际上就是说个谎话,在并不确定真实结果的情况下返回一个默认值
假如在商品详情页调用营销优惠接口时发生了故障,无法返回正确的计算结果,这里我们就可以在fallback逻辑中返回商品原价,作为打折后的价格,这样就相当于返回了一个没有打折优惠的计算结果。
这种方式下接口的返回值并不是真实的,因此不能应用在某些核心主链路中。举个例子,比如下单页面就是核心主链路,是最终确定订单价格的关键步骤。假如我们对订单优惠计算采用了默认值的方式,那么就会实际造成用户损失。因此,这里面的优惠计算决不能返回默认值,一定要得出真实结果,如果无法获取那么宁可返回异常中断下单操作。
同学们可能会问,那为什么商品详情页可以用默认值降级,而下单页面不能呢?这就要讲到主链路的规划,简单来说,电商平台的用户购物行为是一个漏斗模型,上宽下窄,用户流量在漏斗口最多,在尾部最少,越接近尾部的流量被转化为购物行为的比例就越高,因此越到后面对降级的容忍度就越低。商品搜索和商品详情页处于漏斗的上部,主要是导流端,在没有发生金钱往来的情况下我们可以容忍一定程度的降级误差。但对于下单页,这是整个漏斗模型的尾部,直接发生交易的环节,绝不能容忍任何金钱上的误差。老师在实际工作里设计商品详情页服务的时候,计算优惠访问返回的上限是1000ms,超过这个数字则自动降级为0优惠进行返回。
3.3. 恢复服务才是王道
这才称得上是正经的积极措施,fallback会尝试用各种方法获取正确的返回值,有这么几个常用场景。
- 缓存异常:假如因为缓存故障无法获取数据,在fallback逻辑中可以转而访问底层数据库(这个方法不能用在热点数据上,否则可能把数据库打挂,或者引发更大范围的服务降级和熔断,要谨慎使用)。反过来如果数据库发生故障,也可以在fallback里访问缓存,但要注意数据一致性
- 切换备库:一般大型应用都会采用主从+备库的方式做灾备,假如我们的主从库都发生了故障,往往需要人工将数据源切换到备份数据库,我们在fallback中可以先于人工干预之前自动访问备库数据。这个场景尽量限定在核心主链路接口上,不要动不动就去访问备库,以免造成脏读幻读。
- 重试:Ribbon可以处理超时重试,但对于异常情况来说(比如当前资源被暂时锁定),我们可以在fallback中自己尝试重新发起接口调用
- 人工干预:有些极其重要的接口,对异常不能容忍,这里可以借助fallback启动人工干预流程,比如做日志打点,通过监控组件触发报警,通知人工介入
3.4. 一错再错-多次降级
在某种情况下,fallback里由于各种问题又出现一个异常来。这时我们可以做二次降级,也就是在fallback中再引入一个fallback。当然,你也可以引入三四五六七八更多层的降级,对应一些复杂的大型应用,比如淘系很多核心系统,多级降级是很常见的,根据系统故障的严重程度采取更精细粒度的降级方案。
那假如这一连串降级全部都失败了,难道要牢底坐穿不成?对这种一错再错无药可救的顽固分子,Hystrix也没有办法,只好放你走了,将异常抛到最外层。
3.5. Request Cache
Request Cache并不是让你在fallback里访问缓存,它是Hystrix的一个特殊功能。我们可以通过@CacheResult和@CacheKey两个注解实现,配置如下
@CacheResult
@HystrixCommand
public Friend requestCache(@CacheKey Integer id) {
}
@CacheResult注解的意思是该方法的结果可以被Hystrix缓存起来,@CacheKey指定了这个缓存结果的业务ID是什么。在一个Hystrix上下文范围内,如果使用相同的参数对@CacheResult修饰的方法发起了多次调用,Hystrix只会在首次调用时向服务节点发送请求,后面的几次调用实际上是从Hystrix的本地缓存里读取数据。
Request Cache并不是由调用异常或超时导致的,而是一种主动的可预知的降级手段,严格的说,这更像是一种性能优化而非降级措施。
4. Feign+Hystrix实现fallback降级方案
创建一个新的目录hystrix,在该目录下创建hystrix-fallback项目模块
我们这个新的模块还是一个服务调用者,参考前面的feign-consumer-advanced的引入POM依赖,注意要添加hystrix的依赖包了
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>hystrix-fallback</artifactId>
<name>hystrix-fallback</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>feign-client-intf</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
</dependencies>
</project>
我们需要定义一个可以抛出异常的Feign接口调用
先去Feign-client-intf里新建立一个error接口
@GetMapping("/error")
String error();
再去feign-client-advanced里实现这个方法
@Override
public String error() {
throw new RuntimeException("mouse droppings");
}
再回到hystrix-fallback里进行applicaiton代码的编写
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
//断路器
@EnableCircuitBreaker
public class HystrixFallbackApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixFallbackApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
在hystrix-fallback中新建一个service包,在里面创建一个MyService的Interface
package com.icodingedu.springcloud.service;
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name = "feign-client")
public interface MyService extends IServiceAdvanced {
}
在hystrix-fallback中新建一个业务包hystrix,在里面创建一个业务类Fallback
package com.icodingedu.springcloud.hystrix;
import com.icodingedu.springcloud.pojo.PortInfo;
import com.icodingedu.springcloud.service.MyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
//Fallback其实就是我们的容错类
@Slf4j
@Component
public class Fallback implements MyService {
@Override
public String error() {
log.info("********* sorry ********");
return "I am so sorry!";
}
//下面的方法都暂时不管,只针对error方法做实现
@Override
public String sayHello() {
return null;
}
@Override
public PortInfo sayHello(PortInfo portInfo) {
return null;
}
@Override
public String retry(int timeout) {
return null;
}
}
再回到MyService接口里加上降级处理的实现类
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.hystrix.Fallback;
import org.springframework.cloud.openfeign.FeignClient;
//这是一个整体容错方案,接口里的所有方法都进行了容错管理都需要在上面的Fallback类里实现容错
@FeignClient(name = "feign-client",fallback = Fallback.class)
public interface MyService extends IServiceAdvanced {
}
创建controller调用实现
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.service.MyService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
public class HystrixController {
@Resource
private MyService myService;
@GetMapping("/fallback")
public String fallback(){
return myService.error();
}
}
配置properties文件
spring.application.name=hystrix-consumer
server.port=50001
# 允许bean的注解重载
spring.main.allow-bean-definition-overriding=true
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
# 开启feign下的hystrix功能
feign.hystrix.enabled=true
# 是服务开启服务降级
hystrix.command.default.fallback.enabled=true
测试启动顺序
eureka-server、feign-client-advanced、hystrix-fallback
测试结果是feign-client-advanced出现:java.lang.RuntimeException: mouse droppings 异常
而hystrix-fallback则返回降级后的返回 I am so sorry !
测试结果:调用产生异常后就会返回降级的实现内容
5. Hystrix实现timeout降级
在controller里加入一个timeout的方法,还是用之前的retry方法
@GetMapping("/timeout")
public String timeout(int second){
return myService.retry(second);
}
在Fallback类里将降级方法实现了
@Override
public String retry(int timeout) {
return "Yout are late !";
}
properties里配置超时降级机制
# 配置全局超时
hystrix.command.default.execution.timeout.enabled=true
# 全局超时时间,默认是1000ms
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=2000
# 超时以后终止线程
hystrix.command.default.execution.isolation.thread.interruptOnTimeout=true
# 取消的时候终止线程
hystrix.command.default.execution.isolation.thread.interruptOnFutureCancel=true
# 这里把ribbon的超时重试机制也拿进来
# 每台机器最大的重试次数
feign-client.ribbon.MaxAutoRetries=0
# 可以重试的机器数量
feign-client.ribbon.MaxAutoRetriesNextServer=0
# 连接请求超时的时间限制ms
feign-client.ribbon.ConnectTimeout=1000
# 业务处理的超时时间ms
feign-client.ribbon.ReadTimeout=5000
# 默认是false,默认是在get上允许重试
# 这里是在所有HTTP Method进行重试,这里要谨慎开启,因为POST,PUT,DELETE如果涉及重试就会出现幂等问题
feign-client.ribbon.OkToRetryOnAllOperations=false
重启hystrix-fallback进行测试,发现超时没有返回达到配置的2秒就直接降级了
刚刚是对全局进行的超时配置,如果想要对具体方法实现如下
# 将default替换成MyService
# 具体方法的超时时间
hystrix.command.MyService#retry(int).execution.isolation.thread.timeoutInMilliseconds=4000
这块如果不知到这个参数怎么写的可以在main里输出一下:MyService#retry(int)
public static void main(String[] args) throws NoSuchMethodException {
System.out.println(Feign.configKey(MyService.class,
MyService.class.getMethod("retry",int.class)
));
}
也可以通过注解的方式实现具体方法的超时降级,下面会讲到
6. Requet Cache降压实现
Request Cache并不是由调用异常或超时导致的,而是一种主动的可预知的降级手段,严格的说,这更像是一种性能优化而非降级措施
代码直接开撸,在hytrix-fallback里创建一个RequestCacheService
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.pojo.PortInfo;
import com.netflix.hystrix.contrib.javanica.cache.annotation.CacheKey;
import com.netflix.hystrix.contrib.javanica.cache.annotation.CacheResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@Service
@Slf4j
public class RequestCacheService {
@Resource
private MyService service;
//这里就是将结果缓存并根据参数值来进行k-v的缓存对应
@CacheResult
@HystrixCommand(commandKey = "cacheKey")
public PortInfo requestCache(@CacheKey String name){
log.info("request cache name "+ name);
PortInfo portInfo = new PortInfo();
portInfo.setName(name);
portInfo.setPort("2020");
portInfo = service.sayHello(portInfo);
log.info("after request cache name "+name);
return portInfo;
}
}
超时降级的方法key另一种配置方式
注意这里的@HystrixCommand(commandKey = "cacheKey")
这个cacheKey就可以用在上面的具体方法超时时间上,但还要配套fallbackMethod的方法
hystrix.command.cacheKey.execution.isolation.thread.timeoutInMilliseconds=2000
编写controller的实现
@Autowired
private RequestCacheService requestCacheService;
@GetMapping("/cache")
public PortInfo requestCache(String name){
//缓存存放在hystrix的上下文中,需要初始化上下文,上下文打开后执行完还要关闭context.close();
//使用try-catch-finally里去context.close();掉
//或者使用lombok的@Cleanup注解,默认调用close方法,如果默认不是close方法而是其他方法关闭
//可以这样来设置@Cleanup("shutup")
@Cleanup HystrixRequestContext context = HystrixRequestContext.initializeContext();
//我们在这里调用两次看看执行过程
PortInfo portInfo = requestCacheService.requestCache(name);
portInfo = requestCacheService.requestCache(name);
return portInfo;
}
properties里可以配置也可以不配置
# 默认requestCache是打开状态
hystrix.command.default.requestCache.enabled=true
重启后进行测试发现,feign-client-advanced的控制台只被调用了一次,这样就可以将远程调用需要K-V缓存的内容放到一个hystrix上下文中进行调用,只要调用参数值一样,无论调用多少次返回值都是从缓存中取这样就能提升一定的性能不用每次都进行远程调用了
7. 多级降级实现
去到Fallback类里再创建两个降级的实现方法
@Override
//降级方法的参数要保持一致
@HystrixCommand(fallbackMethod = "fallback2")
public String error() {
log.info("********* sorry ********");
throw new RuntimeException("first fallback");
}
@HystrixCommand(fallbackMethod = "fallback3")
public String fallback2(){
log.info("********* sorry again ********");
throw new RuntimeException("first fallback again");
}
public String fallback3(){
log.info("********* sorry again 2 ********");
return "success sorry again 2!";
}
通过注解配置实现timeout超时降级
去到controller里添加一个方法
@GetMapping("/timeout2")
@HystrixCommand(
fallbackMethod = "timeoutfallback",
//可以忽略不进行降级的异常
ignoreExceptions = {IllegalArgumentException.class},
//这个commandProperties是一个数组,所以可以配置多个HystrixProperty
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds",value = "6000")
}
)
public String timeout2(int second){
return myService.retry(second);
}
//这个是降级的实现方法
public String timeoutfallback(int second){
return "timeout success "+second;
}
这里需要注意的是在注解上添加的超时和配置文件里配置的全局超时设置之间的时间关系
8. Hystrix超时配置和Ribbon超时重试共用的问题
Feign集成了Ribbon和Hystrix两个组件,它俩都各自有一套超时配置,那到底哪个超时配置是最终生效的那个呢
我们先来复习一下Ribbon的超时时间计算公式:
最大超时时间=(连接超时时间+接口超时时间)*(当前节点重试次数+1)*(换节点重试次数+1)
假如经过上述计算,Ribbon的超时时间是2000ms,那么Hystrix的超时时间应该设置成多少才合理呢?我们先来看看Hystrix的默认全局配置
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=1000
以上全局配置设置了Hystrix的熔断时间为1000ms。这里Hystrix的超时时间设置比Ribbon配置的时间短,那么不等Ribbon重试结束,Hystrix判定超时后就会直接执行熔断逻辑。因此,Hystrix和Ribbon是一个共同作用的关系,谁先到达超时指标就会率先起作用。
通常来讲,Hystrix的熔断时间要比Ribbon的最长超时时间设置的略长一些,这样就可以让Ribbon的重试机制充分发挥作用,以免出现还没来得及重试就进入fallback逻辑的情况发生。
那如果我们有一些接口对响应时间的要求特别高,比如说商品详情页接口,元数据必须在2s以内加载返回,那我们怎么针对方法设置更细粒度的Hystrix超时限制?
-
这个时候我们就需要以方法为维度来设置服务降级时间而不是直接应用全局配置了
-
方法级别的降级时间优先级是高于全局应用级别的,即便是方法超时时长>全局超时时长,也是走方法级别的超时时间
# 具体方法的超时时间 hystrix.command.MyService#retry(int).execution.isolation.thread.timeoutInMilliseconds=6000 # 超时时间 hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=3000
-
方法上实现超时时间配置有三种方法
- 通过方法签名配置:MyService#retry(int)
- 通过commandKey配置
- 通过注解配置@HystrixProperty
9. 熔断器核心机制理解分析
服务熔断是建立在降级之上的强力手段,是进击的降级,对于调用后进入降级处理的业务反复fallback就需要启用熔断机制了,不再进行远程调用,待什么时候服务恢复了再恢复远程调用访问
以上流程中省略了服务降级部分的业务,我们只关注熔断部分。
- 发起调用-切面拦截:由于熔断器是建立在服务降级的基础上,因此在前面的触发机制上和服务降级流程一模一样。在向@HystrixCommand注解修饰的方法发起调用时,将会触发由Aspect切面逻辑
- 检查熔断器:当熔断状态开启的时候,直接执行进入fallback,不执行远程调用
- 发起远程调用-异常情况:还记得前面服务降级小节里讲到的,服务降级是由一系列的回调函数构成的,当远程方法调用抛出异常或超时的时候,这个异常情况将被对应的回调函数捕捉到
- 计算Metrics:这里的Metrics指的是衡量指标,在异常情况发生后,将会根据断路器的配置计算当前服务健康程度,如果达到熔断标准,则开启断路开关,后续的请求将直接进入fallback流程里
熔断器有三个状态阶段:
- 熔断器open状态:远程服务关闭状态,服务在一定时间内不得发起外部调用,访问服务调用者直接进入fallback里处理
- 熔断器half-open状态:在fallback里待的也够久了,给一个改过自新的机会,可以尝试发起真实的服务调用,但这一切都在监视下进行,一旦远程调用不成功无论是否达到熔断的阈值则直接熔断,等待下次尝试调用的机会
- 熔断器closed:上一步尝试进行远程调用成功了,那便关闭熔断,开始正常远程访问
熔断器的判断阀值:
主要从两个维度判断熔断器是否开启:
- 在一定时间窗口内,发生异常的请求数量达到临界值
- 在一定时间窗口内,发生异常的请求数量占请求总数量达到一定比例
其中时间窗口的大小也是可以配置的,而且我们还可以指定half-open判定的时间间隔,比如说熔断开启10秒以后进入half-open状态,此时就会让一个请求发起调用,如果成功就关闭熔断器。
10. Feign集成Hystrix熔断器
这一节比较轻松,只需要通过配置来进行熔断设置即可
# 熔断的前提条件(请求的数量),在一定的时间窗口内,请求达到5个以后,才开始进行熔断判断
hystrix.command.default.circuitBreaker.requestVolumeThreshold=5
# 失败请求数达到50%则熔断开关开启
hystrix.command.default.circuitBreaker.errorThresholdPercentage=50
# 当熔断开启后经过多少秒再进入半开状态,放出一个请求进行远程调用验证,通过则关闭熔断不通过则继续熔断
hystrix.command.default.circuitBreaker.sleepWindowInMilliseconds=15000
# 配置时间窗口
hystrix.command.default.metrics.rollingStats.timeInMilliseconds=20000
# 开启或关闭熔断的功能
hystrix.command.default.circuitBreaker.enabled=true
# 强制开启熔断开关
hystrix.command.default.circuitBreaker.forceOpen=false
# 强制关闭熔断开关
hystrix.command.default.circuitBreaker.forceClosed=false
要把retry的全局延时降级时间调整成2秒进行测试
1秒正常,2秒降级进行测试,验证阈值的百分比触发条件
作业:
上面配置的是基于全局的熔断器,如何配置基于某个方法的熔断机制
可以参考超时降级的配置方法,将配置文件中default换成具体key,通过注解实现的方式
11. 主链路降级熔断规划
- 首先是识别主链路
- 根据主链路分析主线流程和业务底线以及故障恢复
12. 线程隔离机制
-
线程池拒绝
这一步是线程隔离机制直接负责的,假如当前商品服务分配了10个线程,那么当线程池已经饱和的时候就可以拒绝服务,调用请求会收到Thread Pool Rejects,然后将被转到对应的fallback逻辑中。其实控制线程池中线程数量是由多个参数共同作用的,我们分别看一下
- coreSize:核心线程数(默认为10)
- maximumSize:设置最大允许的线程数(默认也是10),这个属性需要打开allowMaximumSizeToDivergeFromCoreSize之后才能生效,后面这个属性允许线程池中的线程数扩展到maxinumSize个
- queueSizeRejectionThreshold:这个属性经常会被忽略,这是控制队列最大阈值的,Hystrix默认设置了5。即便把maximumSize改大,但因为线程队列阈值的约束,你的程序依然无法承载很多并发量。所以当你想改大线程池的时候,需要这两个属性一同增大
- keepAliveTimeMinutes:这个属性和线程回收有关,我们知道线程池可以最大可以扩展到maximumSize,当线程池空闲的时候,多余的线程将会被回收,这个属性就指定了线程被回收前存活的时间。默认2分钟,如果设置的过小,可能会导致系统频繁回收/新建线程,造成资源浪费
-
线程Timeout:我们通常情况下认为延迟只会发生在网络请求上,其实不然,在Netflix设计Hystrix的时候,就有一个设计理念:调用失败和延迟也可能发生在远程调用之前(比如说一次超长的Full GC导致的超时,或者方法只是一个本地业务计算,并不会调用外部方法),这个设计理念也可以在Hystrix的Github文档里也有提到。因此在方法调用过程中,如果同样发生了超时,则会产生Thread Timeout,调用请求被流转到fallback
-
服务异常/超时:这就是我们前面学习的的服务降级,在调用远程方法后发生异常或者连接超时等情况,直接进入fallback
**代码实现:**在service包下创建一个业务类来做测试
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.pojo.PortInfo;
import com.netflix.hystrix.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class GetPortInfoCommand extends HystrixCommand<PortInfo> {
private String name;
public GetPortInfoCommand(String name) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("GetPortInfoCommandPool"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetPortInfoCommandKey"))
.andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey(name))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withExecutionIsolationStrategy(HystrixCommandProperties.ExecutionIsolationStrategy.THREAD)
.withExecutionTimeoutInMilliseconds(25000)
//.withExecutionIsolationSemaphoreMaxConcurrentRequests(3)
)
.andThreadPoolPropertiesDefaults(
HystrixThreadPoolProperties.Setter()
.withCoreSize(3)
.withQueueSizeRejectionThreshold(1)
)
);
this.name = name;
}
@Override
protected PortInfo run() throws Exception {
log.info("********进入线程池******");
Thread.sleep(20000);
PortInfo portInfo = new PortInfo();
portInfo.setName(name);
portInfo.setPort("99999");
log.info("********执行完毕******");
return portInfo;
}
}
controller层进行验证,创建一个方法
@GetMapping("/command")
public String portInfoCommand(){
com.netflix.hystrix.HystrixCommand<PortInfo> hystrixCommand = new GetPortInfoCommand("gavin.huang");
PortInfo portInfo = hystrixCommand.execute();
return "success "+portInfo.getName()+" --- "+portInfo.getPort();
}
线程隔离原理
- 线程池技术:它使用Hystrix自己内建的线程池去执行方法调用,而不是使用Tomcat的容器线程
- 信号量技术:它直接使用Tomcat的容器线程去执行方法,不会另外创建新的线程,信号量只充当开关和计数器的作用。获取到信号量的线程就可以执行方法,没获取到的就转到fallback
从性能角度看
- 线程池技术:涉及到线程的创建、销毁和任务调度,而且CPU在执行多线程任务的时候会在不同线程之间做切换,我们知道在操作系统层面CPU的线程切换是一个相对耗时的操作,因此从资源利用率和效率的角度来看,线程池技术会比信号量慢
- 信号量技术:由于直接使用Tomcat容器线程去访问方法,信号量只是充当一个计数器的作用,没有额外的系统资源消费,所以在性能方面具有明显的优势
信号量实现只需要修改service 的两个地方
//在service的构造方法里将THREAD改成SEMAPHORE
//增加信号量控制.withExecutionIsolationSemaphoreMaxConcurrentRequests(3)
//下面线程池的内容注释掉即可
public GetPortInfoCommand(String name) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("GetPortInfoCommandPool"))
.andCommandKey(HystrixCommandKey.Factory.asKey("GetPortInfoCommandKey"))
.andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey(name))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withExecutionIsolationStrategy(HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE)
.withExecutionTimeoutInMilliseconds(25000)
.withExecutionIsolationSemaphoreMaxConcurrentRequests(3)
)
// .andThreadPoolPropertiesDefaults(
// HystrixThreadPoolProperties.Setter()
// .withCoreSize(3)
// .withQueueSizeRejectionThreshold(1)
// )
);
this.name = name;
}
13. Turbine聚合信息服务
Turbine需要连接了服务注册中心获取服务提供者列表以便进行相应信息聚合
在hystrix目录下创建一个hystrix-turbine的module
导入POM依赖,和hystrix-fallback基本一样,只需要把feign-client-intf依赖去掉并加入turbine的依赖即可
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>hystrix-turbine</artifactId>
<name>hystrix-turbine</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-turbine</artifactId>
</dependency>
</dependencies>
</project>
编写application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.netflix.turbine.EnableTurbine;
@EnableDiscoveryClient
@EnableHystrix
@EnableTurbine
@EnableAutoConfiguration
public class HystrixTurbineApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixTurbineApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
配置propertie的配置文件
spring.application.name=hystrix-turbine
server.port=50002
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
# 指定需要监控的服务名
turbine.app-config=hystrix-consumer
turbine.cluster-name-expression="default"
# 将端口和hostname作为区分不同服务的条件,默认只用hostname,默认方式在本地一个IP下就区分不开了
turbine.combine-host-port=true
# turbine通过这个路径获取监控数据,所以监控的服务也要开放这个路径监控
turbine.instanceUrlSuffix.default=actuator/hystrix.stream
turbine.aggregtor.clusterConfig=default
去到hystrix-fallback项目中打开actuator的配置
# actuator暴露接口
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
去到浏览打开项目hystrix-fallback的actuator路径进行查看
http://localhost:50001/actuator 可以看到所有监控的信息
http://localhost:50001/actuator/hystrix.stream 可以看到一个长连接的ping在不断返回结果
现在启动hystrix-turbine项目
http://localhost:50002/turbine.stream 访问这个路径
从这个页面发现turbine自己并不生产数据,只是将数据进行收集
14. Turbine集成Dashboard
创建一个hystrix-dashboard的module
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>hystrix-dashboard</artifactId>
<name>hystrix-dashboard</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
</dependencies>
</project>
创建application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.netflix.hystrix.dashboard.EnableHystrixDashboard;
@EnableHystrixDashboard
@SpringBootApplication
public class HystrixDashboardApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixDashboardApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建properties配置文件
spring.application.name=hystrix-dashboard
server.port=50003
# hystrix监控路径:这个是一台服务的监控检查
# http://localhost:50001/actuator/hystrix.stream
# turbine监控路径:这是整合多台服务的监控检查
# http://localhost:50002/turbine.stream
分别启动eureka-server、feign-client-advanced、hystrix-fallback、hystrix-turbine、hystrix-dashboard
按照顺序启动完毕后登录:http://localhost:50003/hystrix
SpringCloud服务熔断详解(四)
1. 熔断器核心机制理解分析
频繁出错的应用,应该快速失败,不要占用系统,快速失败后不再进行远程调用了,直接进行本地访问,待远程恢复后再进行访问
以上流程中省略了服务降级部分的业务,我们只关注熔断部分。
- 发起调用-切面拦截:由于熔断器是建立在服务降级的基础上,因此在前面的触发机制上和服务降级流程一模一样。在向@HystrixCommand注解修饰的方法发起调用时,将会触发由Aspect切面逻辑
- 检查熔断器:当熔断状态开启的时候,直接执行进入fallback,不执行远程调用
- 发起远程调用-异常情况:还记得前面服务降级小节里讲到的,服务降级是由一系列的回调函数构成的,当远程方法调用抛出异常或超时的时候,这个异常情况将被对应的回调函数捕捉到
- 计算Metrics:这里的Metrics指的是衡量指标,在异常情况发生后,将会根据断路器的配置计算当前服务健康程度,如果达到熔断标准,则开启断路开关,后续的请求将直接进入fallback流程里
熔断器有三个状态阶段:
- 熔断器open状态:远程服务关闭状态,服务在一定时间内不得发起外部调用,访问服务调用者直接进入fallback里处理
- 熔断器half-open状态:在fallback里待的也够久了,给一个改过自新的机会,可以尝试发起真实的服务调用,但这一切都在监视下进行,一旦远程调用不成功无论是否达到熔断的阈值则直接熔断,等待下次尝试调用的机会
- 熔断器closed:上一步尝试进行远程调用成功了,那便关闭熔断,开始正常远程访问
熔断器的判断阀值:
主要从两个维度判断熔断器是否开启:
- 在一定时间窗口内,发生异常的请求数量达到临界值
- 在一定时间窗口内,发生异常的请求数量占请求总数量达到一定比例
其中时间窗口的大小也是可以配置的,而且我们还可以指定half-open判定的时间间隔,比如说熔断开启10秒以后进入half-open状态,此时就会让一个请求发起调用,如果成功就关闭熔断器。
2. Feign集成Hystrix熔断器
纯粹的properties配置,在hystrix-fallback里配置
# 熔断的前提条件(请求数量),在一定时间窗口内,请求达到5个以后,才开始熔断判读
hystrix.command.default.circuitBreaker.requestVolumeThreshold=5
# 失败请求数达到50%熔断开关开启
hystrix.command.default.circuitBreaker.errorThresholdPercentage=50
# 当熔断开启后经过多少秒进入半开判断,单位ms
hystrix.command.default.circuitBreaker.sleepWindowInMilliseconds=15000
# 发生异常的时间窗口,单位ms
hystrix.command.default.metrics.rollingStats.timeInMilliseconds=20000
# 开启熔断功能
hystrix.command.default.circuitBreaker.enabled=true
# 关闭强制开启和强制关闭熔断器开关
hystrix.command.default.circuitBreaker.forceOpen=false
hystrix.command.default.circuitBreaker.forceClosed=false
3. Turbine聚合信息服务集成Dashboard
3.1. 创建Turbine服务
Turbine需要连接了服务注册中心获取服务提供者列表以便进行相应信息聚合
在hystrix目录下创建一个hystrix-turbine的module
导入POM依赖,和hystrix-fallback基本一样,只需要把feign-client-intf依赖去掉并加入turbine的依赖即可
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>hystrix-turbine</artifactId>
<name>hystrix-turbine</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-turbine</artifactId>
</dependency>
</dependencies>
</project>
编写application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.netflix.turbine.EnableTurbine;
@EnableDiscoveryClient
@EnableHystrix
@EnableTurbine
@EnableAutoConfiguration
public class HystrixTurbineApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixTurbineApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
配置propertie的配置文件
spring.application.name=hystrix-turbine
server.port=50002
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
# 指定需要监控的服务名
turbine.app-config=hystrix-consumer
turbine.cluster-name-expression="default"
# 将端口和hostname作为区分不同服务的条件,默认只用hostname,默认方式在本地一个IP下就区分不开了
turbine.combine-host-port=true
# turbine通过这个路径获取监控数据,所以监控的服务也要开放这个路径监控
turbine.instanceUrlSuffix.default=actuator/hystrix.stream
turbine.aggregtor.clusterConfig=default
去到hystrix-fallback项目中打开actuator的配置
# actuator暴露接口
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
去到浏览打开项目hystrix-fallback的actuator路径进行查看
http://localhost:50001/actuator 可以看到所有监控的信息
http://localhost:50001/actuator/hystrix.stream 可以看到一个长连接的ping在不断返回结果
现在启动hystrix-turbine项目
http://localhost:50002/turbine.stream 访问这个路径
从这个页面发现turbine自己并不生产数据,只是将数据进行收集
3.2. Turbine集成Hystrix-dashboard
创建一个hystrix-dashboard的module
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>hystrix-dashboard</artifactId>
<name>hystrix-dashboard</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
</dependencies>
</project>
创建application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.netflix.hystrix.dashboard.EnableHystrixDashboard;
@EnableHystrixDashboard
@SpringBootApplication
public class HystrixDashboardApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(HystrixDashboardApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建properties配置文件
spring.application.name=hystrix-dashboard
server.port=50003
# hystrix监控路径:这个是一台服务的监控检查
# http://localhost:50001/actuator/hystrix.stream
# turbine监控路径:这是整合多台服务的监控检查
# http://localhost:50002/turbine.stream
分别启动eureka-server、feign-client-advanced、hystrix-fallback、hystrix-turbine、hystrix-dashboard
按照顺序启动完毕后登录:http://localhost:50003/hystrix
SpringCloud配置中心服务
1. 配置中心在微服务中的应用
1.1. 系统中常用的文件配置都是如何设置的
-
程序中硬编码
- 在代码中写死一个文件的配置项
- 如果使用绝对路径,更换了开发电脑会出现地址无法使用的问题
- 如果代码在windows上开发,部署到linux上就要在上线前修改配置文件路径
-
配置文件
- 一般将不经常修改的内容放到配置文件中
- 比如将端口号配置到yaml或properties文件中
-
环境变量
- 比如JDK的环境变量就是操作系统层面的配置
- 线上启动tomcat时会加入一下启动参数来启动服务
-
数据库/缓存存储
- 将属性配置到数据库或缓存中
- 在数据库或缓存中进行灵活设置
1.2. 这些配置有什么缺点和不足
-
格式不统一
- yaml
- json
- properties
- 使用哪种根据个人选择即可
-
没有版本控制
- 如果在yaml或properties中还能依赖git/svn进行版本控制
- 如果是配置在数据库中则脱离了版本控制的束缚,修改则完全没有轨迹
-
基于静态配置
- 如果是hardcode方式或配置文件方式则需要先修改再发布完成修改实现
-
分布零散
- 从架构的层面考虑,各个微服务团队放飞自我百花齐放,方式不同格式不同,出现问题也不同
- 没有统一的管理,比如几个项目都要连接数据库,如果数据库产生一些连接改变,就要修改所有项目的驱动串,通知所有的项目方进行修改
1.3. 在进行配置统一管理前先对配置项进行一下分类
1.3.1. 配置项的静态内容
- 环境配置
- 数据库连接
- Eureka的注册中心
- MQ的连接串
- 搜索引擎连接串
- 应用命,端口
- 安全配置
- 连接的密码
- 公钥私钥
- 对这些配置还要进行加解密
1.3.2. 配置项的动态内容
- 功能控制
- 在代码里配置开关,传统的灰度发布方法,很灰度发布对应的就是灰度测试(金丝雀测试)
- 人工熔断开关
- 蓝绿发布
- 数据源切换
- 业务规则
- 当日汇率
- 动态文案
- 规则引擎参数:当前折扣系数,国际物流费用(drools规则引擎)
- 应用参数
- 网关层要访问的黑白名单
- 缓存过期时间配置
1.4. 配置项的功能定义
- 高可用
- 版本管理
- 访问权限控制
- 动态推送变更
- 内容加密
- 配置分离的中心化管理思想
1.5. Config Server核心功能
- 统一配置:提供了一个中心化配置方案,将各个项目中的配置内容集中在Config Server端进行管理
- 环境隔离:Config Server提供了多种环境隔离机制,可以根据需要进行配置加载
- 动态刷新:支持运行期改变配置进行业务动态配置,以及功能的动态开关
- 配置加密解密,定向推送等功能
2. 直连式配置中心实施
2.1. 配置文件设置
直连配置中心的工作模式非常简单,Config Server直接从配置库(GitHub)中拉取配置同步到各个Server中来进行直连式配置
首先在GitHub上创建repo然后创建两个配置文件
命名规则:
应用名-环境名.yaml / 应用名-环境名.properties
我们创建开发环境的文件名为:config-consumer-dev.yaml
info:
profile: dev
name: config-dev
words: this is a development environment
创建生产环境的文件名:config-consumer-prod.yaml
info:
profile: prod
name: config-prod
words: this is a production environment
2.2. 搭建config-server
创建一个config目录,在目录中创建一个config-sever的module
指定POM导入config-server
config-server和eureka-server很像就是一个中心化配置服务所以加入的依赖就一个
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>config-server</artifactId>
<name>config-server</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
</dependencies>
</project>
创建application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.config.server.EnableConfigServer;
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
这次我们设置yaml配置文件
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/hjtbfx/config-repo.git
force-pull: true # 强制拉取资源文件
# search-paths: dev,prod* 支持csv格式配置多个路径同时获取,还支持*作为通配符
# username:
# password:
server:
port: 60001
启动config-server使用PostMan拉取配置文件
# GET
# config-consumer/dev 就是我们的命名规范application-profile
http://localhost:60001/config-consumer/dev
返回数据内容
{
"name": "config-consumer",
"profiles": [
"dev"
],
"label": null,
"version": "598ccaf69010b4ebbf13558fc3cb21f0212ceb3a",
"state": null,
"propertySources": [
{
"name": "https://github.com/hjtbfx/config-repo.git/config-consumer-dev.yaml",
"source": {
"info.profile": "dev",
"name": "config-dev",
"words": "this is a development environment"
}
}
]
}
并且在config-server的控制台还会输出一个本地资源的路径
2020-04-06 23:56:56.262 INFO 4610 --- [io-60001-exec-2] o.s.c.c.s.e.NativeEnvironmentRepository : Adding property source: file:/var/folders/bs/t5hqbzl52r18nfyx3v0dc0k80000gn/T/config-repo-6888460700345550079/config-consumer-dev.yaml
如果要获取repo仓库中不同路径下的文件,可以这样访问
http://localhost:60001/config-consumer/dev/master
如果只想获得文件内容
# 其实这也是一个通配符方式,返回yaml格式数据
http://localhost:60001/config-consumer-dev.yaml
# 同理可得,返回properties格式数据
http://localhost:60001/config-consumer-dev.properties
# 返回json格式数据
http://localhost:60001/config-consumer-dev.json
# 这里就说明config并不要求你在github要把所有格式保存了,他会按照你的要求进行统一
如果我们的文件不在repo的根目录下,可以在前面加上路径
http://localhost:60001/master/config-consumer-dev.yaml
通配的格式如下
http://localhost:60001/{application}/{profile}/{label}
http://localhost:60001/{application}-{profile}.json(.yaml .properties)
2.3. Client直连配置中心
在config目录下创建config-client的module
引入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>config-client</artifactId>
<name>config-client</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
</dependencies>
</project>
创建application启动类,只需要springboot启动类即可
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
@SpringBootApplication
public class ConfigClientApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigClientApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建controller实现配置获取的两种方式
package com.icodingedu.springcloud.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ConfigController {
//直接从外部github上的配置文件加载
@Value("${name}")
private String name;
//从外部将配置注入到本地配置文件,再从本地加载
@Value("${myWords}")
private String words;
@GetMapping("/name")
public String getName(){
return name;
}
@GetMapping("/words")
public String getWords(){
return words;
}
}
定义配置文件:bootstrap.yaml
为什么是bootstrap文件,而不是application,因为bootstrap的加载早于application,而且application在这里是要被写入属性的,所以需要在之前加载配置,因此要使用bootstrap配置
spring:
application:
name: config-client
cloud:
config:
uri: http://localhost:60001
profile: prod # 这个应该是从外部注入的,比如启动tomcat是传入参数来确定是什么环境
label: master
server:
port: 60002
myWords: ${words}
这样配置是启动不起来的原因是这里直接使用了spring.application.name作为applicationName了,要么和github上一致要么在下面spring.cloud.config里重写一下name
spring:
application:
name: config-client
cloud:
config:
uri: http://localhost:60001
profile: prod # 这个应该是从外部注入的,比如启动tomcat是传入参数来确定是什么环境
label: master
name: config-consumer
server:
port: 60002
myWords: ${words}
3. 配置中心配置项动态刷新
还在原来的config-client项目中进行修改
在POM中增加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
项目中创建一个新的controller
package com.icodingedu.springcloud.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("refresh")
//允许bean在运行期更新值,进入源码看下注释
@RefreshScope
public class RefreshController {
@Value("${words}")
private String words;
@GetMapping("/words")
public String getWords(){
return words;
}
}
修改配置文件yaml配置开放Actuator的内容
spring:
application:
name: config-client
cloud:
config:
uri: http://localhost:60001
profile: prod # 这个应该是从外部注入的,比如启动tomcat是传入参数来确定是什么环境
label: master
name: config-consumer
server:
port: 60002
myWords: ${words}
management:
security:
enabled: false
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
测试配置变更情况
1、我们到github里将words数据进行修改,添加一些内容
2、通过controller访问:http://localhost:60002/refresh/words 获得的数据还是历史未修改的值
3、需要通过actuator来进行刷新,在config-client端进行刷行
# POST 请求
http://localhost:60002/actuator/refresh
# 请求后会在控制台输出更新信息
4、再次访问就会产生更新信息了:http://localhost:60002/refresh/words
5、这种方式就可以进行不同服务器的功能开关了:对多台服务器中的某几台进行刷行,功能就只在这几台服务上打开,这样就做到手工灰度发布了
4. 配置中心高可用架构实现
-
方案1
使用Eureka注册中心来实现配置中心的高可用,将所有的配置中心注册到Eureka中,利用Eureka的服务发现,服务续约服务剔除来维护配置中心,配置调用方通过Eureka拿到配置中心列表再通过Ribbon负载均衡访问具体的配置中心即可
-
方案2
如果我们单独使用配置中心,系统中没有使用Eureka,可以在网关层做负载均衡,搭建多个配置中心接入到负载均衡的网关,可以是Nginx/HAProxy/Lvs,对于网关层HA可以配套keepalived的VIP进行HA,也可以直接使用LB的云服务进行使用
既然我们是在springcloud环境中,就来看下如何借助Eureka实现配置中心高可用的
创建一个新的module用来实现配置中心的高可用:config-server-eureka,证明这个配置中行集成了eureka
在POM中导入两个依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>config-server-eureka</artifactId>
<name>config-server-eureka</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
</dependencies>
</project>
创建application实现类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.config.server.EnableConfigServer;
@SpringBootApplication
@EnableConfigServer
@EnableDiscoveryClient
public class ConfigServerEurekaApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigServerEurekaApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建application.yaml,需要配置eureka的client加入到eureka-server中
spring:
application:
name: config-server-eureka
cloud:
config:
server:
git:
uri: https://github.com/hjtbfx/config-repo.git
force-pull: true # 强制拉取资源文件
# search-paths: dev,prod* 支持csv格式配置多个路径同时获取,还支持*作为通配符
# username:
# password:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:20001/eureka/
server:
port: 60003
可以按顺序启动eureka-server、config-server-eureka了
我们来修改前面创建的config-client项目
先修改POM,加入eureka的服务发现依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
修改启动类增加eureka的服务发现注解
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class ConfigClientApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigClientApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
修改config-client的bootstrap.yaml的配置文件
- 修改config下的uri连接到eureka服务
- 将eureka服务发现加入到配置中
spring:
application:
name: config-client
cloud:
config:
# uri: http://localhost:60001
discovery:
enabled: true
service-id: config-server-eureka
profile: prod # 这个应该是从外部注入的,比如启动tomcat是传入参数来确定是什么环境
label: master
name: config-consumer
eureka:
client:
serviceUrl:
defaultZone: http://localhost:20001/eureka/
server:
port: 60002
myWords: ${words}
management:
security:
enabled: false
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
这样就可进行测试了,启动服务然后进行验证即可
SpringCloud配置中心&消息总线(五)
1. 配置中心的信息加密方式
之前都是明文保存,比如用户名,密码(社会工程学)
config是如何做密码加密和解密,通过config的encrypt的功能加密明文生成密文,然后把密文放到github上,需要在密文前加一个标识{cipher},config 发现有这样的标识,就会启动解密
加密的密钥的方式:对称密钥、非对称密钥
我们是用对称加密来进行config的加解密
- 对JDK(Java Cryptography Extension)进行一定的优化,替换JDK端的JCE组件,因为springcloud的config在进行加解密的时候要用到JCE组件
- 在JDK8u161之后的版本不需要升级(7u171以后不需要升级,6u16以后不需要升级)
- 为什么要升级JCE,自带的JDK加密算法位数都不超过256位,需要手工替换
- JCE解压后将里面的两个Jar包放到jdk-home/jre/lib/security目录
在config-server-eureka中增加bootstrap.yaml
# 在bootstrap中增加对称加密的key
encrypt:
key: 19491001
通过encrypt进行加密
# POST 明文:icodingedu is very well!
http://localhost:60003/encrypt
# 获得密文
289765ddfd0a7aaf747f59c549cf81a76dce270067a22b91b7ed3259d41a7c550b9de1a2c913b5a3f99ecf779a66f327
# 如何解密
# POST 密文
http://localhost:60003/decrypt
如果在系统中自动解密,则需要将密文加上{cipher}进行密文标识
# Github里
introduce: '{cipher}289765ddfd0a7aaf747f59c549cf81a76dce270067a22b91b7ed3259d41a7c550b9de1a2c913b5a3f99ecf779a66f327'
在config-client获取introduce和没有加密的配置一样,正常获取即可
2. 总线式配置架构思考
如果我们的config-client是对外提供负载均衡服务的,有60002,60012,60022三台服务,这时我们配置更新了,如果需要三台机器都更新配置,是否需要在每台服务上都执行一遍actuator/refresh?如果需要全部更新则需要在三台机器上都执行
在软件工程领域有这样一句名言:计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决
- 响应事件:对变更事件做出响应
- 消息事件:各个服务节点都可以从这个组件中消费事件
自己如果做:程序订阅一个更新调用actuator/refresh
SpringCloud bus + config + github搭建一套总线式的配置中心
3. 消息总线在微服务中的应用
BUS-消息总线:代理了将消息变更发送给所有服务节点的角色
Stream组件代理了大部分消息中间件的通信服务,BUS在实际应用中大多是为了应对消息广播的场景
- BUS底层还需要依赖消息中间件来完成消息的分发
- 对于总线式的配置中心架构有两个问题要解决
- 谁来发起变更:是由服务节点config-server,还是config-client?
- 何时发起变更:是手工发起变更,还是每次github上更改后自动推送?
4. BUS和消息队列的集成方式
4.1. 集成方式
前面我们了解了Bus的工作方式,在动手改造配置中心之前,我们先来了解一下Bus有哪些接入方式。
Spring的组件一向是以一种插件式的方式提供功能,将组件自身和我们项目中的业务代码隔离,使得我们更换组件的成本可以降到最低。Spring Cloud Bus也不例外,它的底层消息组件非常容易替换,替换过程不需要对业务代码引入任何变更。Bus就像一道隔离了底层消息组件和业务应用的中间层,比如我们从RabbitMQ切换为Kafka的时候,只需要做两件事就好了:
- 在项目pom中替换依赖组件
- 更改配置文件里的连接信息
4.2. 接入RabbitMQ
RabbitMQ是实现了AMQP(Advanced Message Queue Protocal)的开源消息代理软件,也是大家平时项目中应用最广泛的消息分发组件之一。同学们在分布式章节应该已经深入了解了消息队列的使用,这里我们就不再赘述了。
接入RabbitMQ的方式很简单,我们只要在项目中引入以下依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
点进去查看这个依赖的pom,你会发现它依赖了spring-cloud-starter-stream-rabbit
,也就是说Stream组件才是真正被用来发送广播消息到RabbitMQ的,Bus这里只是帮我们封装了整个消息的发布和监听动作。
接下来我们看下项目中所需的具体配置:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
上面配置分别指定了RabbitMQ的地址、端口、用户名和密码,以上均采用RabbitMQ中的默认配置。
4.3. 接入Kafka
要使用Kafka来实现消息代理,只需要把上一步中引入的spring-cloud-starter-bus-amqp依赖替换成spring-cloud-starter-bus-kafka依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-kafka</artifactId>
</dependency>
如果大家的Kafka和ZooKeeper都运行在本地,并且采用了默认配置,那么不需要做任何额外的配置,就可以直接使用。但是在生产环境中往往Kafka和ZooKeeper会部署在不同的环境,所以就需要做一些额外配置:
属性 | 含义 |
---|---|
spring.cloud.stream.kafka.binder.brokers | Kafka服务节点(默认localhost) |
spring.cloud.stream.kafka.binder.defaultBrokerPort | Kafka端口(默认9092) |
spring.cloud.stream.kafka.binder.zkNodes | ZooKeeper服务节点(默认localhost) |
zspring.cloud.stream.kafka.binder.defaultZkPort | ZooKeeper端口(默认2181) |
5. 实现总线式架构的配置中心
5.1. Config Server搭建
创建bus目录,然后创建一个config-bus-server
添加POM文件依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>config-bus-server</artifactId>
<name>config-bus-server</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
我们看一下bus-amqp这个依赖有什么玄机,进去后看到他里面还藏着一个stream-rabbit的组件,所有bus其实就是一个空壳子,他在通信的时候引入的是stream-rabbit这个适配层
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
<version>3.0.3.RELEASE</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-bus</artifactId>
<version>2.2.1.RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies>
创建启动类application
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.config.server.EnableConfigServer;
@SpringBootApplication
@EnableConfigServer
@EnableDiscoveryClient
public class ConfigBusServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigBusServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
配置application.yaml和bootstrap.yaml,可以从config-server-eureka项目中copy过来
在application.yaml里修改application name并添加rabbitmq的连接字符串,开放actuator的所有endpoint
可以从config-client里复制actuator的内容
- application.name
- server.port
- rabbitmq
- actuator
spring:
application:
name: config-bus-server
rabbitmq:
host: 39.98.81.253
port: 5672
username: guest
password: guest
cloud:
config:
server:
git:
uri: https://github.com/hjtbfx/config-repo.git
force-pull: true # 强制拉取资源文件
# search-paths: dev,prod* 支持csv格式配置多个路径同时获取,还支持*作为通配符
# username:
# password:
eureka:
client:
serviceUrl:
defaultZone: http://localhost:20001/eureka/
server:
port: 60011
management:
security:
enabled: false
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
启动测试一下:RabbitMQ要提前启动好
eureka-server、config-server-bus
测试一下配置是否能拿到:http://localhost:60011/config-consumer/prod
测试一下actuator:http://localhost:60011/actuator
# 下面我们将使用actuator的这个更新来进行批量通知更新
# 如果只想更新部分节点则可以使用{destination}路径参数
http://localhost:60011/actuator/bus-refresh/{destination}
5.2. Config Client搭建
在bus目录下创建一个config-bus-client
创建POM依赖,可以从config-client里复制过来
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>config-bus-client</artifactId>
<name>config-bus-client</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
<!-- 如果使用kafka只需要改一下依赖即可,业务代码层不同调整
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-kafka</artifactId>
</dependency>
-->
</dependencies>
</project>
启动类和实现类可以直接从config-client里复制过来
application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class ConfigBusClientApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ConfigBusClientApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
把config-client里的controller都copy过来,这个bus推送在代码上是没有什么感知,只会在yaml配置上有所不同,我们来进行一下yaml的配置,可以从config-client里把yaml复制过来进行修改
- application.name
- service-id
- server.port
- rabbitmq
- stream:设置默认的底层消息中间件(比如项目使用了两个消息中间件,这里就可以只指定一个)
spring:
application:
name: config-bus-client
rabbitmq:
host: 39.98.81.253
port: 5672
username: guest
password: guest
cloud:
stream:
default-binder: rabbit
config:
# uri: http://localhost:60001
discovery:
enabled: true
service-id: config-bus-server
profile: prod # 这个应该是从外部注入的,比如启动tomcat是传入参数来确定是什么环境
label: master
name: config-consumer
eureka:
client:
serviceUrl:
defaultZone: http://localhost:20001/eureka/
server:
port: 60012
myWords: ${words}
management:
security:
enabled: false
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
启动两个config-bus-client进行测试
先看一下现有的words值
http://localhost:60012/refresh/words
http://localhost:60013/refresh/words
然后去github上修改words值,修改后再访问并没有改变
这是时候我们去config-bus-server上进行更新
# POST
# http://localhost:60011/actuator/bus-refresh
# 返回204就说明全部更新完成了
现在去访问多个config-bus-client看看效果,更新完成没,已全部更新
也可以发送到某个client节点,在具体的config-bus-client节点上执行
# POST
# http://localhost:60012/actuator/bus-refresh
# 这样也会触发所有节点更新
可以指定某个具体服务的具体端口进行更新,这个就要使用{destination}参数了
# POST 将请求发送到confi-bus-server上
# http://localhost:60011/actuator/bus-refresh/config-bus-client:60014
# {destination}参数格式:serverName:serverPort
# 也可以在端口上使用通配符,让这个服务的所有的端口都更新
# http://localhost:60011/actuator/bus-refresh/config-bus-client:*
注意:bus会在rabbitmq上自动创建exchagne、queue、routingkey
6. GitHub配置更改后自动同步更新
通过github的webhook机制(就是第三方的回调接口,当配置参数修改后github会主动调用你预留的接口)
通过github触发更新需要以下几步
1、在config-server上设置encrypt.key
2、将上一步的key添加到Github仓库中
3、配置webhook url
我们需要填写以下两个内容
Payload URL:http://45.89.90.12:60011/actuator/bus-refresh
这个地址的IP一定是公网上可以访问的IP或域名
Secret:encrypt.key里设置的值,我们项目中是19491001
很多webhook都要求回调的地址是https的,确保安全
自动推送需要注意的点:
- 无法进行灰度测试:改动一提交就全部更新,如果不小心修改了配置改错了,所有服务器都团灭了
- 定点推送:webhook的url是写死的,所以无法做到实时变更
如果借助github的webhook的实现自动更新并能实时修改目标节点
- 我们做一个更新的中间层,把这个中间层的url给到webhook
- 这个url调用中间层需要更新的节点或全局更新,这个节点和全局更新可以配置
- 先确认好中间层的更新范围,再去修改配置
SpringCloud服务网关(六)
1. 服务网关在微服务中的应用
1.1. 对外服务的难题
微服务的应用系统体系很庞大,光是需要独立部署的基础组件就有注册中心、配置中、服务总线、Turbine和监控大盘dashboard、调用链追踪和链路聚合,还有kafka和MQ之类的中间件,再加上拆分后的零散微服务,一个系统轻松就有20多个左右部署包
都微服务了,所有的业务对外都是实现单一原则,这就导致服务节点和服务数增多,一个整体的链路需要整合很多服务进行组合使用
还有一个问题就是安全性,如果让所有服务都引入安全验证,把所有的接口都加上安全验证,要更换成OAuth2.0,这个时候让所有的服务提供者都变更?
1.2. 微服务的传达室
我们就给微服务引入一层专事专办的中间层-传达室
1、访问控制:看你是否有权进入,拒绝无权来访者
2、引导指路:问你做什么,给你指路,就是路由
网关层作为唯一的对外服务,外部请求不直接访问服务层,由网关层承接所有HTTP请求,我们会将gateway和nginx一同使用
1.2.1. 访问控制
- 拦截请求:识别header中的授权令牌信息,如果没有登录信息就返回403
- 鉴权:对令牌进行验证,如果令牌失败或过期就拒绝服务
1.2.2. 路由规则
- URL映射:大多数情况下我们给到服务调用方的地址是一个虚拟路由地址,对应的真实地址是由路由规则进行映射
- 服务寻址:URL映射好了之后,如果服务端有多个节点,对于服务集群应该服务访问,需要实现负载均衡策略了(SpringCloud中gateway借助Eureka的服务发现通过Ribbon实现负载均衡)
2. 第二代网关Gateway
Gateway的标签
- Gateway是Spring官方主推的组件
- 底层是基于Netty构建,一个字概括就是快
- 由spring开源社区直接贡献开源力量的
Gateway可以做什么
- 路由寻址
- 负载均衡
- 限流
- 鉴权
Gateway VS zuul(第一代网关是Netflix出品)
Gateway | zuul 1.x | zuul 2.x | |
---|---|---|---|
靠谱性 | 官方背书指出 | 开创者,曾经靠谱 | 一直跳票,千呼万唤始出来 |
性能 | Netty | 同步阻塞,性能慢 | Netty |
QPS | 超30000 | 20000左右 | 20000-30000 |
SpringCloud | 已整合 | 已整合 | 暂无整合到组件库计划,但可以引用 |
长连接keepalive | 支持 | 不支持 | 支持 |
编程体验 | 略复杂 | 同步模型,比较简单 | 略复杂 |
调试&链路追踪 | 异步模型,略复杂 | 同步方式,比较容易 | 异步模型,略复杂 |
新的项目果断选择Gateway
3. Gateway快速落地实施
- 创建gateway项目,引入依赖
- 连接Eureka基于服务发现自动创建路由规则
- 通过Actuator实现动态路由
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-learn</artifactId>
<groupId>com.icodingedu.springcloud</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>gateway-server</artifactId>
<name>gateway-server</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--redis limiter flow-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
</project>
application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class GatewayServerApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServerApplication.class,args);
}
}
application.yaml配置
spring:
application:
name: gateway-server
cloud:
gateway:
discovery:
locator:
enabled: true
server:
port: 65000
eureka:
client:
serviceUrl:
defaultZone: http://localhost:20001/eureka/
management:
security:
enabled: false
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
启动服务:eureka-server、feign-client-advanced(启动三个)、gateway-server
启动后访问:http://localhost:65000/actuator/gateway/routes
可以得到动态加载的eureka路由规则
通过自动路由规则负载均衡实现:http://localhost:65000/FEIGN-CLIENT/sayhello
访问服务的路径希望是小写的
spring:
application:
name: gateway-server
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true # 增加这个
gateway动态路由规则配置
# POST
# http://localhost:65000/actuator/gateway/routes/myrouter
{
"predicates": [
{
"name": "Path",
"args": {
"_genkey_0": "/myrouter-path/**"
}
}
],
"filters": [
{
"name": "StripPrefix",
"args": {
"_genkey_0": "1"
}
}
],
"uri": "lb://FEIGN-CLIENT",
"order": 0
}
# DELETE删除路由规则
# http://localhost:65000/actuator/gateway/routes/myrouter
先删除路由表,再删除服务
4. 路由功能详解
4.1. 路由的组成结构
Gateway中可以有多个Route,一个Route就是一套包含完整转发规则的路由,主要由三部分组成
- 断言集合 断言是路由处理的第一个环节,它是路由的匹配规则,它决定了一个网络请求是否可以匹配给当前路由来处理。之所以它是一个集合的原因是我们可以给一个路由添加多个断言,当每个断言都匹配成功以后才算过了路由的第一关。有关断言的详细内容将在下一小节进行介绍
- 过滤器集合 如果请求通过了前面的断言匹配,那就表示它被当前路由正式接手了,接下来这个请求就要经过一系列的过滤器集合。过滤器的功能就是八仙过海各显神通了,可以对当前请求做一系列的操作,比如说权限验证,或者将其他非业务性校验的规则提到网关过滤器这一层。在过滤器这一层依然可以通过修改Response里的Status Code达到中断效果,比如对鉴权失败的访问请求设置Status Code为403之后中断操作。有关过滤器的详细内容将在后面的小节介绍
- URI 如果请求顺利通过过滤器的处理,接下来就到了最后一步,那就是转发请求。URI是统一资源标识符,它可以是一个具体的网址,也可以是IP+端口的组合,或者是Eureka中注册的服务名称
4.2. 负载均衡
对最后一步寻址来说,如果采用基于Eureka的服务发现机制,那么在Gateway的转发过程中可以采用服务注册名的方式来调用,后台会借助Ribbon实现负载均衡(可以为某个服务指定具体的负载均衡策略),其配置方式如:lb://FEIGN-SERVICE-PROVIDER/
,前面的lb就是指代Ribbon作为LoadBalancer。
4.3. 工作流程
- Predicate Handler (断言)具体承接类是RoutePredicateHandlerMapping。首先它获取所有的路由(配置的routes全集),然后依次循环每个Route,把应用请求与Route中配置的所有断言进行匹配,如果当前Route所有断言都验证通过,Predict Handler就选定当前的路由。这个模式是典型的职责链。
- Filter Handler 在前一步选中路由后,由FilteringWebHandler将请求交给过滤器,在具体处理过程中,不仅当前Route中定义的过滤器会生效,我们在项目中添加的全局过滤器(Global Filter)也会一同参与。同学们看到图中有Pre Filter和Post Filter,这是指过滤器的作用阶段,我们在稍后的章节中再深入了解
- 寻址 这一步将把请求转发到URI指定的地址,在发送请求之前,所有Pre类型过滤器都将被执行,而Post过滤器会在调用请求返回之后起作用。有关过滤器的详细内容将会在稍后的章节里讲到。
5. 断言功能详解
Predicate接受一个判断条件,返回true或false的布尔值,告知调用方判断结果,也可以通过and、or、negative(非)三个操作符来将多个Predicate,对所有来的Request进行条件判断
只要网关接收到请求立即触发断言,满足所有的断言后才进入Filter阶段
Gateway给我们提供了十几种内置断言,常用的就下面几种
5.1. Path匹配
.router(r -> r.path("/gateway/**"))
.uri("lb://FEIGN-CLIENT")
)
.router(r -> r.path("/baidu"))
.uri("https://www.baidu.com")
)
5.2. Method断言
.router(r -> r.path("/gateway/**"))
.and().method(HttpMethod.GET)
.uri("lb://FEIGN-CLIENT")
)
5.3. RequestParam断言
.router(r -> r.path("/gateway/**"))
.and().method(HttpMethod.GET)
.and().query("name","icodingedu")
.and().query("age")
.uri("lb://FEIGN-CLIENT")
)
//这里的age仅需要有age这个参数即可,至于值是什么不关心,但name的值必须是icodingedu
5.4. Header断言
.router(r -> r.path("/gateway/**"))
.and().header("Authorization")
.uri("lb://FEIGN-CLIENT")
)
//header中必须包含一个Authorization属性,也可以传入两个参数,锁定值
5.5. Cookie断言
.router(r -> r.path("/gateway/**"))
.and().cookie("name","icodingedu")
.uri("lb://FEIGN-CLIENT")
)
//cookie是几个参数断言中唯一一个必须指定值的断言
5.6. 时间片匹配
时间片匹配有三种模式:Before、After、Between,这个指定了在什么时间范围内容路由才生效
.router(r -> r.path("/gateway/**"))
.and().after("具体时间")
.uri("lb://FEIGN-CLIENT")
)
6. 实现断言的配置
断言配置可以在yaml和java代码里都能够实现
在yaml里配置一个,rotues这部分
spring:
application:
name: gateway-server
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
routes:
- id: feignclient
uri: lb://FEIGN-CLIENT
predicates:
- Path=/gavinyaml/**
filters:
- StripPrefix=1
配置完成后:http://localhost:65000/actuator/gateway/routes
在Java程序里进行配置
创建一个config包,建立一个配置类
package com.icodingedu.springcloud.config;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
@Configuration
public class GatewayConfiguration {
@Bean
@Order
public RouteLocator customerRoutes(RouteLocatorBuilder builder){
return builder.routes()
.route(r -> r.path("/gavinjava/**")
.and().method(HttpMethod.GET)
.and().header("name")
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
)
.uri("lb://FEIGN-CLIENT")
).build();
}
}
7. 通过After断言实现定时秒杀
geteway调用的是feign-client的业务,我们就到feign-client-advanced里创建一个controller实现
这里面要使用到的product需要提前在feign-client-intf中定义好
package com.icodingedu.springcloud.pojo;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class Product {
private Long productId;
private String description;
private Long stock;
}
feign-client-advanced中创建GatewayController
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.pojo.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@Slf4j
@RequestMapping("gateway")
public class GatewayController {
//我们就构建一个简易的数据存储,Product需要在feign-client-intf中定义
public static final Map<Long, Product> items = new ConcurrentHashMap<>();
@GetMapping("detail")
public Product getProduct(Long pid){
//如果第一次没有先创建一个
if(!items.containsKey(pid)){
Product product = Product.builder().productId(pid)
.description("very well!")
.stock(100L).build();
//没有才插入数据
items.putIfAbsent(pid,product);
}
return items.get(pid);
}
@GetMapping("placeOrder")
public String buy(Long pid){
Product product = items.get(pid);
if(product==null){
return "Product Not Found";
}else if(product.getStock()<=0L){
return "Sold Out";
}
synchronized (product){
if(product.getStock()<=0L){
return "Sold Out";
}
product.setStock(product.getStock()-1);
}
return "Order Placed";
}
}
回到Gateway-sever项目,按照时间顺延方式定义
package com.icodingedu.springcloud.config;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import java.time.ZonedDateTime;
@Configuration
public class GatewayConfiguration {
@Bean
@Order
public RouteLocator cutomerRoutes(RouteLocatorBuilder builder){
return builder.routes()
.route(r -> r.path("/gavinjava/**")
.and().method(HttpMethod.GET)
.and().header("name")
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
.and().after(ZonedDateTime.now().plusSeconds(20))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT")
)
.build();
}
}
也可以定义精确的时间节点值
@Bean
@Order
public RouteLocator cutomerRoutes(RouteLocatorBuilder builder){
LocalDateTime ldt = LocalDateTime.of(2020, 4, 11, 16, 11, 10);
return builder.routes()
.route(r -> r.path("/gavinjava/**")
.and().method(HttpMethod.GET)
.and().header("name")
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
.and().after(ZonedDateTime.of(ldt,ZoneId.of("Asia/Shanghai")))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT")
)
.build();
}
8. 过滤器原理和生命周期
过滤器的实现方式
只需要实现两个接口:GatewayFilter、Ordered
过滤器类型
Header过滤器:可以增加和减少header里的值
StripPrefix过滤器:
.router(r -> r.path("/gateway/**"))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT")
)
假如请求的路径:/gateway/sample/update,如果没有stripPrefix过滤器,http://FEIGN-CLIENT/gateway/sample/update,他的作用就是将第一个路由路径截取掉
PrefixPath过滤器:它和StripPrefix作用相反
.router(r -> r.path("/gateway/**"))
.filters(f -> f.prefixPath("go"))
.uri("lb://FEIGN-CLIENT")
)
/gateway/sample/update 变成 /go/gateway/sample/update
RedirectTo过滤器:
.filters(f -> f.redirect(303,"https://www.icodingedu.com"))
// 遇到错误是30x的直接过滤跳转
9. 自定义过滤器实现接口计时功能
去到gateway-server项目中进行修改,创建一个filter的package
package com.icodingedu.springcloud.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
//Ordered是指定执行顺序的接口
@Slf4j
@Component
public class TimerFilter implements GatewayFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//给接口计时并能打出很漂亮的log
StopWatch timer = new StopWatch();
timer.start(exchange.getRequest().getURI().getRawPath());//开始计时
//我们还可以对调用链进行加工,手工放入请求参数
exchange.getAttributes().put("requestTimeBegin",System.currentTimeMillis());
return chain.filter(exchange).then(
//这里就是执行完过滤进行调用的地方
Mono.fromRunnable(() -> {
timer.stop();
log.info(timer.prettyPrint());
})
);
}
@Override
public int getOrder() {
return 0;
}
}
拿上TimerFilter去到GatewayConfiguration里设置自定义filter
package com.icodingedu.springcloud.config;
import com.icodingedu.springcloud.filter.TimerFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@Configuration
public class GatewayConfiguration {
// @Autowired
// private TimerFilter timerFilter;
@Bean
@Order
public RouteLocator customerRoutes(RouteLocatorBuilder builder){
LocalDateTime ldt1 = LocalDateTime.of(2020,4,12,22,6,30);
LocalDateTime ldt2 = LocalDateTime.of(2020,4,12,23,6,35);
return builder.routes()
.route(r -> r.path("/gavinjava/**")
.and().method(HttpMethod.GET)
.and().header("name")
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
// .filter(timerFilter)
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
//.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
.and().between(ZonedDateTime.of(ldt1, ZoneId.of("Asia/Shanghai")),ZonedDateTime.of(ldt2, ZoneId.of("Asia/Shanghai")))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT")
)
.build();
}
}
全局Filter就是把filter的继承从GatewayFilter换成GlobalFilter
10. 权限认证方案分析
10.1. 传统单应用的用户鉴权
从我们开始学JavaEE的时候,就被洗脑式灌输了一种权限验证的标准做法,那就是将用户的登录状态保存到HttpSession中,比如在登录成功后保存一对key-value值到session,key是userId而value是用户后台的真实ID。接着创建一个ServletFilter过滤器,用来拦截需要登录才能访问的资源,假如这个请求对应的服务端session里找不到userId这个key,那么就代表用户尚未登录,这时候可以直接拒绝服务然后重定向到用户登录页面。
大家应该都对session机制比较熟悉,它和cookie是相互依赖的,cookie是存放在用户浏览器中的信息,而session则是存放在服务器端的。当浏览器发起服务请求的时候就会带上cookie,服务器端接到Request后根据cookie中的jsessionid拿到对应的session。
由于我们只启动一台服务器,所以在登录后保存的session始终都在这台服务器中,可以很方便的获取到session中的所有信息。用这野路子,我们一路搞定了各种课程作业和毕业设计。结果一到工作岗位发现行不通了,因为所有应用都是集群部署,在一台机器保存了的session无法同步到其他机器上。那我们有什么成熟的解决方案吗?
10.2. 分布式环境下的解决方案
10.2.1. 同步Session
-
Session复制是最容易先想到的解决方案,我们可以把一台机器中的session复制到集群中的其他机器。比如Tomcat中也有内置的session同步方案,但是这并不是一个很优雅的解决方案,它会带来以下两个问题:
- Timing问题 同步需要花费一定的时间,我们无法保证session同步的及时性,也就是说,当用户发起的两个请求分别落在不同机器上的时候,前一个请求写入session的信息可能还没同步到所有机器,后一个请求就已经开始执行业务逻辑了,这不免引起脏读幻读。
- 数据冗余 所有服务器都需要保存一份session全集,这就产生了大量的冗余数据
10.2.2. 反向代理:绑定IP或一致性Hash
这个方案可以放在Nignx网关层做的,我们可以指定某些IP段的请求落在某个指定机器上,这样一来session始终只存在一台机器上。不过相比前一种session复制的方法来说,绑定IP的方式有更明显的缺陷:
- 负载均衡 在绑定IP的情况下无法在网关层应用负载均衡策略,而且某个服务器出现故障的话会对指定IP段的来访用户产生较大影响。对网关层来说该方案的路由规则配置也极其麻烦。
- IP变更 很多网络运营商会时不时切换用户IP,这就会导致更换IP后的请求被路由到不同的服务节点处理,这样一来就读不到前面设置的session信息了
为了解决第二个问题,可以通过一致性Hash的路由方案来做路由,比如根据用户ID做Hash,不同的Hash值落在不同的机器上,保证足够均匀的分配,这样也就避免了IP切换的问题,但依然无法解决第一点里提到的负载均衡问题
10.2.3. Redis解决方案
这个方案解决了前面提到的大部分问题,session不再保存在服务器上,取而代之的是保存在redis中,所有的服务器都向redis写入/读取缓存信息。
在Tomcat层面,我们可以直接引入tomcat-redis-session-manager组件,将容器层面的session组件替换为基于redis的组件,但是这种方案和容器绑定的比较紧密。另一个更优雅的方案是借助spring-session管理redis中的session,尽管这个方案脱离了具体容器,但依然是基于Session的用户鉴权方案,这类Session方案已经在微服务应用中被淘汰了。
10.3. 分布式Session的解决方案
10.3.1. OAuth 2.0
OAuth 2.0是一个开放授权标准协议,它允许用户让第三方应用访问该用户在某服务的特定私有资源,但是不提供账号密码信息给第三方应用
拿微信登录第三方应用的例子来说:
- Auth Grant 在这一步Client发起Authorization Request到微信系统(比如通过微信内扫码授权),当身份验证成功后获取Auth Grant
- Get Token 客户端拿着从微信获取到的Auth Grant,发给第三方引用的鉴权服务,换取一个Token,这个Token就是访问第三方应用资源所需要的令牌
- 访问资源 最后一步,客户端在请求资源的时候带上Token令牌,服务端验证令牌真实有效后即返回指定资源
我们可以借助Spring Cloud中内置的spring-cloud-starter-oauth2
组件搭建OAuth 2.0的鉴权服务,OAuth 2.0的协议还涉及到很多复杂的规范,比如角色、客户端类型、授权模式等。
10.3.2. JWT鉴权
JWT也是一种基于Token的鉴权机制,它的基本思想就是通过用户名+密码换取一个Access Token
鉴权流程
相比OAuth 2.0来说,它的鉴权过程更加简单,其基本流程是这样的:
- 用户名+密码访问鉴权服务
- 验证通过:服务器返回一个Access Token给客户端,并将token保存在服务端某个地方用于后面的访问控制(可以保存在数据库或者Redis中)
- 验证失败:不生成Token
- 客户端使用令牌访问资源,服务器验证令牌有效性
- 令牌错误或过期:拦截请求,让客户端重新申请令牌
- 令牌正确:允许放行
Access Token中的内容
JWT的Access Token由三个部分构成,分别是Header、Payload和Signature,我们分别看下这三个部分都包含了哪些信息:
- Header 头部声明了Token的类型(JWT类型)和采用的加密算法(HS256)
{
'typ': 'JWT',
'alg': 'HS256'
}
- Payload 这一段包含的信息相当丰富,你可以定义Token签发者、签发和过期时间、生效时间等一系列属性,还可以添加自定义属性。服务端收到Token的时候也同样可以对Payload中包含的信息做验证,比如说某个Token的签发者是“Feign-API”,假如某个接口只能允许“Gateway-API”签发的Token,那么在做鉴权服务时就可以加入Issuer的判断逻辑。
- Signature 它会使用Header和Payload以及一个密钥用来生成签证信息,这一步会使用Header里我们指定的加密算法进行加密
目前实现JWT的开源组件非常多,如果决定使用这个方案,只要添加任意一个开源JWT实现的依赖项到项目的pom文件中,然后在加解密时调用该组件来完成
目前来说应用比较广泛的三种方案就是JWT、OAuth和spring-session+redis
SpringCloud服务网关&调用链追踪(七)
1. 实现服务网关层JWT鉴权
通过以下几步完成鉴权操作
- 创建auth-service(登录,鉴权等服务)
- 添加JwtService类实现token创建和验证
- 网关层集成auth-service(添加AuthFilter到网关层,如果没有登录则返回403)
在gateway里创建一个auth-service-api
添加POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>auth-service-api</artifactId>
<name>auth-service-api</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
创建一个entity包,创建一个账户实体对象
package com.icodingedu.springcloud.entity;
import com.sun.tracing.dtrace.ArgsAttributes;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {
private String username;
private String token;
//当token接近失效的时候可以用refreshToken生成一个新的token
private String refreshToken;
}
在entity包下,创建一个AuthResponse类
package com.icodingedu.springcloud.entity;
public class AuthResponse {
public static final Long SUCCESS = 1L;
public static final Long INCORRECT_PWD = 1000L;
public static final Long USER_NOT_FOUND = 1001L;
public static final Long INVALID_TOKEN = 1002L;
}
在entity包下创建一个AuthResponse处理结果类
package com.icodingedu.springcloud.tools;
import com.icodingedu.springcloud.pojo.Account;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private Account account;
private Long code;
}
创建一个service包在里面创建接口AuthService
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.entity.AuthResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@FeignClient("auth-service")
public interface AuthService {
@PostMapping("/login")
@ResponseBody
public AuthResponse login(@RequestParam("username") String username,
@RequestParam("password") String password);
@GetMapping("/verify")
@ResponseBody
public AuthResponse verify(@RequestParam("token") String token,
@RequestParam("username") String username);
@PostMapping("/refresh")
@ResponseBody
public AuthResponse refresh(@RequestParam("refresh") String refreshToken);
}
创建服务实现的auth-service的module,还是放在gateway目录下
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>auth-service</artifactId>
<name>auth-service</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redis用来存放生成的token和refresh token的,本地token要启动-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--jwt-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>auth-service-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
创建启动类application
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnableDiscoveryClient
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(AuthServiceApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建一个service包,建立JwtService类
package com.icodingedu.springcloud.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.JWTVerifier;
import com.icodingedu.springcloud.entity.Account;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Date;
@Slf4j
@Service
public class JwtService {
//生产环境中应该从外部加密后传入
private static final String KEY = "you must change it";
//生产环境中应该从外部加密后传入
private static final String ISSUER = "icodingedu";
//定义个过期时间
private static final long TOKEN_EXP_TIME = 60000;
//定义传入的参数名
private static final String USERNAME = "username";
/**
* 生成token
* @param account 账户信息
* @return token
*/
public String token(Account account){
//生成token的时间
Date now = new Date();
//生成token所要用到的算法
Algorithm algorithm = Algorithm.HMAC256(KEY);
String token = JWT.create()
.withIssuer(ISSUER) //发行方,解密的时候依然要验证,即便拿到了key不知道发行方也无法解密
.withIssuedAt(now) //这个key是在什么时间点生成的
.withExpiresAt(new Date(now.getTime() + TOKEN_EXP_TIME)) //过期时间
.withClaim(USERNAME,account.getUsername()) //传入username
//.withClaim(ROLE,"roleName") 还可以传入其他内容
.sign(algorithm); //用前面的算法签发
log.info("jwt generated user={}",account.getUsername());
return token;
}
/**
* 验证token
* @param token
* @param username
* @return
*/
public boolean verify(String token,String username){
log.info("verify jwt - user={}",username);
try{
//加密和解密要一样
Algorithm algorithm = Algorithm.HMAC256(KEY);
//构建一个验证器:验证JWT的内容,是个接口
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(ISSUER) //前面加密的内容都可以验证
.withClaim(USERNAME,username)
.build();
//这里有任何错误就直接异常了
verifier.verify(token);
return true;
}catch (Exception ex){
log.error("auth failed",ex);
return false;
}
}
}
创建controller包,建立JwtController类
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.entity.Account;
import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.AuthService;
import com.icodingedu.springcloud.service.JwtService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@Slf4j
public class JwtController implements AuthService{
@Autowired
private JwtService jwtService;
@Autowired
private RedisTemplate redisTemplate;
@Override
public AuthResponse login(String username, String password) {
Account account = Account.builder()
.username(username)
.build();
//TODO 0-这一步需要验证用户名和密码,一般是在数据库里,假定验证通过了
//1-生成token
String token = jwtService.token(account);
account.setToken(token);
//这里保存拿到新token的key
account.setRefreshToken(UUID.randomUUID().toString());
//3-保存token,把token保存起来在refresh时才知道更新关联哪个token
redisTemplate.opsForValue().set(account.getRefreshToken(),account);
//2-返回token
return AuthResponse.builder()
.account(account)
.code(AuthResponseCode.SUCCESS)
.build();
}
@Override
public AuthResponse verify(String token, String username) {
boolean flag = jwtService.verify(token, username);
return AuthResponse.builder()
.code(flag?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
.build();
}
@Override
public AuthResponse refresh(String refreshToken) {
//当使用redisTemplate保存对象时,对象必须是一个可被序列化的对象
Account account = (Account) redisTemplate.opsForValue().get(refreshToken);
if(account == null){
return AuthResponse.builder()
.code(AuthResponseCode.USER_NOT_FOUND)
.build();
}
String token = jwtService.token(account);
account.setToken(token);
//更新新的refreshToke
account.setRefreshToken(UUID.randomUUID().toString());
//将原来的删除
redisTemplate.delete(refreshToken);
//添加新的token
redisTemplate.opsForValue().set(account.getRefreshToken(),account);
return AuthResponse.builder()
.account(account)
.code(AuthResponseCode.SUCCESS)
.build();
}
}
设置application配置文件
spring.application.name=auth-service
server.port=65100
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
spring.redis.host=localhost
spring.redis.database=0
spring.redis.port=6379
info.app.name=auth-service
info.app.description=test
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
可以启动验证一下:先启动eureka-server,再启动auth-server
在PostMan里进行验证:login,verify,refresh都测试一下
开始改造gateway-sever
POM里引入依赖,增加下面三个依赖
<!--因为spring cloud gateway是基于webflux的,如果需要web则是导入spring-boot-starter-webflux而不是spring-boot-starter-web-->
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>auth-service-api</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--工具类,进行一下StringUtil的操作-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
创建一个新的类:AuthFilter
package com.icodingedu.springcloud.filter;
import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class AuthFilter implements GatewayFilter, Ordered {
private static final String AUTH = "Authorization";
private static final String USERNAME = "icodingedu-username";
@Autowired
private RestTemplate restTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("Auth Start");
ServerHttpRequest request = exchange.getRequest();
HttpHeaders header = request.getHeaders();
String token = header.getFirst(AUTH);
String username = header.getFirst(USERNAME);
ServerHttpResponse response = exchange.getResponse();
if(StringUtils.isBlank(token)){
log.error("token not found");
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
String path = String.format("http://auth-service/verify?token=%s&username=%s",token,username);
AuthResponse resp = restTemplate.getForObject(path,AuthResponse.class);
if(resp.getCode() != 1L){
log.error("invalid token");
response.setStatusCode(HttpStatus.FORBIDDEN);
return response.setComplete();
}
//将用户信息再次存放在请求的header中传递给下游
ServerHttpRequest.Builder mutate = request.mutate();
mutate.header(USERNAME,username);
ServerHttpRequest buildRequest = mutate.build();
//如果响应中需要放数据,可以放在response的header中
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add("icoding-user",username);
return chain.filter(exchange.mutate()
.request(buildRequest)
.response(response)
.build());
}
@Override
public int getOrder() {
return 0;
}
}
给gateway-server的application启动类加上RestTemplate实现
package com.icodingedu.springcloud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication
@EnableDiscoveryClient
public class GatewayServerApplication {
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
public static void main(String[] args) {
SpringApplication.run(GatewayServerApplication.class,args);
}
}
AuthFilter注入到configuration中的,只需要注入并加入过滤器即可
2. 实现服务网关层统一异常返回
2.1. 异常的种类
网关层的异常分为以下两种:
-
调用请求异常 通常由调用请求直接抛出的异常,比如在订单服务中直接报错
throw new RuntimeException("error")
-
网关层异常 由网关层触发的异常,比如Gateway通过服务发现找不到可用节点,或者任何网关层内部的问题。这部分异常通常是在实际调用请求发起之前发生的。
在以上两种问题中,网关层只应该关注第二个点,也就是自身异常。在实际应用中我们应该尽量保持网关层的“纯洁性”并且做好职责划分,Gateway只要做好路由的事情,不要牵扯到具体业务层的事儿,最好也不要替调用请求的异常操心。对于业务调用中的异常情况,如果需要采用统一格式封装调用异常,那就交给每个具体服务去定义结构,让各自业务方和前端页面协调好异常消息的结构。
但是在实际项目中,不能保证每个接口都实现了异常封装,如果想给前台页面一个统一风格的JSON格式异常结构,那就需要让Gateway做一些分外的事儿,比如拦截Response并修改返回值。(还是强烈建议让服务端自己定义异常结构,因为Gateway本身不应该对这些异常做额外封装只是原封不动的返回)
Gateway已经将网关层直接抛出的异常(没有调用远程服务之前的异常)做了结构化封装,对于POST的调用来说其本身也会返回结构化的异常信息,但是对于GET接口的异常来说,则是直接返回一个HTML页面,前端根本无法抓取具体的异常信息。所以我们这里主要聚焦在如何处理调用请求异常。
2.2. 自定义异常封装
装饰器编程模式+代理模式,给Gateway加一层处理,改变ResponseBody中的数据结构
代理模式 - BodyHackerFunction接口
在最开始我们先定义一个代理模式的接口
package com.icodingedu.springcloud.tools;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import reactor.core.publisher.Mono;
import java.util.function.BiFunction;
public interface BodyHackerFunction extends
BiFunction<ServerHttpResponse, Publisher<? extends DataBuffer>, Mono<Void>> {
}
这里引入代理模式是为了将装饰器和具体业务代理逻辑拆分开来,在装饰器中只需要依赖一个代理接口,而不需要和具体的代理逻辑绑定起来
装饰器模式 - BodyHackerDecrator
接下来我们定义一个装饰器类,这个装饰器继承自ServerHttpResponseDecorator类,我们这里就用装饰器模式给Response Body的构造过程加上一层特效
package com.icodingedu.springcloud.tools;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Mono;
public class BodyHackerHttpResponseDecorator extends ServerHttpResponseDecorator {
/**
* 负责具体写入Body内容的代理类
*/
private BodyHackerFunction delegate = null;
public BodyHackerHttpResponseDecorator(BodyHackerFunction bodyHandler, ServerHttpResponse delegate) {
super(delegate);
this.delegate = bodyHandler;
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
return delegate.apply(getDelegate(), body);
}
}
这个装饰器的构造方法接收一个BodyHancker代理类,其中的关键方法writeWith就是用来向Response Body中写入内容的。这里我们覆盖了该方法,使用代理类来托管方法的执行,而在整个装饰器类中看不到一点业务逻辑,这就是我们常说的单一职责。
创建Filter
package com.icodingedu.springcloud.filter;
import com.icodingedu.springcloud.tools.BodyHackerFunction;
import com.icodingedu.springcloud.tools.BodyHackerHttpResponseDecorator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class ErrorFilter implements GatewayFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
final ServerHttpRequest request = exchange.getRequest();
// TODO 这里定义写入Body的逻辑
BodyHackerFunction delegate = (resp, body) -> Flux.from(body)
.flatMap(orgBody -> {
// 原始的response body
byte[] orgContent = new byte[orgBody.readableByteCount()];
orgBody.read(orgContent);
String content = new String(orgContent);
log.info("original content {}", content);
// 如果500错误,则替换
if (resp.getStatusCode().value() == 500) {
content = String.format("{\"status\":%d,\"path\":\"%s\"}",
resp.getStatusCode().value(),
request.getPath().value());
}
// 告知客户端Body的长度,如果不设置的话客户端会一直处于等待状态不结束
HttpHeaders headers = resp.getHeaders();
headers.setContentLength(content.length());
return resp.writeWith(Flux.just(content)
.map(bx -> resp.bufferFactory().wrap(bx.getBytes())));
}).then();
// 将装饰器当做Response返回
BodyHackerHttpResponseDecorator responseDecorator = new BodyHackerHttpResponseDecorator(delegate, exchange.getResponse());
return chain.filter(exchange.mutate().response(responseDecorator).build());
}
@Override
public int getOrder() {
// WRITE_RESPONSE_FILTER的执行顺序是-1,我们的Hacker在它之前执行
return -2;
}
}
在这个Filter中,我们定义了一个装饰器类BodyHackerHttpResponseDecorator,同时声明了一个匿名内部类(代码TODO部分),实现了BodyHackerFunction代理类的Body替换逻辑,并且将这个代理类传入了装饰器。这个装饰器将直接参与构造Response Body。
我们还覆盖了getOrder方法,是为了确保我们的filter在默认的Response构造器之前执行
我们对500的HTTP Status做了特殊定制,使用我们自己的JSON内容替换了原始内容,同学们可以根据需要向JSON中加入其它参数。对于其他非500 Status的Response来说,我们还是返回初始的Body。
我们在feign-client-advanced的GatewayController中定一个500的错误方法进行测试
@GetMapping("/valid")
public String valid(){
int i = 1/0;
return "Page Test Success";
}
ErrorFilter的注入方式同之前的过滤器一样
3. 实现服务网关限流
创建一个限流配置类RedisLimiterConfiguration
package com.icodingedu.springcloud.config;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;
@Configuration
public class RedisLimiterConfiguration {
// ID: KEY 限流的业务标识
// 我们这里根据用户请求IP地址进行限流
@Bean
@Primary //一个系统不止一个KeyResolver
public KeyResolver remoteAddressKeyResolver(){
return exchange -> Mono.just(
exchange.getRequest()
.getRemoteAddress()
.getAddress()
.getHostAddress()
);
}
@Bean("redisLimiterUser")
@Primary
public RedisRateLimiter redisRateLimiterUser(){
//这里可以自己创建一个限流脚本,也可以使用默认的令牌桶
//defaultReplenishRate:限流桶速率,每秒10个
//defaultBurstCapacity:桶的容量
return new RedisRateLimiter(10,60);
}
@Bean("redisLimiterProduct")
public RedisRateLimiter redisRateLimiterProduct(){
//这里可以自己创建一个限流脚本,也可以使用默认的令牌桶
//defaultReplenishRate:限流桶速率,每秒10个
//defaultBurstCapacity:桶的容量
return new RedisRateLimiter(20,100);
}
}
配置application.yaml 中的redis信息
spring:
application:
name: gateway-server
redis:
host: localhost
port: 6379
database: 0
main:
allow-bean-definition-overriding: true
在GatewayConfiguration中进行配置加入RedisLimiter的配置
package com.icodingedu.springcloud.config;
import com.icodingedu.springcloud.filter.AuthFilter;
import com.icodingedu.springcloud.filter.ErrorFilter;
import com.icodingedu.springcloud.filter.TimerFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@Configuration
public class GatewayConfiguration {
// @Autowired
// private TimerFilter timerFilter;
@Autowired
private AuthFilter authFilter;
@Autowired
private ErrorFilter errorFilter;
@Autowired
private KeyResolver hostNameResolver;
@Autowired
@Qualifier("redisLimiterUser")
private RateLimiter rateLimiterUser;
@Bean
@Order
public RouteLocator customerRoutes(RouteLocatorBuilder builder){
LocalDateTime ldt1 = LocalDateTime.of(2020,4,12,22,6,30);
LocalDateTime ldt2 = LocalDateTime.of(2020,4,12,23,6,35);
return builder.routes()
.route(r -> r.path("/gavinjava/**")
.and().method(HttpMethod.GET)
// .and().header("name")
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
// .filter(timerFilter)
// .filter(authFilter)
.filter(errorFilter)
.requestRateLimiter(
c -> {
c.setKeyResolver(hostNameResolver);
c.setRateLimiter(rateLimiterUser);
})
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
//.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
.and().between(ZonedDateTime.of(ldt1, ZoneId.of("Asia/Shanghai")),ZonedDateTime.of(ldt2, ZoneId.of("Asia/Shanghai")))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT")
)
.build();
}
}
4. 链路追踪在微服务中的作用
微服务之间的调用关系是网状的
根本无法用人力梳理出上线游调用关系,通过调用链追踪技术进行调用关系梳理,得处调用拓扑图,理清上下游关系以及更细粒度的时间报表
- 谁是你的大客户,调用你的次数最多
- 你的服务在每个环节的响应时间是多少
5. 链路追踪的基本功能
- 分布式环境下的链路追踪
- 获知Timing信息:还可以根据执行时间的多少触发相应的预警信息
- 定位链路功能:对于整个链路有一个traceId,通过这个id可以获取整个链路的信息
- 信息的收集和展示
6. Sleuth的链路追踪介绍
HTTP–>服务A–>服务D–>服务C–>服务F
用户请求访问了服务A,接着服务A又在内部先后调用了服务D,C和F,在这里Sleuth的工作就是通过一种“打标”的机制,将这个链路上的所有被访问到的服务打上一个相同的标记,这样我们只要拿到这个标记,就很容易可以追溯到链路上下游所有的调用
借助Sleuth的链路追踪能力,我们还可以完成一些其他的任务,比如说:
- 线上故障定位:结合Tracking ID寻找上下游链路中所有的日志信息(这一步还需要借助一些其他开源组件)
- 依赖分析梳理:梳理上下游依赖关系,理清整个系统中所有微服务之间的依赖关系
- 链路优化:比如说目前我们有三种途径可以导流到下单接口,通过对链路调用情况的统计分析,我们可以识别出转化率最高的业务场景,从而为以后的产品设计提供指导意见。
- 性能分析:梳理各个环节的时间消耗,找到性能瓶颈,为性能优化、软硬件资源调配指明方向
SpringCloud服务调用链追踪(八)
1. Sleuth的体系架构设计原则
Sleuth的设计理念
- 无业务侵入 如果说接入某个监控组件还需要改动业务代码,那么我们认为这是一个“高侵入性”的组件。Sleuth在设计上秉承“低侵入”的理念,不需要对业务代码做任何改动,即可静默接入链路追踪功能
- 高性能 一般认为在代码里加入完善的log(10行代码对应2条log)会降低5%左右接口性能(针对非异步log框架),而通过链路追踪技术在log里做埋点多多少少也会影响性能。Sleuth在埋点过程中力求对性能影响降低到最小,同时还提供了“采样率配置”来进一步降低开销(比如说开发人员可以设置只对20%的请求进行采样)
哪些数据需要埋点
每一个微服务都有自己的Log组件(slf4j,logback等各不相同),当我们集成了Sleuth之后,它便会将链路信息传递给底层Log组件,同时Log组件会在每行Log的头部输出这些数据,这个埋点动作主要会记录两个关键信息:
- 链路ID 当前调用链的唯一ID,在这次调用请求开始到结束的过程中,所有经过的节点都拥有一个相同的链路ID
- 单元ID 在一次链路调用中会访问不同服务器节点上的服务,每一次服务调用都相当于一个独立单元,也就是说会有一个独立的单元ID。同时每一个独立单元都要知道调用请求来自哪里(就是对当前服务发起直接调用的那一方的单元ID,我们记为Parent ID)
比如这里服务A是起始节点,所以它的Event ID(单元ID)和Trace ID(链路ID)相同,而服务B的前置节点就是A节点,所以B的Parent Event就指向A的Event ID。而C在B的下游,所以C的Parent就指向B。A、B和C三个服务都有同一个链路ID,但是各自有不同的单元ID。
数据埋点之前要解决的问题
看起来创建埋点数据是件很容易的事儿,但是想让这套方案在微服务集群环境下生效,我们还需要先解决两个核心问题:
- Log系统集成 如何让埋点信息加入到业务Log中?
- 埋点信息的传递 我们知道SpringCloud中的调用都是通过HTTP请求来传递的,那么上游调用方是如何将链路ID等信息传入到下游的呢?
MDC
MDC(Mapped Diagnostic Context,映射调试上下文)是 log4j 和 logback 提供的一种方便在多线程条件下记录日志的功能
MDC是通过InheritableThreadLocal来实现的,它可以携带当前线程的上下文信息。它的底层是一个Map结构,存储了一系列Key-Value的值。Sleuth就是借助Spring的AOP机制,在方法调用的时候配置了切面,将链路追踪数据加入到了MDC中,这样在打印Log的时候,就能从MDC中获取这些值,填入到Log Format中的占位符里。
2. 调用链路数据模型
Sleuth从一个调用请求开始直到结束,不管中途又调用了多少外部服务,从头到尾一直贯穿一个ID。
- Trace 它就是从头到尾贯穿整个调用链的ID,我们叫它Trace ID,不管调用链路中途访问了多少服务节点,在每个节点的log中都会打印同一个Trace ID
- Span 它标识了Sleuth下面一个基本的工作单元,每个单元都有一个独一无二的ID。比如服务A发起对服务B的调用,这个事件就可以看做一个独立单元,生成一个独立的ID。
Span不单单只是一个ID,它还包含一些其他信息,比如时间戳,它标识了一个事件从开始到结束经过的时间,我们可以用这个信息来统计接口的执行时间。
我们知道了Trace ID和Span ID,问题就是如何在不同服务节点之间传递这些ID。在Eureka的服务治理下所有调用请求都是基于HTTP的,那我们的链路追踪ID也一定是HTTP请求中的一部分。把ID加在HTTP哪里呢,一来GET请求压根就没有Body,二来加入Body还有可能影响后台服务的反序列化。那加在URL后面呢?似乎也不妥,因为某些服务组件对URL的长度可能做了限制(比如Nginx可以设置最大URL长度)。
那剩下的只有Header了!Sleuth正是通过Filter向Header中添加追踪信息,我们来看下面表格中Header Name和Trace Data的对应关系:
HTTP Header Name | Trace Data | 说明 |
---|---|---|
X-B3-TraceId | Trace ID | 链路全局唯一ID |
X-B3-SpanId | Span ID | 当前Span的ID |
X-B3-ParentSpanId | Parent Span ID | 前一个Span的ID |
X-Span-Export | Can be exported for sampling or not | 是否可以被采样 |
在调用下一个服务的时候,Sleuth会在当前的Request Header中写入上面的信息,这样下游系统就很容易识别出当前Trace ID以及它的前置Span ID是什么
3. 整合Sleuth追踪调用链路
创建sleuth-trace-a、sleuth-trace-b、sleuth-trace-c模块,先创建一个sleuth-trace-a
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>sleuth-trace-a</artifactId>
<name>sleuth-trace-a</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
</dependencies>
</project>
创建application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@EnableDiscoveryClient
@SpringBootApplication
public class SleuthTraceAApplication {
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(SleuthTraceAApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建controller实现集成sleuth
package com.icodingedu.springcloud.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
@Slf4j
public class SleuthController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/traceA")
public String traceA(){
log.info("--------------TraceA");
return restTemplate.getForEntity("http://sleuth-traceB/traceB",String.class).getBody();
}
}
创建配置文件,可以从auth-service中复制一部分
spring.application.name=sleuth-traceA
server.port=65501
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
logging.file=${spring.application.name}.log
# 采样率,1就表示100%,0.8表示80%
spring.sleuth.sampler.probability=1
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
添加logback-spring.xml配置文件
<?xml version="1.0" encoding="UTF-8"?>
<!--该日志将日志级别不同的log信息保存到不同的文件中 -->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<springProperty scope="context" name="springAppName"
source="spring.application.name" />
<!-- 日志输出位置 -->
<property name="LOG_FILE" value="${BUILD_FOLDER:-build}/${springAppName}" />
<!-- 日志格式 -->
<property name="CONSOLE_LOG_PATTERN"
value="%clr(%d{HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<!-- 日志输出编码 -->
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
</appender>
<!-- 日志输出级别 -->
<root level="INFO">
<appender-ref ref="console" />
</root>
</configuration>
sleuth-trace-b和sleuth-trace-c和sleuth-trace-a类似,修改controller和properties文件即可
4. 什么是Zipkin
Why Zipkin
我们先思考一个问题:Sleuth空有一身本领,可是没个页面可以show出来,而且Sleuth似乎只是自娱自乐在log里埋点,却没有一个汇聚信息的能力,不方便对整个集群的调用链路进行分析。Sleuth目前的情形就像Hystrix一样,也需要一个类似Turbine的组件做信息聚合+展示的功能。在这个背景下,Zipkin就是一个不错的选择。
Zipkin是一套分布式实时数据追踪系统,它主要关注的是时间维度的监控数据,比如某个调用链路下各个阶段所花费的时间,同时还可以从可视化的角度帮我们梳理上下游系统之间的依赖关系。
Zipkin由来
Zipkin也是来源于Google发布的一篇有关分布式监控系统论文(论文名称《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》),Twitter基于该论文研发了一套开源实现-Zipkin
Zipkin的核心功能
Zipkin的主要作用是收集Timing维度的数据,以供查找调用延迟等线上问题。所谓Timing其实就是开始时间+结束时间的标记,有了这两个时间信息,我们就能计算得出调用链路每个步骤的耗时。Zipkin的核心功能有以下两点
- 数据收集 聚合客户端数据
- 数据查找 通过不同维度对调用链路进行查找
Zipkin分为服务端和客户端,服务端是一个专门负责收集数据、查找数据的中心Portal,而每个客户端负责把结构化的Timing数据发送到服务端,供服务端做索引和分析。这里我们重点关注一下“Timing数据”到底用来做什么,前面我们说过Zipkin主要解决调用延迟情况的线上排查,它通过收集一个调用链上下游所有工作单元的独立用时,Zipkin就能知道每个环节在服务总用时中所占的比重,再通过图形化界面的形式,让开发人员知道性能瓶颈出在哪里。
Zipkin提供了多种维度的查找功能用来检索Span的耗时,最直观的是通过Trace ID查找整个Trace链路上所有Span的前后调用关系和每阶段的用时,还可以根据Service Name或者访问路径等维度进行查找。
5. 搭建配置Zipkin服务
5.1. zipkin基础服务搭建
创建一个zipkin-server的module
导入POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>zipkin-server</artifactId>
<name>zipkin-server</name>
<dependencies>
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-server</artifactId>
<version>2.8.4</version>
</dependency>
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-autoconfigure-ui</artifactId>
<version>2.8.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.icodingedu.springcloud.ZipkinServerApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
创建application启动类
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import zipkin.server.internal.EnableZipkinServer;
@SpringBootApplication
@EnableZipkinServer
public class ZipkinServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ZipkinServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
设置application.properties配置文件
spring.application.name=zipkin-server
server.port=65503
spring.main.allow-bean-definition-overriding=true
# 关闭后台窗口输出的一些无效错误日志
management.metrics.web.server.auto-time-requests=false
SpringCloud F版以后都可以通过jar包的形式直接启动了,下载地址如下
https://dl.bintray.com/openzipkin/maven/io/zipkin/java/zipkin-server/
下载最新版的:zipkin-server-2.12.9-exec.jar
然后 java -jar zipkin-server-2.12.9-exec.jar 运行即可
访问地址:http://localhsot:9411 默认端口9411
5.2. 服务集成zipkin
在sleuth-trace-a、sleuth-trace-b、sleuth-trace-c模块集成zipkin服务
增加POM依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
增加properties的属性
# zipkin地址
spring.zipkin.base-url=http://localhost:65503
# 采样率,1就表示100%,0.8表示80% 采样率和zipkin是一对同时生效的
# spring.sleuth.sampler.probability=1
6. 实现Zipkin服务高可用
给zipkin的POM中加入eureka-client的依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--把启动类加上:Application的-->
<configuration>
<mainClass>com.icodingedu.springcloud.ZipkinServerApplication</mainClass>
</configuration>
因为要做高可用,所以在Application启动类上要加入Eureka的注解
@SpringBootApplication
@EnableZipkinServer
@EnableDiscoveryClient
public class ZipkinServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(ZipkinServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
application配置文件里增加注册中心配置
eureka.client.serviceUrl.defaultZone=http://localhost:20001/eureka/
zipkin已经注册到eureka中了
这时就需要将traceA、traceB之前连接的ip地址更换成服务名并开启自动发现服务
# zipkin地址
spring.zipkin.sender.type=web
spring.zipkin.discovery-client-enabled=true
spring.zipkin.locator.discovery.enabled=true
spring.zipkin.base-url=http://ZIPKIN-SERVER/
7. Sleuth集成ELK实现日志检索
# 1-安装Elasticsearch
# 2-安装kibana
# 3-配置Logstash
# 解压Logstash在根目录创建新的配置文件目录
mkdir sync
vi logstash-log-sync.conf
input {
tcp {
port => 5044
codec => json_lines
}
}
output {
elasticsearch {
hosts => ["192.168.0.200:9200"]
}
}
去到Kibanan创建日志查询工具
创建完毕后,还是通过discover点击进入查询页面
将Sleuth的数据对接进ELK
- 引入Logstash的依赖到Sleuth项目中
- 配置日志文件,将所有日志以json格式输出到Logstash
引入POM依赖
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>5.2</version>
</dependency>
将traceA和traceB、traceC项目中日志模版增加Logstash日志输出部分,Logstash日志输出级别
<?xml version="1.0" encoding="UTF-8"?>
<!--该日志将日志级别不同的log信息保存到不同的文件中 -->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<springProperty scope="context" name="springAppName"
source="spring.application.name" />
<!-- 日志输出位置 -->
<property name="LOG_FILE" value="${BUILD_FOLDER:-build}/${springAppName}" />
<!-- 日志格式 -->
<property name="CONSOLE_LOG_PATTERN"
value="%clr(%d{HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<!-- 日志输出编码 -->
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
</appender>
<!-- Logstash -->
<!-- 为logstash输出的JSON格式的Appender -->
<appender name="logstash"
class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>39.99.216.16:5044</destination>
<!-- 日志输出编码 -->
<encoder
class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<timeZone>UTC</timeZone>
</timestamp>
<pattern>
<pattern>
{
"severity": "%level",
"service": "${springAppName:-}",
"trace": "%X{X-B3-TraceId:-}",
"span": "%X{X-B3-SpanId:-}",
"exportable": "%X{X-Span-Export:-}",
"pid": "${PID:-}",
"thread": "%thread",
"class": "%logger{40}",
"rest": "%message"
}
</pattern>
</pattern>
</providers>
</encoder>
</appender>
<!-- 日志输出级别 -->
<root level="INFO">
<appender-ref ref="console" />
<appender-ref ref="logstash" />
</root>
</configuration>
配置完数据源去启动Logstash并指定刚刚创建的配置文件
# 进入logstash/bin目录下
./logstash -f /usr/local/logstash/sync/logstash-log-sync.conf
SpringCloud消息组件-Stream(九)
springcloud就是想通过这些全家桶的组件来屏蔽底层差异,使用springcloud的使用者只关注业务
1. 消息驱动在微服务中的应用
-
跨系统异步通信
- 一般是公司内部使用消息队列来进行通信
- 消息通信是非阻塞式,如果需要阻塞通信就需要HTTP或其他方式
-
系统应用解耦
-
流量削峰
2. Stream体系结构
SpringCloud Stream是基于SpringBoot构建的,专门为构建消息驱动服务设计的应用框架,它的底层是使用Spring Integration来为消息代理层提供网络连接支持的
- 应用模型:引入了三个角色,输入通道Input,输出通道Output和底层中间件的代理Binder(RMQ/Kafka)
- 适配层抽象:Stream将组件与底层中间件之间通信抽象成了Binder层,使得应用层不需要关心底层中间件是Kafka还是RabbitMQ,只关心自身业务逻辑即可
- 插件式的适配层:只需要将底层的依赖更换
- 持久化的发布订阅模型
- 消费组:Stream允许将多个consumer加入到一个消费组,如果有两个消费组同时订阅消费一个topic,这个topic的消息会下发到这两个消费者组,且组内无轮多少消费者一次只能有一个进行消费,组内进行负载均衡
- 消费分区:Strean支持在多个消费者实例之间创建分区,并将消息发送给指定的分区,分区内可以进行消息的负载均衡
3. Stream快速落地
创建一个新的stream目录,建立stream-server的module
创建POM依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-project</artifactId>
<groupId>com.icodingedu</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>stream-server</artifactId>
<name>stream-server</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
</dependencies>
</project>
创建启动类application
package com.icodingeud.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
@SpringBootApplication
public class StreamServerApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(StreamServerApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建消息体对象,新建一个entity包
package com.icodingeud.springcloud.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MessageBean {
//生产者产生的消息体
private String payload;
}
创建接收消息的业务对象,创建一个service包
package com.icodingeud.springcloud.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
@Slf4j
@EnableBinding(value = {
Sink.class
})
public class StreamConsumer {
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
}
配置文件properties
spring.application.name=stream-server
server.port=63000
# RabbitMQ连接字符串
spring.rabbitmq.host=39.98.53.94
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
启动项目后在rabbitMQ的queue中去查看创建了一个新的队列
4. 实现Stream的消息发布订阅
- 创建消息的Product服务,配置消息的topic
- 启动多个consumer节点测试消息广播
创建一个topic的包,建立自己的topic类
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.messaging.SubscribableChannel;
public interface MyTopic {
String INPUT = "myTopic";
//可以被订阅的通道
//stream中input指接收消息端
//output是生产发送消息端
@Input(INPUT)
SubscribableChannel input();
}
在consumer类里增加自己的topic接收,一个自定义的消息消费者就创建好了
package com.icodingeud.springcloud.service;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
@Slf4j
@EnableBinding(value = {
Sink.class,
MyTopic.class
})
public class StreamConsumer {
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
@StreamListener(MyTopic.INPUT)
public void consumerMyMessage(Object payload){
log.info("My Message consumed successfully, payload={}",payload);
}
}
接下来我们创建生产者,在MyTopic里定义消息生产者
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface MyTopic {
String INPUT = "myTopic";
//可以被订阅的通道
//stream中input指接收消息端
//output是生产发送消息端
@Input(INPUT)
SubscribableChannel input();
//TODO 按照正常理解这里应该可以一样,我们先测试一下
@Output(INPUT)
MessageChannel output();
}
定义一个controller调用生产者来发送一条消息,创建一个controller的包
package com.icodingeud.springcloud.controller;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class StreamController {
//这就是由stream给我完成注入和绑定了
@Autowired
private MyTopic producer;
@PostMapping("send")
public void sendMessage(@RequestParam("body") String body){
producer.output().send(MessageBuilder.withPayload(body).build());
}
}
再次启动项目,这个时候会出现错误
org.springframework.beans.factory.BeanDefinitionStoreException: Invalid bean definition with name 'myTopic' defined in com.icodingeud.springcloud.topic.MyTopic: bean definition with this name already exists
告诉你之前定义的myTopic重复了,虽然我们前面认为input和output应该用一个topic名,但这里相当于声明了两个一样名字的bean,spring启动会报错
两步处理
1、将input和output分开定义:但这里发送和接收就不在一个topic里了,就会导致各自发送和接收
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface MyTopic {
String INPUT = "myTopic-consumer";
String OUTPUT = "myTopic-producer";
//可以被订阅的通道
//stream中input指接收消息端
//output是生产发送消息端
@Input(INPUT)
SubscribableChannel input();
//TODO 按照正常理解这里应该可以一样,我们先测试一下
@Output(OUTPUT)
MessageChannel output();
}
2、通过配置文件将两个topic绑定到一起
# 将两个channel绑定到同一个topic上
spring.cloud.stream.bindings.myTopic-consumer.destination=mybroadcast
spring.cloud.stream.bindings.myTopic-producer.destination=mybroadcast
启动两个项目,去RMQ中看一下,发现mybroadcast其实就是exchange并绑定了两个queue
5. 消费组和消息分区详解
5.1. 消费组
在上面这个例子中,“商品发布”就是一个消息,它被放到了对应的消息队列中,有两拨人马同时盯着这个Topic,这两拨人马各自组成了一个Group,每次有新消息到来的时候,每个Group就派出一个代表去响应,而且是从这个Group中轮流挑选代表(负载均衡),这里的Group也就是我们说的消费者。
5.2. 消费分区
消费组相当于是每组派一个代表去办事儿,而消费分区相当于是专事专办,也就是说,所有消息都会根据分区Key进行划分,带有相同Key的消息只能被同一个消费者处理。
消息分区有一个预定义的分区Key,它是一个SpEL表达式。我们需要在配置文件中指定分区的总个数N,Stream就会为我们创建N个分区,这里面每个分区就是一个Queue(可以在RabbitMQ管理界面中看到所有的分区队列)。
当商品发布的消息被生产者发布时,Stream会计算得出分区Key,从而决定这个消息应该加入到哪个Queue里面。在这个过程中,每个消费组/消费者仅会连接到一个Queue,这个Queue中对应的消息只能被特定的消费组/消费者来处理。
5.3. 基于消息组实现轮询单播
先创建一个GroupTopic接口
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface GroupTopic {
String INPUT = "group-consumer";
String OUTPUT = "group-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
在controller里进行注入并加入消息生产者
package com.icodingeud.springcloud.controller;
import com.icodingeud.springcloud.topic.GroupTopic;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class StreamController {
//这就是由stream给我完成注入和绑定了
@Autowired
private MyTopic producer;
@Autowired
private GroupTopic groupProducer;
@PostMapping("send")
public void sendMessage(@RequestParam("body") String body){
producer.output().send(MessageBuilder.withPayload(body).build());
}
@PostMapping("sendgroup")
public void sendGroupMessage(@RequestParam("body") String body){
groupProducer.output().send(MessageBuilder.withPayload(body).build());
}
}
创建消息消费者在consumer里进行添加
package com.icodingeud.springcloud.service;
import com.icodingeud.springcloud.topic.GroupTopic;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
@Slf4j
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class
})
public class StreamConsumer {
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
@StreamListener(MyTopic.INPUT)
public void consumerMyMessage(Object payload){
log.info("My Message consumed successfully, payload={}",payload);
}
@StreamListener(GroupTopic.INPUT)
public void consumerGroupMessage(Object payload){
log.info("Group Message consumed successfully, payload={}",payload);
}
}
最后设置配置文件
spring.cloud.stream.bindings.group-consumer.destination=group-exchange
spring.cloud.stream.bindings.group-producer.destination=group-exchange
# 消费分组是对于消息的消费者来说的
spring.cloud.stream.bindings.group-consumer.group=Group-A
Group-A启动两个服务进行一下测试,看是否一次只接收一个消息并且是轮询接收
可以看一下这个在RMQ中的形式,其实是只创建了一个消息队列:group-exchange.Group-A
只有一个队列接收消息然后由stream转给其中一个消费者
可以把分组名改成Group-B后再创建两个端口实例测试一下,每个消息被所有消费组中的一个消费者消费
6. Stream实现延时消息
前提:先要把RabbitMQ的延时插件:rabbitmq_delayed_message_exchange 安装好
已安装插件查看命令:rabbitmq-plugins list
安装完成后在RMQ管理界面创建exchage那里可以看到type中增加了x-delayed-message类型就ok了
创建一个DelayedTopic
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface DelayedTopic {
String INPUT = "delayed-consumer";
String OUTPUT = "delayed-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
修改controller,加入DelayedTopic
package com.icodingeud.springcloud.controller;
import com.icodingeud.springcloud.entity.MessageBean;
import com.icodingeud.springcloud.topic.DelayedTopic;
import com.icodingeud.springcloud.topic.GroupTopic;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class StreamController {
//这就是由stream给我们完成注入和绑定了
@Autowired
private MyTopic producer;
@Autowired
private GroupTopic groupProducer;
@Autowired
private DelayedTopic delayedProducer;
@PostMapping("send")
public void sendMessage(@RequestParam("body") String body){
producer.output().send(MessageBuilder.withPayload(body).build());
}
@PostMapping("sendgroup")
public void sendGroupMessage(@RequestParam("body") String body){
groupProducer.output().send(MessageBuilder.withPayload(body).build());
}
@PostMapping("senddm")
public void sendDelayedMessage(@RequestParam("body") String body,
@RequestParam("second") Integer second){
MessageBean messageBean = new MessageBean();
messageBean.setPayload(body);
log.info("***** 准备进入延迟发送队列.....");
delayedProducer.output().send(MessageBuilder.withPayload(messageBean)
.setHeader("x-delay",1000 * second)
.build());
}
}
修改consumer实现,增加DelayedTopic实现
package com.icodingeud.springcloud.service;
import com.icodingeud.springcloud.entity.MessageBean;
import com.icodingeud.springcloud.topic.DelayedTopic;
import com.icodingeud.springcloud.topic.GroupTopic;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
@Slf4j
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class
})
public class StreamConsumer {
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
@StreamListener(MyTopic.INPUT)
public void consumerMyMessage(Object payload){
log.info("My Message consumed successfully, payload={}",payload);
}
@StreamListener(GroupTopic.INPUT)
public void consumerGroupMessage(Object payload){
log.info("Group Message consumed successfully, payload={}",payload);
}
@StreamListener(DelayedTopic.INPUT)
public void consumerDelayedMessage(MessageBean bean){
log.info("Delayed Message consumed successfully, payload={}",bean.getPayload());
}
}
增加配置文件
# 延迟消息配置
spring.cloud.stream.bindings.delayed-consumer.destination=delayed-exchange
spring.cloud.stream.bindings.delayed-producer.destination=delayed-exchange
# 声明exchange类型
spring.cloud.stream.rabbit.bindings.delayed-producer.producer.delayed-exchange=true
7. Stream实现本地重试
创建重试的Topic
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface ErrorTopic {
String INPUT = "error-consumer";
String OUTPUT = "error-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
修改controller进行消息发送
@Autowired
private ErrorTopic errorProducer;
//单机版错误重试
@PostMapping("senderror")
public void sendErrorMessage(@RequestParam("body") String body){
errorProducer.output().send(MessageBuilder.withPayload(body).build());
}
修改consumer
package com.icodingeud.springcloud.service;
import com.icodingeud.springcloud.entity.MessageBean;
import com.icodingeud.springcloud.topic.DelayedTopic;
import com.icodingeud.springcloud.topic.ErrorTopic;
import com.icodingeud.springcloud.topic.GroupTopic;
import com.icodingeud.springcloud.topic.MyTopic;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class,
ErrorTopic.class
})
public class StreamConsumer {
//定一个一个线程安全的变量
private AtomicInteger count = new AtomicInteger(1);
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
@StreamListener(MyTopic.INPUT)
public void consumerMyMessage(Object payload){
log.info("My Message consumed successfully, payload={}",payload);
}
@StreamListener(GroupTopic.INPUT)
public void consumerGroupMessage(Object payload){
log.info("Group Message consumed successfully, payload={}",payload);
}
@StreamListener(DelayedTopic.INPUT)
public void consumerDelayedMessage(MessageBean bean){
log.info("Delayed Message consumed successfully, payload={}",bean.getPayload());
}
//异常重试单机版
@StreamListener(ErrorTopic.INPUT)
public void consumerErrorMessage(Object payload){
log.info("****** 进入异常处理 ******");
//计数器进来就自增1
if(count.incrementAndGet() % 3 == 0){
log.info("====== 完全没有问题! ======");
count.set(0);
}else{
log.info("----- what's your problem? -----");
throw new RuntimeException("****** 整个人都不行了 ******");
}
}
}
配置properties文件
# 单机错误重试消息配置
spring.cloud.stream.bindings.error-consumer.destination=error-exchange
spring.cloud.stream.bindings.error-producer.destination=error-exchange
# 重试次数(本机重试,是在客户端这里不断重试而不会发回给RabbitMQ)
# 次数=1相当于不重试
spring.cloud.stream.bindings.error-consumer.consumer.max-attempts=2
需要注意的点:
- 本地重试,Consumer相当于从消息组件中处理了一个新消息。而Consumer重试的触发点是Stream本身,只是从业务逻辑层面进行重试,消费的是同一个消息。
- 而RMQ的NACK机制是将消息重回队列队首,这个重试是基于消息队列的,可以被其他Consumer拿到并进行消费的
8. Stream实现消息重新入队
首先要注意一点:Re-queue和前面的本地重试Retry是有冲突的,配置了Retry的消息就不会触发Re-queue
创建RequeueTopic
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface RequeueTopic {
String INPUT = "requeue-consumer";
String OUTPUT = "requeue-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
修改controller
@Autowired
private RequeueTopic requeueProducer;
@PostMapping("requeue")
public void sendRequeueMessage(@RequestParam("body") String body){
requeueProducer.output().send(MessageBuilder.withPayload(body).build());
}
修改consumer
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class,
ErrorTopic.class,
RequeueTopic.class
})
@StreamListener(RequeueTopic.INPUT)
public void consumerRequeueMessage(Object payload){
log.info("****** 进入入队异常处理 ******");
try{
Thread.sleep(3000);
}catch (Exception ex){
log.error("**** 延迟等待错误{} ****",ex);
}
//让这个消息一直抛错
throw new RuntimeException("****** 整个人都不行了 ******");
}
修改配置文件properties
# 联机Requeue错误重试消息配置
spring.cloud.stream.bindings.requeue-consumer.destination=requeue-exchange
spring.cloud.stream.bindings.requeue-producer.destination=requeue-exchange
# 仅对当前consumer开启重新入队
spring.cloud.stream.rabbit.bindings.requeue-consumer.consumer.requeue-rejected=true
# 还要将本地重试次数设置为1,让其不要本地重试
spring.cloud.stream.bindings.requeue-consumer.consumer.max-attempts=1
# 增加一个消费者组,让其在一个组内进行消费
spring.cloud.stream.bindings.requeue-consumer.group=Group-Requeue
测试启动两个服务外,看是否入队后能被其他消费者消费
9. 异常情况导致消息无法消费的解决方案
顽固异常分为以下几类
- 消息被拒绝多次超过重试次数
- 消息过期,超过TTL
- 队列长度已满
对于架构师处理问题的态度和意识:一个都不能少
- 确保数据一致性,保留异常场景,人工介入
- 在RMQ场景中可以使用死信队列记录异常信息
- DLX:死信exchange
- DLK:死信queue
10. Stream借助死信队列实现异常处理
创建DlqTopic
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface DlqTopic {
String INPUT = "dlq-consumer";
String OUTPUT = "dlq-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
controller中引入DlqTopic
@Autowired
private DlqTopic dlqProducer;
//死信队列
@PostMapping("dlq")
public void sendDlqMessage(@RequestParam("body") String body){
dlqProducer.output().send(MessageBuilder.withPayload(body).build());
}
修改consumer中的内容
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class,
ErrorTopic.class,
RequeueTopic.class,
DlqTopic.class
})
//死信队列
@StreamListener(DlqTopic.INPUT)
public void consumerDlqMessage(Object payload){
log.info("****** DLK 进入异常处理 ******");
//计数器进来就自增1
if(count.incrementAndGet() % 3 == 0){
log.info("====== DLK 完全没有问题! ======");
}else{
log.info("----- DLK what's your problem? -----");
throw new RuntimeException("****** DLK 整个人都不行了 ******");
}
}
在配置文件中进行设置properties
# 死信队列配置
spring.cloud.stream.bindings.dlq-consumer.destination=dlq-exchange
spring.cloud.stream.bindings.dlq-producer.destination=dlq-exchange
spring.cloud.stream.bindings.dlq-consumer.consumer.max-attempts=2
spring.cloud.stream.bindings.dlq-consumer.group=Group-DLQ
# 默认创建一个exchange.dlq死信队列
spring.cloud.stream.rabbit.bindings.dlq-consumer.consumer.auto-bind-dlq=true
测试一下错误重试后的消息发送到死信队列中了,我们可以移动这个队列里的内容到另一个queue
但Move messages里提示需要安装两个插件
# rabbitmq_shovel
# rabbitmq_shovel_management
rabbitmq-plugins enable rabbitmq_shovel
rabbitmq-plugins enable rabbitmq_shovel_management
安装好后就是这样的内容显示了
可以移动到指定的queue中再次消费:dlq-exchange.Group-DLQ
11. 消息驱动中的降级和接口升版
创建一个FallbackTopic
package com.icodingeud.springcloud.topic;
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.SubscribableChannel;
public interface FallbackTopic {
String INPUT = "fallback-consumer";
String OUTPUT = "fallback-producer";
@Input(INPUT)
SubscribableChannel input();
@Output(OUTPUT)
MessageChannel output();
}
在controller里进行设置
@Autowired
private FallbackTopic fallbackProducer;
//fallbackTopic
@PostMapping("fallback")
public void sendFallbackMessage(@RequestParam("body") String body,
@RequestParam(value = "version",defaultValue = "1.0") String version){
//假定我们调用接口的版本
//生成订单placeOrder,placeOrderV2,placeOrderV3
//可以在上游通过不同的queue来调用区分版本
//也可以不改动上游只需要在调用时加上verison
fallbackProducer.output().send(MessageBuilder
.withPayload(body)
.setHeader("version",version)
.build());
}
进入consumer进行设置
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class,
ErrorTopic.class,
RequeueTopic.class,
DlqTopic.class,
FallbackTopic.class
})
//fallback + 升级版本
@StreamListener(FallbackTopic.INPUT)
public void consumerFallbackMessage(Object payload, @Header("version") String version){
log.info("****** Fallback Are you ok? ******");
//可以通过这样不同的版本走不同的业务逻辑
if("1.0".equalsIgnoreCase(version)){
log.info("====== Fallback 完全没有问题! ======");
}else if("2.0".equalsIgnoreCase(version)){
log.info("----- unsupported version -----");
throw new RuntimeException("****** fallback version ******");
}else{
log.info("---- Fallback version={} ----",version);
}
}
修改配置文件properties
# fallback队列配置
spring.cloud.stream.bindings.fallback-consumer.destination=fallback-exchange
spring.cloud.stream.bindings.fallback-producer.destination=fallback-exchange
spring.cloud.stream.bindings.fallback-consumer.consumer.max-attempts=2
spring.cloud.stream.bindings.fallback-consumer.group=Group-Fallback
回到consumer里增加fallback的逻辑
package com.icodingeud.springcloud.service;
import com.icodingeud.springcloud.entity.MessageBean;
import com.icodingeud.springcloud.topic.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.annotation.Header;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@EnableBinding(value = {
Sink.class,
MyTopic.class,
GroupTopic.class,
DelayedTopic.class,
ErrorTopic.class,
RequeueTopic.class,
DlqTopic.class,
FallbackTopic.class
})
public class StreamConsumer {
//定一个一个线程安全的变量
private AtomicInteger count = new AtomicInteger(1);
//这里先使用stream给的默认topic
@StreamListener(Sink.INPUT)
public void consumer(Object payload){
log.info("message consumed successfully, payload={}",payload);
}
@StreamListener(MyTopic.INPUT)
public void consumerMyMessage(Object payload){
log.info("My Message consumed successfully, payload={}",payload);
}
@StreamListener(GroupTopic.INPUT)
public void consumerGroupMessage(Object payload){
log.info("Group Message consumed successfully, payload={}",payload);
}
@StreamListener(DelayedTopic.INPUT)
public void consumerDelayedMessage(MessageBean bean){
log.info("Delayed Message consumed successfully, payload={}",bean.getPayload());
}
//异常重试单机版
@StreamListener(ErrorTopic.INPUT)
public void consumerErrorMessage(Object payload){
log.info("****** 进入异常处理 ******");
//计数器进来就自增1
if(count.incrementAndGet() % 3 == 0){
log.info("====== 完全没有问题! ======");
count.set(0);
}else{
log.info("----- what's your problem? -----");
throw new RuntimeException("****** 整个人都不行了 ******");
}
}
@StreamListener(RequeueTopic.INPUT)
public void consumerRequeueMessage(Object payload){
log.info("****** 进入入队异常处理 ******");
try{
Thread.sleep(3000);
}catch (Exception ex){
log.error("**** 延迟等待错误{} ****",ex);
}
throw new RuntimeException("****** 整个人都不行了 ******");
}
//死信队列
@StreamListener(DlqTopic.INPUT)
public void consumerDlqMessage(Object payload){
log.info("****** DLK 进入异常处理 ******");
//计数器进来就自增1
if(count.incrementAndGet() % 3 == 0){
log.info("====== DLK 完全没有问题! ======");
}else{
log.info("----- DLK what's your problem? -----");
throw new RuntimeException("****** DLK 整个人都不行了 ******");
}
}
//fallback + 升级版本
@StreamListener(FallbackTopic.INPUT)
public void consumerFallbackMessage(Object payload, @Header("version") String version){
log.info("****** Fallback Are you ok? ******");
//可以通过这样不同的版本走不同的业务逻辑
if("1.0".equalsIgnoreCase(version)){
log.info("====== Fallback 完全没有问题! ======");
}else if("2.0".equalsIgnoreCase(version)){
log.info("----- unsupported version -----");
throw new RuntimeException("****** fallback version ******");
}else{
log.info("---- Fallback version={} ----",version);
}
}
//exchange.group.errors
//配置一定要设置组名否则就找不到queue了,不设置组名是随机生成queue后缀
@ServiceActivator(inputChannel = "fallback-exchange.Group-Fallback.errors")
public void fallback(Message<?> message){
log.info("**** Enter Fallback, Payload={}",message.getPayload());
}
}