一、什么是微服务

1.1 认识微服务

1.1.1 单体架构

将业务的所有功能集中在一个项目中开发,打包成一个包部署。

image-20230814120650288

优点:

  • 架构简单
  • 部署成本低

缺点:

  • 耦合度高

1.1.2 分布式架构

根据业务功能对系统 进行拆分,每个业务模块作为独立项目开发,称为一个服务。

image-20230814121111159

优点:

  • 耦合度低
  • 有利于服务升级拓展

1.1.3 服务治理

分布式架构要考虑的问题有很多

  • 服务拆分粒度如何?
  • 服务集群地址如何维护?
  • 服务之间如何实现远程调用?
  • 服务健康状态如何感知?

1.1.4 微服务

微服务是一种经过良好架构设计的==分布式架构==方案,微服务架构的特征:

  • 单一职责:微服务拆分粒度更小,每个服务都对应唯一的业务能力,做到单一职责,避免重复业务开发。
  • 面向服务:微服务对外暴露业务接口。
  • 自治:团队独立、技术独立、数据独立、部署独立。
  • 隔离性强:服务调用做好隔离、容错、降级、避免出现级联问题。

1.2 微服务结构

微服务这种方案需要技术框架落地,全球的互联网公司都在积极尝试自己的微服务落地技术。在国内最知名的就是SpringCloud和阿里巴巴的Dubbo。

image-20230814123146287

1.3 微服务技术对比

image-20230814123901169

1.4 SpringCloud

官网:https://spring.io/projects/spring-cloud

SpringCloud是目前国内使用最广泛的微服务框架。

SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供良好的开箱即用体验。

image-20230814131615752

1.5 SpirngCloud与SpringBoot的兼容关系

image-20230814131949682

二、服务拆分及远程调用

2.1 服务拆分的注意事项

  1. 不同微服务,不要重复开发相同业务。
  2. 微服务数据独立,不要访问其他微服务的数据库。
  3. 微服务可以将自己的业务暴露为接口,供其他服务使用。

2.2 微服务远程调用

image-20230814164235898

1
2
3
4
5
6
7
/*
注入RestTemplate到容器
*/
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private RestTemplate restTemplate;

@GetMapping("{orderId}")
public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
// 1.根据id查询订单并返回
Order order = orderService.queryOrderById(orderId);
// 2.利用RestTemplate发HTTP请求
String url = "http://localhost:8081/user/";
User user = restTemplate.getForObject(url + order.getUserId(), User.class);
order.setUser(user);
return order;
}

2.3 提供者与调用者

  • 服务提供者:一次业务中,被其他服务调用的服务。(提供接口给其他服务)
  • 服务消费者:一次业务中,调用其他微服务的服务。(调用其他微服务的接口)
  • 提供者和消费者角色是相对的,既可以是提供者也可以是消费者

三、Eureka注册中心

3.1 服务调用出现的问题

  • 服务消费者该如何获取服务提供者的地址信息?
    • 服务提供者启动向eureka注册自己的信息
    • eureka保存这些信息
    • 消费者根据服务名称向eureka拉取这些信息
  • 如果有多个服务提供者,消费者如何选择?
    • 服务消费者利用负载均衡算法,从服务列表中挑选一个
  • 消费者如何得知服务提供者的健康状态?
    • 提供者会每隔30秒向eureka发送心跳请求,报告健康状态
    • eureka会更新服务列表信息,心跳不正常会被剔除
    • 消费者就可以拉取到最新的信息。

image-20230814182638815

3.2 实践

image-20230814183454269

3.2.1 搭建EurekaServer

  1. 创建项目,导入spring-cloud-starter-netflix-eureka-server
1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
  1. 添加EnableEurekaServer注解
1
2
3
4
5
6
7
8
@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {

public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class,args);
}
}
  1. 配置
1
2
3
4
5
6
7
8
9
10
11
server:
port: 10086 # 服务端口

spring:
application:
name: eureka-server # eureka的服务名称

eureka:
client:
service-url: # eureka地址信息
defaultZone: http://localhost:10086/eureka

3.3 服务注册

  1. 引入依赖
1
2
3
4
5
<!--Eureka客户端依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
  1. 添加配置
1
2
3
4
5
6
7
8
spring:
application:
name: eureka-server # eureka的服务名称

eureka:
client:
service-url: # eureka地址信息
defaultZone: http://localhost:10086/eureka

image-20230814204934291

3.4服务拉取

服务拉取是基于服务名称获取服务列表,然后在对服务列表做负载均衡

  1. 修改OrderService的代码,修改url的访问路径,用服务名(user-service)代替IP、端口:
1
2
String url = "http://user-service/user/";
User user = restTemplate.getForObject(url + order.getUserId(), User.class);
  1. 在order-service的启动类中为RestTemplate添加负载均衡的注解@LoadBalanced
1
2
3
4
5
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}

四、Ribbon负载均衡

4.1 负载均衡流程

image-20230814205743367

4.2 负载均衡原理

  • RestTemplate发送的请求会被LoadBalancerInterceptor负载均衡拦截器拦截。获取URI和服务名
1
2
URI originalUri = request.getURI();	// http://user-service/user/
String serviceName = originalUri.getHost();// user-service
  • 然后会调用RibbonLoadBalancerClient的execute方法
1
(ClientHttpResponse)this.loadBalancer.execute(serviceName, this.requestFactory.createRequest(request, body, execution));
  • 通过execute的getgetLoadBalancer()或得负载均衡器DynamicServerListLoadBalancer。里面拥有根据服务名获得的所有服务列表。
1
2
3
4
5
6
7
8
9
ILoadBalancer loadBalancer = this.getLoadBalancer(serviceId);
// 根据负载均衡器可以获得一个指定的服务
Server server = this.getServer(loadBalancer, hint);

// ZoneAwareLoadBalancer.chooseServer
super.chooseServer(key);

// BaseLoadBalancer.chooseServer
return this.rule.choose(key); // rule负载均衡策略

image-20230814214816493

4.2 负载均衡策略

image-20230814220436904

4.3 调整负载均衡的规则

  • 方式一:注入bean(全局)
1
2
3
4
@Bean
public IRule randomRule(){
return new RandomRule();
}
  • 方式二:(针对某个微服务而言,这里针对user-service是随机的)
1
2
3
user-service:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.loadbalancer

4.4 饥饿加载

Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。

而饥饿加载则在项目启动时创建,降低第一次访问的耗时。

1
2
3
4
ribbon:
eager-load:
enabled: true # 开启饥饿加载
clients: user-service # 集合,开启饥饿加载的服务名称

五、Nacos注册中心

Nacos是阿里巴巴的产品,现在是SpringCloud的一个组件。相比Eureka功能更加丰富,在国内很受欢迎。

5.1 Nacos的安装

5.1.1 Windows安装

开发阶段采用单机安装即可。

下载安装包

在Nacos的GitHub页面,提供有下载链接,可以下载编译好的Nacos服务端或者源代码:

GitHub主页:https://github.com/alibaba/nacos

GitHub的Release下载页:https://github.com/alibaba/nacos/releases

如图:

image-20210402161102887

本课程采用1.4.1.版本的Nacos,课前资料已经准备了安装包:

image-20210402161130261

windows版本使用nacos-server-1.4.1.zip包即可。

1.2.解压

将这个包解压到任意非中文目录下,如图:

image-20210402161843337

目录说明:

  • bin:启动脚本
  • conf:配置文件

端口配置

Nacos的默认端口是8848,如果你电脑上的其它进程占用了8848端口,请先尝试关闭该进程。

如果无法关闭占用8848端口的进程,也可以进入nacos的conf目录,修改配置文件中的端口:

image-20210402162008280

修改其中的内容:

image-20210402162251093

启动

启动非常简单,进入bin目录,结构如下:

image-20210402162350977

然后执行命令即可:

  • windows命令:

    1
    startup.cmd -m standalone

执行后的效果如图:

image-20210402162526774

访问

在浏览器输入地址:http://127.0.0.1:8848/nacos即可:

image-20210402162630427

默认的账号和密码都是nacos,进入后:

image-20210402162709515

5.1.2 Linux安装

Linux或者Mac安装方式与Windows类似。

安装JDK

Nacos依赖于JDK运行,索引Linux上也需要安装JDK才行。

上传jdk安装包:

image-20210402172334810

上传到某个目录,例如:/usr/local/

然后解压缩:

1
tar -xvf jdk-8u144-linux-x64.tar.gz

然后重命名为java

配置环境变量:

1
2
export JAVA_HOME=/usr/local/java
export PATH=$PATH:$JAVA_HOME/bin

设置环境变量:

1
source /etc/profile

上传安装包

如图:

image-20210402161102887

也可以直接使用课前资料中的tar.gz:

image-20210402161130261

上传到Linux服务器的某个目录,例如/usr/local/src目录下:

image-20210402163715580

解压

命令解压缩安装包:

1
tar -xvf nacos-server-1.4.1.tar.gz

然后删除安装包:

1
rm -rf nacos-server-1.4.1.tar.gz

目录中最终样式:

image-20210402163858429

目录内部:

image-20210402164414827

端口配置

与windows中类似

启动

在nacos/bin目录中,输入命令启动Nacos:

1
sh startup.sh -m standalone

5.1.3 Docker安装nacos

1
2
3
4
5
6
7
8
9
PREFER_HOST_MODE=hostname
MODE=standalone
SPRING_DATASOURCE_PLATFORM=mysql
MYSQL_SERVICE_HOST=192.168.150.102
MYSQL_SERVICE_DB_NAME=nacos
MYSQL_SERVICE_PORT=3306
MYSQL_SERVICE_USER=root
MYSQL_SERVICE_PASSWORD=123
MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
1
2
3
4
5
6
7
8
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim

5.2 Nacos的快速入门

5.2.1 服务注册

  1. 在父工程中添加spring-cloud-alibaba-dependencies依赖
1
2
3
4
5
6
7
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.5.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
  1. 注释掉order-service和user-service原有的eureka依赖

  2. 添加nacos客户端依赖

1
2
3
4
5
<!-- nacos客户端依赖包 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
  1. 修改yaml的配置文件
1
2
3
4
5
6
spring:
application:
name: user-service # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos客户端地址
  1. 启动并测试。

image-20240719161003933

5.2.2 服务发现

  1. 引入nacosdiscovery依赖
  2. 配置nacos地址
  3. 服务发现

image-20250529223415626

5.3 Nacos配置集群

image-20230815164152212

  • 一级是服务,例如user-service

  • 二级是集群,例如杭州集群,上海集群

  • 三级是实例,例如杭州机房某台部署了user-service的服务器

如何设置集群属性。

1
2
3
4
5
6
spring:
cloud:
nacos:
server-addr: localhost:8848
discovery:
cluster-name: SH # 设置集群名称

5.4 NacosRule负载均衡

在order-service中配置NacosRule负载均衡

1
2
3
user-service:
ribbon:
NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule

NacosRule负载均衡的特点:

  1. 优先选择同集群服务实例列表
  2. 本地集群找不到提供者 ,才去其他集群寻找,并且返回警告
  3. 确定了可用实例列表后,在采用随机负载均衡挑选实例

5.4.1 根据权重负载均衡

服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求。

Nacos提供了权重配置来控制访问频率,可设置权重值在0~1之间,权重越大则访问频率越高。权重为0则完全不会被访问。

在Nacos控制台可以设置实例实际的权重值,首选选中实例后面的编辑按钮。

image-20230815185428067

image-20230815185453192

5.5 环境隔离namespace

Nacos中服务存储和数据存储的最外层都是一个名为namespace的东西,用来做最外层隔离。不同namespace下的服务不可见。

image-20230815190513244
  1. Nacos控制台可以创建namespace,用来做环境隔离

image-20230815190943274

image-20230815191347818

  1. 配置namespace
1
2
3
4
5
6
7
spring:
cloud:
nacos:
server-addr: localhost:8848
discovery:
cluster-name: HZ # 集群名称
namespace: e16730ce-f808-4fd7-91bb-2943414e8893 # 命名空间ID
  1. 此时访问order-service,因为namespace不同,会导致找不到user-service,控制台会报错。

5.6 Nacos与Eureka的区别

image-20230815192424005

服务注册到Nacos时可以选择注册为临时实例和非临时实例,通过下列配置来设置:

1
2
3
4
5
spring:
cloud:
nacos:
discovery:
ephemeral: false #设置为非临时实例

共同点:

  1. 都支持服务注册和服务拉取
  2. 都支持服务提供者心跳方式做健康检测

异同点:

  1. Nacos支持服务端主动监测提供者状态;临时实例采用心跳模式,非临时实例采用主动监测模式。
  2. 临时实例心跳不正常会被剔除。非临时不会。
  3. Nacos支持服务列表变更的消息推送模式,服务列表及时更新更及时。
  4. Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式。Eureka采用AP方式。

六、OpenFeign远程调用

官方地址:https://github.com/OpenFeign/feign

6.1 RestTemplate方式存在的问题

1
2
String url = "http://user-service/user/";
User user = restTemplate.getForObject(url + order.getUserId(), User.class);
  1. 代码可读性差,编程体验不统一
  2. 参数复杂URL难以维护

6.2 Feign的使用

Feign是一个声明式的http客户端,其作用就是帮助我们优雅的实现http请求的发送,解决上面提到的问题。

  1. 引入依赖
1
2
3
4
5
6
7
8
9
10
<!--feign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
  1. 在启动类上添加@EnableFeignClients注解
1
2
3
4
5
6
7
8
9
@EnableFeignClients
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {

public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
  1. 使用
1
2
3
4
5
6
@FeignClient("user-service")
public interface UserClient {

@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class OrderService {

@Autowired
private OrderMapper orderMapper;

@Autowired
private UserClient userClient;

public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.利用 Feign 发HTTP请求
User user = userClient.findById(order.getUserId());
order.setUser(user);
// 3.返回
return order;
}
}

6.3 Feign的自定义配置

image-20230816143814254

一般我们需要配置的就是日志级别

6.3.1 配置Feign日志

配置Feign日志的方式有两种

配置文件方式

1
2
3
4
5
6
7
8
9
10
11
12
# 全局方式
feign:
client:
config:
default:
logger-level: FULL
# 局部生效
feign:
client:
config:
user-service: # 针对某个微服务生效
logger-level: FULL

配置类方式

1
2
3
4
5
6
7
public class DefaultFeignConfiguration {

@Bean
public Logger.Level loggerLevel(){
return Logger.Level.FULL;
}
}
  • 全局生效
1
2
3
4
5
6
7
8
@EnableFeignClients(defaultConfiguration = DefaultFeignConfiguration.class)
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
  • 局部有效
1
2
3
4
5
6
@FeignClient(value = "user-service",configuration = DefaultFeignConfiguration.class)
public interface UserClient {

@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}

6.4 性能优化

Feign底层客户端实现

  • URLConnection:默认实现,不支持链接池
  • Apache HttpClient:支持链接池
  • OKHttp:支持链接池

优化Feign的性能主要包括

  1. 连接池代替默认的URLConnection

  2. 日志级别,最好用basic或none

1
2
3
4
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
feign:
client:
config:
default:
logger-level: basic # 日志级别
httpclient:
enabled: true # 开启feign对httpClient的支持
max-connections: 200 # 最大链接数
max-connections-per-route: 50 # 每个路径最大链接数

6.5 最佳实践

方式一(继承)

给消费者的FeignClient和提供者的controller定义统一的父接口作为标准。

image-20230816153039877

方式二(抽取)

将FeignClient抽取为独立模块,并且把接口相关的POJO,默认的Feign配置都放在这个模块中,提供给所有消费者使用。

image-20230816152902544

  1. 首先创建一个module,命名为feign-api,然后引入feign的starter依赖
  2. 将order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中
  3. 在order-service中引入feign-api的依赖
  4. 修改order-service中的所有与上述三个组件有关的import部分,改成导入feign-api中的包
  5. 重启测试

当定义的FeignClient不在Springboot的扫描范围时,这些FeignClient无法使用。有两种解决方法。

1
@EnableFeignClients(clients = {UserClient.class})
1
@EnableFeignClients(basePackages = "com.itcast.clients")

七、Gateway网关

7.1 为什么需要网关

image-20230816164458174

网关功能:

  • 身份认证和权限校验

  • 服务路由转发,负载均衡

  • 请求限流

SpringCloud中网关的实现包括两种:

  • Gateway
  • zuul

zuul是基于servlet实现,属于阻塞式编程,而SpringCloudGateway是基于spring5中提供的WebFulx,属于响应式编程,具备更好的性能。

7.2 搭建网关服务

  1. 创建新的module,引入SpringCloudGateway的依赖和nacos服务发现的依赖
1
2
3
4
5
6
7
8
9
10
<!--nacos服务注册发现依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--网关gateway依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
  1. 配置

网关路由可以配置的内容包括

  • 路由id:路由的唯一坐标。
  • uri:路由的地址,支持lb和http两种
  • predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地
  • filters:路由过滤器,处理请求或响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
server-addr: localhost:8848 #nacos地址
gateway:
routes:
- id: user-service
# lb就是负载均衡,后面跟服务名称
uri: lb://user-service
predicates: # 路由断言
- Path=/user/** # 只要以/user/开头就符合规则
- id: order-service
uri: lb://order-service
predicates:
- Path=/order/**

image-20230816172817059

7.3 路由断言工厂

我们在配置文件中写的断言规则只是字符串,这些字符串会被PredicateFactory读取并处理,转变为路由判断的条件

例如:Path=/user/**是按照路径匹配,这个规则是由
org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory类来处理的

Spring提供了11种基本的Predicate工厂:

image-20230816173807797

PredicateFactory的作用是什么?

读取路由的判断规则,而后把他解析成对应的判断条件,等请求进来做判断。

7.4 过滤器

7.4.1 网关请求处理流程

image-20250604203717728

7.4.2 网关提供的GatewayFilter

路由过滤器GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理。

默认不生效,需要配置到路由后生效。

image-20230816181018507

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
server-addr: localhost:8848 #nacos地址
gateway:
routes:
- id: user-service
# lb就是负载均衡,后面跟服务名称
uri: lb://user-service
predicates: # 路由断言
- Path=/user/** # 只要以/user/开头就符合规则
- id: order-service
uri: lb://order-service
predicates:
- Path=/order/**
# filters:
# - AddRequestHeader=color,blue # 针对user-service的过滤器
default-filters:
- AddRequestHeader=color,blue # 针对所有路由的过滤器

7.4.3 全局过滤器

自定义GlobalFilter

全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与GatewayFilter的作用一样。区别在于GatewayFilter通过配置定义,处理逻辑是固定的。而GlobalFilter的逻辑需要自己写代码实现,作用范围是所有路由,声明后自动生效。

定义方式是实现GlobalFilter

image-20230816185741130

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Order(-1)	// 数字越小,优先级越高
@Component
public class AuthorizeFilter implements GlobalFilter {

// exchange:包含整个过滤器链内的共享数据,例如:request,response
// chain:过滤器链,当前过滤器执行完后,要调用下一个过滤器链中的过滤器
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求参数
ServerHttpRequest request = exchange.getRequest();
MultiValueMap<String, String> queryParams = request.getQueryParams();
// 获取authorization参数
String auth = queryParams.getFirst("authorization");
// 判断
if ("admin".equals(auth)){
// 放行
return chain.filter(exchange);
}
ServerHttpResponse response = exchange.getResponse();
// 设置状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 拦截请求
return response.setComplete();
}
}

自定义GateWayFilter

所有自定义GateWayFilter必须以GatewayFilterFactory作为后缀。前缀作为配置项到配置文件配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {

@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter(new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String a = config.getA();
String b = config.getB();
String c = config.getC();
System.out.println("过滤器执行了!");
System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("c = " + c);
return chain.filter(exchange);
}
}, 1);
}

@Override
public List<String> shortcutFieldOrder() {
return List.of("a", "b", "c");
}

@Override
public Class<Config> getConfigClass() {
return Config.class;
}

@Data
public static class Config{
private String a;
private String b;
private String c;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 192.168.150.102:8848
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
default-filters:
- PrintAny=1,2,3 # 配置类名前缀生效

7.4.4 过滤器的执行顺序

请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter。
请求路由后,会将当前路由过滤器DefaultFilterGlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器。

  • 每一个过滤器都必须指定一个int类型的order值,order值越小,优先级越高,执行顺序越靠前

  • GlobalFilter通过实现Ordered接口,或者添加@Order注解来指定order值,由我们自己指定

  • 路由过滤器和defaultFilter的order由Spring指定,默认是按照声明顺序从1递增。

  • 当过滤器的order值一样时,会按照defaultFilter >路由过滤器>GlobalFilter的顺序执行。

7.5 跨越问题处理

image-20230816192613316

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
cloud:
gateway:
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
corsConfigurations:
'[/**]':
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:8090"
- "http://www.leyou.com"
allowedMethods: # 允许的跨域ajax的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期

7.6 网关登录校验

7.6.1 自定义GlobalFilter实现登录校验

image-20250604203935813****

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 登录校验过滤器
*/
@Order(1)
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthGlobalFilter implements GlobalFilter {

private final AuthProperties authProperties;
private final JwtTool jwtTool;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取request
ServerHttpRequest request = exchange.getRequest();
// 2. 判断是否需要做登录拦截
// 2.1 获取得到放行的路径
if (isExclude(request.getPath().toString())){
return chain.filter(exchange);
}
// 3. 获取token
List<String> headers = request.getHeaders().get(HttpHeaders.AUTHORIZATION);
if (headers == null || headers.size() == 0){
return null;
}
// 4.解析token
Long userId;
try {
String token = headers.get(0);
userId = jwtTool.parseToken(token);
}catch (Exception e){
// 4.1拦截,设置响应状态码
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 5.传递用户信息
ServerWebExchange swe = exchange.mutate().request(builder -> builder.header("user-info", userId.toString())).build();
// 6.放行
return chain.filter(swe);
}

private boolean isExclude(String path) {
List<String> excludePaths = authProperties.getExcludePaths();
for (String excludePath : excludePaths) {
if (antPathMatcher.match(excludePath,path)) {
return true;
}
}
return false;
}
}

从网关到微服务实际上是发送了一次HTTP请求,可以将用户信息放到请求头里面传递过去。

因为每个微服务都需要获取用户信息,所以可以在执行业务方法之前加一个SpringMVC的拦截器处理用户信息就行,将其存到ThreadLocal中,微服务需要用到的时候在从中取。不用重复写,将拦截器写在common模块中就可以了。

image-20250604211816099

7.6.2 将用户信息放行给下游微服务

  1. 修改转发到微服务的请求,需要用到ServerWebExchange类提供的API。
1
exchange.mutate().request(builder -> builder.header("user-info",userId.toString())).build();
  1. 使用SpringMVC的拦截器获取用户信息,存储到ThreadLocal中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.hmall.common.interceptors;

import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class UserInfoInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取登录用户信息
String userInfo = request.getHeader("user-info");
// 2.判断是否获取得到用户信息,如果有,存入ThreadLocal
if(StrUtil.isNotBlank(userInfo)){
UserContext.setUser(Long.parseLong(userInfo));
}
// 3.放行
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理用户
UserContext.removeUser();
}
}

网关也引入了common模块,启动会报错。网关和其他微服务的区别是,网关没有引入SpringMVC的依赖,所以不会有DispatcherServlet,加一个条件,当存在DispatcherServlet类的时候才加载配置。

1
2
3
4
5
6
7
8
9
10
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
}
}

因为在common包下,其他微服务启动的时候扫描不到该包,所以要在spring.factories配置文件中加入该路径,使其可以被Spring扫描到。

1
2
3
4
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MyBatisConfig,\
com.hmall.common.config.JsonConfig, \
com.hmall.common.config.MvcConfig

7.6.3 OpenFeign传递用户信息

image-20250604214943253

在交易服务中,请求是从网关发过来的,所以能获取到请求头的用户信息。

但是如果是微服务之间的调用,例如扣减库存,实际是从交易服务发送的请求,所以里面没有包含用户的信息,获取不到用户信息。

OpenFeign中提供了一个拦截器接口,所有由OpenFeign发起的请求前都会先调用拦截器处理请求。

1
2
3
public interface RequestInterceptor {
void apply(RequestTemplate var1);
}
1
2
3
4
5
6
7
8
9
10
@Configuration
public class OpenFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
if (UserContext.getUser() == null) {
return;
}
template.header("user-info", String.valueOf(UserContext.getUser()));
}
}

在启动类的主键上添加上该配置

1
@EnableFeignClients(basePackages = "com.hmall.fegin.client",  defaultConfiguration = OpenFeignInterceptor.class)

7.7 网关实现动态路由

要实现动态路由首先要将路由配置保存到Nacos,当Nacos中的路由配置变更时,推送最新配置到网关,实时更新网关中的路由信息。

  1. 监听Nacos配置变更的消息

  2. 当配置变更时,将最新的路由信息更新到网关路由表。

    • 监听到路由信息后,可以利用RouteDefinitionWriter来更新路由表
    1
    2
    3
    4
    5
    6
    public interface RouteDefinitionWriter {
    // 更新路由到路由表,如果路由ID重复,则会覆盖旧的路由
    Mono<Void> save(Mono<RouteDefinition> route);
    // 根据路由ID删除某个路由
    Mono<Void> delete(Mono<String> routeId);
    }

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {

private final NacosConfigManager nacosConfigManager;
private final RouteDefinitionWriter writer;

private final Set<String> dataIdSet;

private final String dataId = "gateway-routes.json";

private final String group = "DEFAULT_GROUP";

@PostConstruct
public void initRouteConfigListener() throws NacosException {
// 拉取一次配置,并添加配置监听器
String configInfo = nacosConfigManager.getConfigService().getConfigAndSignListener(dataId, group, 5000, new Listener() {
@Override
public Executor getExecutor() {
return null;
}

@Override
public void receiveConfigInfo(String configInfo) {
// 2.监听到配置变更,需要更新路由表
updateConfigInfo(configInfo);
}
});
// 3. 第一次读取配置,也需要更新到路由表
updateConfigInfo(configInfo);
}

public void updateConfigInfo(String configInfo){
log.info("监听到路由配置信息:{}", configInfo);
// 1. 解析配置信息,转为RouteDefinition
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 2.删除旧的路由表
dataIdSet.forEach(id -> {
writer.delete(Mono.just(id)).subscribe();
});
dataIdSet.clear();
// 3. 更新路由表
for (RouteDefinition routeDefinition : routeDefinitions) {
writer.save(Mono.just(routeDefinition)).subscribe();
// 记录路由ID,便于下次更新时删除
dataIdSet.add(routeDefinition.getId());
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[
{
"id": "item",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
}],
"filters": [],
"uri": "lb://item-service"
},
{
"id": "cart",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/carts/**"}
}],
"filters": [],
"uri": "lb://cart-service"
},
{
"id": "user",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/users/**", "_genkey_1":"/addresses/**"}
}],
"filters": [],
"uri": "lb://user-service"
},
{
"id": "trade",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/orders/**"}
}],
"filters": [],
"uri": "lb://trade-service"
},
{
"id": "pay",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/pay-orders/**"}
}],
"filters": [],
"uri": "lb://pay-service"
}
]

八、Nacos配置管理

拆分微服务后存在的问题:

  • 微服务重复配置过多,维护成本高
  • 业务经常变动,每次修改都要重启服务
  • 网关路由写死,如果变更需要重启网关

8.1 统一管理配置

image-20230905222535207

  1. 在Nacos中创建配置文件

image-20230815195458466

image-20230815195618908

  1. 引入Nacos的配置管理客户端依赖
1
2
3
4
5
<!--naoos配置管理依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
  1. 创建bootstrap.yaml文件
1
2
3
4
5
6
7
8
9
10
11
12
spring:
application:
name: user-service
profiles:
active: dev # 环境
cloud:
nacos:
server-addr: localhost:8848 #地址
config:
file-extension: yaml #文件后缀名
shared-configs: # 共享配置
- dataId: # 配置文件名
  1. 测试是否读取到配置信息
1
2
@Value("${pattern.dataformat}")
private String dataformat;

Nacos2021.0.5版本后面的问题(补充)

经过查阅官方资料,确认从2021.0.5版本起,Spring Cloud将不再默认启用bootstrap 包,所以针对该问题,解决方案有两种:

  1. application.yml中添加以下配置,用以指定引入的配置文件。
1
2
3
spring:
config:
import: nacos:xxxx.yaml
  1. 导入依赖
1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>4.0.1</version>
</dependency>

8.2 热更新

Nacos配置文件变更后,微服务无需重启就可以感知,不过需要通过下面两种配置实现

  1. 在@Value注解所在类上面添加注解@RefreshScope
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
@RefreshScope
public class UserService {

@Autowired
private UserMapper userMapper;

@Value("${pattern.dataformat}")
private String dataformat;

public User queryById(Long id) {
System.out.println(dataformat);
return userMapper.findById(id);
}
}

注意事项

  • 不是将所有的配置都适合放到配置中心,维护起来比较麻烦。
  • 建议将一些关键参数,需要运行时调整的参数放到nacos配置中心,一般都是自定义配置。
  1. 使用@ConfigurationProperties注解
1
2
3
4
5
6
7
@Component
@Data
@ConfigurationProperties("pattern")
public class PatternProperties {

private String dataformat;
}

需要配置上文件的后缀,否则不生效。

8.3 命名空间,配置分组

image-20240410172959898

1
2
3
4
5
6
7
spring.application.name=gulimall-coupon

spring.cloud.nacos.config.server-addr=127.0.0.1:8848
# 命名空间id
spring.cloud.nacos.config.namespace=18f71696-17e1-482a-940f-3a9e9212cb87
# 配置分组
spring.cloud.nacos.config.group=dev

每个微服务创建自己的命名空间,使用配置分组区分环境。

8.4 多环境配置共享

微服务启动时,会从nacos读取多个配置文件:

  • [spring.application.name]-[spring.profiles.active].yaml,例如user-service-dev.yaml
  • [spring.application.name].yaml,例如user-service.yaml

无论profile如何变化[spring.application.name].yaml这个文件一定会加载。因此多环境共享配置可以写入这个文件。

image-20240719184004104

配置文件的优先级:服务名-profile.yaml > 服务名.yaml > 本地配置

8.5 把配置抽取到nacos上

nacos2.1版本配置中心spring.cloud.nacos.config.ext-config禁止使用怎么办?

使用extension-configs,数据类型已经是list

boostrap.properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 服务名.properties的文件会默认读取(默认加载)
spring.application.name=gulimall-coupon

spring.cloud.nacos.config.server-addr=127.0.0.1:8848
# 命名空间id,默认加载的空间
spring.cloud.nacos.config.namespace=18f71696-17e1-482a-940f-3a9e9212cb87
# 配置分组,默认加载的环境
spring.cloud.nacos.config.group=dev

# 把微服务的配置都抽取到nacos上面
# 配置集id
spring.cloud.nacos.config.extension-configs[0].data-id=dasource.yaml
spring.cloud.nacos.config.extension-configs[0].group=dev
spring.cloud.nacos.config.extension-configs[0].refresh=true

spring.cloud.nacos.config.extension-configs[1].data-id=mybatis.yaml
spring.cloud.nacos.config.extension-configs[1].group=dev
spring.cloud.nacos.config.extension-configs[1].refresh=true

spring.cloud.nacos.config.extension-configs[2].data-id=other.yaml
spring.cloud.nacos.config.extension-configs[2].group=dev
spring.cloud.nacos.config.extension-configs[2].refresh=true

boostrap.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
application:
name: user-service
cloud:
nacos:
server-addr: 192.168.150.102:8848
config:
file-extension: yaml # 设置文件后缀
shared-configs: # 读取共享配置
- data-id: shared-jdbc.yaml
- data-id: shared-log.yaml
- data-id: shared-swagger.yaml
- data-id: shared-feign.yaml

application.yaml中的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#spring:
# datasource:
# url: jdbc:mysql://192.168.200.101:3306/gulimall_sms
# driver-class-name: com.mysql.cj.jdbc.Driver
# username: root
# password: root
# cloud:
# nacos:
# server-addr: localhost:8848
# application:
# name: gulimall-coupon
#
#mybatis-plus:
# mapper-locations: classpath:/mapper/**/*.xml
# global-config:
# db-config:
# id-type: auto

抽取后

image-20240410175332613

8.6 Nacos集群搭建

集群结构图

官方给出的Nacos集群图:

image-20210409210621117

其中包含3个nacos节点,然后一个负载均衡器代理3个Nacos。这里负载均衡器可以使用nginx。

我们计划的集群结构:

image-20210409211355037

三个nacos节点的地址:

节点 ip port
nacos1 192.168.150.1 8845
nacos2 192.168.150.1 8846
nacos3 192.168.150.1 8847

搭建集群

搭建集群的基本步骤:

  • 搭建数据库,初始化数据库表结构
  • 下载nacos安装包
  • 配置nacos
  • 启动nacos集群
  • nginx反向代理

初始化数据库

Nacos默认数据存储在内嵌数据库Derby中,不属于生产可用的数据库。

这里我们以单点的数据库为例来讲解。

首先新建一个数据库,命名为nacos,而后导入下面的SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
CREATE TABLE `config_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(255) DEFAULT NULL,
`content` longtext NOT NULL COMMENT 'content',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
`app_name` varchar(128) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
`c_desc` varchar(256) DEFAULT NULL,
`c_use` varchar(64) DEFAULT NULL,
`effect` varchar(64) DEFAULT NULL,
`type` varchar(64) DEFAULT NULL,
`c_schema` text,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_aggr */
/******************************************/
CREATE TABLE `config_info_aggr` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(255) NOT NULL COMMENT 'group_id',
`datum_id` varchar(255) NOT NULL COMMENT 'datum_id',
`content` longtext NOT NULL COMMENT '内容',
`gmt_modified` datetime NOT NULL COMMENT '修改时间',
`app_name` varchar(128) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfoaggr_datagrouptenantdatum` (`data_id`,`group_id`,`tenant_id`,`datum_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='增加租户字段';


/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_beta */
/******************************************/
CREATE TABLE `config_info_beta` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL COMMENT 'content',
`beta_ips` varchar(1024) DEFAULT NULL COMMENT 'betaIps',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfobeta_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_beta';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_tag */
/******************************************/
CREATE TABLE `config_info_tag` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
`tag_id` varchar(128) NOT NULL COMMENT 'tag_id',
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL COMMENT 'content',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfotag_datagrouptenanttag` (`data_id`,`group_id`,`tenant_id`,`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_tag';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_tags_relation */
/******************************************/
CREATE TABLE `config_tags_relation` (
`id` bigint(20) NOT NULL COMMENT 'id',
`tag_name` varchar(128) NOT NULL COMMENT 'tag_name',
`tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
`nid` bigint(20) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`nid`),
UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = group_capacity */
/******************************************/
CREATE TABLE `group_capacity` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群',
`quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
`usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
`max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
`max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值',
`max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
`max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_id` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = his_config_info */
/******************************************/
CREATE TABLE `his_config_info` (
`id` bigint(64) unsigned NOT NULL,
`nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`data_id` varchar(255) NOT NULL,
`group_id` varchar(128) NOT NULL,
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL,
`md5` varchar(32) DEFAULT NULL,
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`src_user` text,
`src_ip` varchar(50) DEFAULT NULL,
`op_type` char(10) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`nid`),
KEY `idx_gmt_create` (`gmt_create`),
KEY `idx_gmt_modified` (`gmt_modified`),
KEY `idx_did` (`data_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造';


/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = tenant_capacity */
/******************************************/
CREATE TABLE `tenant_capacity` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID',
`quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
`usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
`max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
`max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数',
`max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
`max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表';


CREATE TABLE `tenant_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`kp` varchar(128) NOT NULL COMMENT 'kp',
`tenant_id` varchar(128) default '' COMMENT 'tenant_id',
`tenant_name` varchar(128) default '' COMMENT 'tenant_name',
`tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc',
`create_source` varchar(32) DEFAULT NULL COMMENT 'create_source',
`gmt_create` bigint(20) NOT NULL COMMENT '创建时间',
`gmt_modified` bigint(20) NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info';

CREATE TABLE `users` (
`username` varchar(50) NOT NULL PRIMARY KEY,
`password` varchar(500) NOT NULL,
`enabled` boolean NOT NULL
);

CREATE TABLE `roles` (
`username` varchar(50) NOT NULL,
`role` varchar(50) NOT NULL,
UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE
);

CREATE TABLE `permissions` (
`role` varchar(50) NOT NULL,
`resource` varchar(255) NOT NULL,
`action` varchar(8) NOT NULL,
UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE
);

INSERT INTO users (username, password, enabled) VALUES ('nacos', '$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu', TRUE);

INSERT INTO roles (username, role) VALUES ('nacos', 'ROLE_ADMIN');

下载nacos

nacos在GitHub上有下载地址:https://github.com/alibaba/nacos/tags,可以选择任意版本下载。

本例中才用1.4.1版本:

image-20210409212119411

配置Nacos

将这个包解压到任意非中文目录下,如图:

image-20210402161843337

目录说明:

  • bin:启动脚本
  • conf:配置文件

进入nacos的conf目录,修改配置文件cluster.conf.example,重命名为cluster.conf:

image-20210409212459292

然后添加内容:

1
2
3
127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

然后修改application.properties文件,添加数据库配置

1
2
3
4
5
6
7
spring.datasource.platform=mysql

db.num=1

db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=root
db.password.0=123

启动

将nacos文件夹复制三份,分别命名为:nacos1、nacos2、nacos3

image-20210409213335538

然后分别修改三个文件夹中的application.properties,

nacos1:

1
server.port=8845

nacos2:

1
server.port=8846

nacos3:

1
server.port=8847

然后分别启动三个nacos节点:

1
startup.cmd

nginx反向代理

找到课前资料提供的nginx安装包:

image-20210410103253355

解压到任意非中文目录下:

image-20210410103322874

修改conf/nginx.conf文件,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
upstream nacos-cluster {
server 127.0.0.1:8845;
server 127.0.0.1:8846;
server 127.0.0.1:8847;
}

server {
listen 80;
server_name localhost;

location /nacos {
proxy_pass http://nacos-cluster;
}
}

而后在浏览器访问:http://localhost/nacos即可。

代码中application.yml文件配置如下:

1
2
3
4
spring:
cloud:
nacos:
server-addr: localhost:80 # Nacos地址

优化

  • 实际部署时,需要给做反向代理的nginx服务器设置一个域名,这样后续如果有服务器迁移nacos的客户端也无需更改配置.

  • Nacos的各个节点应该部署到多个不同服务器,做好容灾和隔离。

九、Sentinel流量控制

9.1 初始Sentinel

9.1.1 雪崩问题

微服务调用链路中的某个服务故障,引起整个链路中的所有微服务都不可用,这就是雪崩。

image-20230829193715288

雪崩产生的原因:

  • 微服务相互调用,服务提供者出现故障或阻塞。
  • 服务调用者没有做好异常处理,导致自身出现故障。
  • 调用链中的所有微服务级联失败。导致集群故障。

解决雪崩问题的常用方式有四种。

  • 超时处理:设定==超时时间==,请求超过一定时间没有响应就会返回错误信息,不会无休止的等待。

  • 舱壁模式(线程隔离):限定每个业务能使用的==线程数==,避免耗尽整个tomcat的资源,因此也叫线程隔离

    image-20250605213315051

  • 熔断降级:由断路器统计业务执行的异常比例,如果超出阈值则会==熔断==该业务,拦截访问该业务的一切请求。熔断期间,所有请求快速失败,全走fallback逻辑。

    image-20250605213703626
  • 请求限流(预防雪崩):限制业务访问微服务的并发量(QPS:每秒中能处理的请求的数量),避免服务因流量的突增而故障。

    image-20250605212811679

9.1.2 技术对比

image-20230829195110176

9.1.3 认识Sentinel

sentinel是阿里巴巴开源的一款微服务流量空组件。

官网:http://sentinelguard.io/zh-cn/index.html

安装sentinel,是哟java -jar启动jar包即可。然后访问localhost:8080

修改配置。

image-20230829201215372

举例:

1
2
java -jar sentinel-dashboard-1.8.6.jar -Dserver.port=8090
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar

9.1.4 整合Sentinel

  1. 引入Sentinel依赖
1
2
3
4
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
  1. 配置控制台地址
1
2
3
4
5
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8080 # sentinel控制台地址
  1. 访问http://localhost:8090/#/login,用户名密码都是,sentinel
  2. 访问微服务任意端点,触发Sentinel监控

9.2 限流规则(流控)

9.2.1 簇点链路

簇点链路:就是项目内的调用链路,链路中被监控的每个接口就是一个资源。默认情况下sentinel会监控SpringMVC的每一个端点(Endpoint) ,因此SpringMVC的每一个端点(Endpoint)就是调用链路中的一个资源。

流控、熔断等都是针对簇点链路中的资源来设置的,因此我们可以点击对应资源后面的按钮来设置规则;

image-20230829210729159

Restful风格的API请求路径一般相同,这会导致簇点资源名称重复。可以修改配置,把请求方式和请求路径作为簇点资源名称。

1
2
3
4
5
6
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
http-method-specify: true

流控(请求限流)

image-20230829210915015

案例:

image-20230829230546894

流控模式(三种)

在添加限流规则时,点击高级选项,可以选择三种流控模式:

  • 直接:统计当前资源的请求,触发阈值时对当前资源直接限流,也是默认的模式。
  • 关联:统计与当前资源相关的另一个资源,触发阈值时,对当前资源限流。
  • 链路:统计从指定链路访问到本资源的请求,触发阈值时,对指定链路限流。
关联模式

关联:统计与当前资源相关的另一个资源,触发阈值时,对当前资源限流。

使用场景:

比如用户支付时需要修改订单状态,同时用户要查询订单。查询和修改操作会争抢数据库锁,产生竞争。业务需求是有限支付和更新订单的业务,因此当修改订单业务触发阈值时,需要对查询订单业务限流。

案例:

image-20230829231711040

1
2
3
4
5
6
7
8
9
@GetMapping("/query")
public String query(){
return "查询订单成功";
}

@GetMapping("/update")
public String update(){
return "更新订单成功";
}
image-20230829232045253
链路模式

链路:统计从指定链路访问到本资源的请求,触发阈值时,对指定链路限流

image-20230830183725887

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/query")
public String query(){
// 查询商品
orderService.queryGoods();
// 查询订单
System.out.println("查询订单");
return "查询订单成功";
}

@PostMapping("/save")
public String save(){
orderService.queryGoods();
// 新增订单
System.out.println("新增订单");
return "添加订单";
}

Sentinel默认只标记Controller中的方法为资源,如果要标记其它方法,需要利用@SentinelResource注解。

1
2
3
4
@SentinelResource("goods")
public void queryGoods() {
System.out.println("查询商品");
}

Sentinel默认会将Controller方法做context整合,导致链路模式的流控失效,需要修改application.yml

1
2
3
4
spring:
cloud:
sentinel:
web-context-unify: false # 关闭context整合

流控效果

流控效果是指请求达到流控阈值时应该采取的措施,包括三种。

  • 快速失败:达到阈值后,新的请求会被立即拒绝并抛出FlowException异常。是默认的处理方式。
  • warm up:预热模式,对超出阈值的请求同样是拒绝并抛出异常。但这种模式阈值会动态变化,从一个较小值逐渐增加到最大阈值。
  • 排队等待:让所有的请求按照先后次序排队执行,两个请求的间隔不能小于指定时长。
warm up

warm up也叫预热模式,是应对服务冷启动的一种方案。请求阈值初始值是threshold / coldFactor,持续指定时长后,逐渐提高到threshold值。而coldFactor的默认值是3

image-20230830195903344

排队等待

当请求超过QPs阈值时,快速失败和warm up会拒绝新的请求并抛出异常。而排队等待则是让所有请求进入一个队列中,然后按照阈值允许的时间间隔依次执行。后来的请求必须等待前面执行完成,如果请求预期的等待时间超出最大时长,则会被拒绝。
例如:QPS=5,意味着每200ms处理一个队列中的请求;timeout = 2000,意味着预期等待超过2000ms的请求会被拒绝并抛出异常。

image-20240727230856546

image-20240727230935344

热点参数限流

之前的限流是统计访问某个资源的所有请求,判断是否超过QPs阈值。而热点参数限流是分别统计参数值相同的请求,判断是否超过QPS阈值。超过阈值的快速失败。

image-20230830202015271

在热点参数限流的高级选项中,可以对部分参数设置例外配置

image-20230830202101488

image-20230830202112303

注意:

热点参数限流对默认的SpringMVC资源无效。需要添加@SentinelResource()注解

9.2.2 隔离和降级

虽然限流可以尽量避免因高并发而引起的服务故障,但服务还会因为其他原因而故障。而要将这些故障控制在一定范围,避免雪崩,就要靠线程隔离和熔断降级手段了。

线程隔离:

image-20230830203221894

熔断降级:

image-20230830203311689

不管是线程隔离还是熔断降级,都是对**客户端(调用方)**的保护

Feign整合sentinel

SprngCloud中,微服务调用都是通过Feign来实现的,因此做客户端保护必须整合Feign和Sentinel。

  1. 修改OrderService的application.yaml文件,开启Feign的Sentinel功能
1
2
3
feign:
sentinel:
enabled: true
  1. 给FeignClien编写失败后的降级逻辑。
    • 方式一:FallbackClass,无法对远程调用的异常做处理
    • 方式二:FallbackFactory,可以对远程调用的异常做处理,我们选择这种

image-20230830204340342

image-20230830204405019

线程隔离

线程隔离有两种方式:

  • 线程池隔离
  • 信号量隔离(Sentinel默认采用)
image-20230830220739647

image-20230830221009068

9.3 熔断降级(熔断)

熔断降级是解决雪崩问题的重要手段。其思路是由断路器统计服务调用的异常比例、慢请求比例,如果超出阈值则会熔断该服务。即拦截访问该服务的一切请求;当服务恢复时,断路器会放行访问该服务的请求。(由断路器里面的状态机实现)

image-20230830222442502

断路器熔断策略有三种:慢调用异常比例异常数

9.3.1 慢调用

慢调用:业务的响应时长(RT)大于指定时长的请求认定为慢调用请求。在指定时间内,如果请求数量超过设定的最小数量,慢调用比例大于设定的阈值,则触发熔断。

例如:

image-20230830222925242

9.3.2 异常比例或异常数

异常比例或异常数:统计指定时间内的调用,如果调用次数超过指定请求数,并且出现异常的比例达到设定的比例阈值(或超过指定异常数),则触发熔断。例如:

image-20230831133237261

image-20230831133309202

9.4 授权规则和持久化

9.4.1 授权规则

授权规则可以对调用方的来源做控制,有白名单和黑名单两种方式。

  • 白名单:来源(origin)在白名单内的调用者允许访问
  • 黑名单:来源(origin)在黑名单内的调用者不允许访问

image-20230831134509986Sentinel是通过RequestOriginParser这个接口的parseOrigin来获取请求的来源的。

image-20230831135044302

image-20230831135053411

image-20230831135103153

9.4.2 自定义异常结果

默认情况下,发生限流、降级、授权拦截时,都会抛出异常到调用方。如果要自定义异常时的返回结果,需要实现BlockExceptionHandler接口:

image-20230831140113348

BlockException的子类:

image-20230831140156020

image-20230831140417966

9.4.3 规则持久化

规则管理模式

Sentinel的控制台规则管理有三种模式。

  • 原始模式: Sentinel的默认模式,将规则保存在内存,重启服务会丢失。
  • pull模式
  • push模式
pull模式

pull模式∶控制台将配置的规则推送到Sentinel客户端,而客户端会将配置规则保存在本地文件或数据库中。以后会定时去本地文件或数据库中查询,更新本地规则。(保存在本地文件或数据库,定时去取

image-20230831141156958

push模式

push模式:控制台将配置规则推送到远程配置中心,例如Nacos。Sentinel客户端监听Nacos,获取配置变更的推送消息,完成本地配置更新。(保存到Nacos

image-20230831141316969

实现规则持久化

一、修改order-service服务

修改OrderService,让其监听Nacos中的sentinel规则配置。

具体步骤如下:

1.引入依赖

在order-service中引入sentinel监听nacos的依赖:

1
2
3
4
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
2.配置nacos地址

在order-service中的application.yml文件配置nacos地址及监听的配置信息:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
sentinel:
datasource:
flow:
nacos:
server-addr: localhost:8848 # nacos地址
dataId: orderservice-flow-rules
groupId: SENTINEL_GROUP
rule-type: flow # 还可以是:degrade、authority、param-flow
  • flow限流
  • degrade降级
二、修改sentinel-dashboard源码

SentinelDashboard默认不支持nacos的持久化,需要修改源码。

1. 解压

解压课前资料中的sentinel源码包:

image-20210618201340086

然后并用IDEA打开这个项目,结构如下:

image-20210618201412878

2. 修改nacos依赖

在sentinel-dashboard源码的pom文件中,nacos的依赖默认的scope是test,只能在测试时使用,这里要去除:

image-20210618201607831

将sentinel-datasource-nacos依赖的scope去掉:

1
2
3
4
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
3. 添加nacos支持

在sentinel-dashboard的test包下,已经编写了对nacos的支持,我们需要将其拷贝到main下。

image-20210618201726280

4. 修改nacos地址

然后,还需要修改测试代码中的NacosConfig类:

image-20210618201912078

修改其中的nacos地址,让其读取application.properties中的配置:

image-20210618202047575

在sentinel-dashboard的application.properties中添加nacos地址配置:

1
nacos.addr=localhost:8848
5. 配置nacos数据源

另外,还需要修改com.alibaba.csp.sentinel.dashboard.controller.v2包下的FlowControllerV2类:

image-20210618202322301

让我们添加的Nacos数据源生效:

image-20210618202334536

6. 修改前端页面

接下来,还要修改前端页面,添加一个支持nacos的菜单。

修改src/main/webapp/resources/app/scripts/directives/sidebar/目录下的sidebar.html文件:

image-20210618202433356

将其中的这部分注释打开:

image-20210618202449881

修改其中的文本:

image-20210618202501928

7. 重新编译、打包项目

运行IDEA中的maven插件,编译和打包修改好的Sentinel-Dashboard:

image-20210618202701492

8.启动

启动方式跟官方一样:

1
java -jar sentinel-dashboard.jar

如果要修改nacos地址,需要添加参数:

1
java -jar -Dnacos.addr=localhost:8848 sentinel-dashboard.jar

十、Seata分布式事务

image-20230831143931292

在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有事务最终状态一致,这样的事务就是分布式事务

image-20230831162918584

库存服务失败,账户服务和订单服务应该进行回滚。

10.1 理论基础

10.1.1 CAP定理

image-20230831163641160

Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。

Availability(可用性):用户访问集群节点中的任意节点,都必须能得到响应,而不是超时或拒绝。

partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去链接,形成独立分区。

Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务。

CAD定理的内容?

  • 分布式系统节点通过网络连接,一定会出现分区问题。
  • 当分区出现时,系统的一致性和可用性就无法同时满足。

10.1.2 BASE理论

BASE是对CAP的一种解决思路,包含三个思想:

  • Basically Available(基本可用)∶分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State(软状态)∶在一定时间内,允许出现中间状态,比如临时的不一致状态。
  • Eventually Consistent(最终一致性)︰虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

而分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论:

  • AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致
  • CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。

image-20230831170951639

解决分布式事务的思想和模型?

全局事务:整个分布式事务。

分支事务:分布式事务中包含每个子系统的事务。

最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据。

强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚。

10.2 初始Seata

Seata是2019年1月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。

image-20230831231956776

全局事务管理器,定义全局的范围,开始全局事务的提交或者回滚。

分布式事务都会有一个入口的方法,在这个入口方法中会调用其他的微服务,每个微服务都是一个分支事务,这样全局事务就知道包含了多少个分支事务,这样范围就确定下来了。当入口方法执行时,全局事务管理器会拦截当前的执行,回去向事务协调者(TC) 注册一个全局事务,告诉事务协调者,这里有个分布式事务要开始执行了,然后开始执行入口方法的逻辑,调用每个微服务的分支事务,这时资源管理器(RM)就会代理分支业务, 在分支事务执行的时候也拦截下来,向TC注册当前分支事务,我属于哪个全局事务,注册完后RM就可以执行业务sql了,执行完分支事务后,报告状态给事务协调者,等入口方法全部执行完毕,全局事务管理器提交事务到事务协调者,事务协调者就会检查每个分支事务的状态,如果都是成功的,让资源管理器提交,如果有失败就回滚。

image-20240727143643487

10.3 动手实践

10.3.1 XA模式(强一致性,低可用)

XA规范是x/Open组织定义的分布式事务处理(DTP, Distributed Transaction Processing) 标准,XA规范描述了全局的
TM与局部的RM之间的接口,几乎所有主流的数据库都对XA规范提供了支持。

image-20240727150905232

image-20230904213124669

seata的XA模式做了一些调整

RM一阶段工作:

  1. RM一阶段的工作:注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段工作:

  1. TC检查各分支事务执行状态
    1. 如果都成功,则通知所有RM提交事务
    2. 如果有失败,则通通知所有RM回滚事务

RM二阶段工作:

  • 接收TC指令,提交或回滚事务。

image-20230904213356534

优点

事务的强一致性,满足ACID原则。

常用数据库都支持,实现简单,并且没有代码侵入

缺点

因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差。

依赖关系型数据库实现事务

实现

Seata的starter已经完成了XA模式的自动装配,实现非常简单,
步骤如下:

  1. 修改application.yml文件(每个参与事务的微服务),开启XA模式:
1
2
seata:
data-source-proxy-mode: XA #开启数据源代理的XA模式
  1. 给发起全局事务的入口方法添加@GlobalTransactional注解,本例中是OrderServicelmpl中的create方法:

image-20240727152006574

  1. 重启服务测试

10.3.2 AT模式

AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。

image-20230904225748686

AT模式和XA模式的区别?

  • XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
  • XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
  • XA模式强一致;AT模式最终一致

AT模式的脏写问题

丢失更新

image-20230904230315520

image-20230904230725322

image-20230904231346993

image-20230905201353830

还需要加上@GlobalTransactional注解。

10.3.3 TCC模式

TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC都是通过人工编码来实现数据恢复。需要实现3个方法。

image-20230905201546089

image-20230905202008185

image-20230905202223049

image-20230905202434589

当某分支事务的try阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚

对于已经空回滚的业务,如果后续继续执行try,就永远不可能confirm或cancel,这就是业务悬挂。应当阻止执行空回滚后的try操作,避免悬挂。

image-20230905203647181

为了防止实现空回滚,防止业务悬挂,以及幂等性的要求。我们必须在数据库记录冻结金额的同时,记录当前事务的id和执行状态,为此我们设计了一个张表。

1
2
3
4
5
6
7
8
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` int(11) UNSIGNED NULL DEFAULT 0,
`state` int(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;

image-20230905204704562

优点

  • 一阶段完成直接提交事务,释放数据库资源,性能好
  • 相比AT模型,无需生成快照,无需使用全局锁,性能最强
  • 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库

缺点

  • 有代码侵入,需要人为编写try.Confirm和Cancel接口,太麻烦
  • 软状态,事务是最终一致
  • 需要考虑Confirm和Cancel的失败情况,做好幂等处理

实现

1
2
3
4
5
6
7
8
9
10
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);

boolean confirm(BusinessActionContext ctx);

boolean cancel(BusinessActionContext ctx);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {

@Autowired
private AccountMapper accountMapper;

@Autowired
private AccountFreezeMapper freezeMapper;


@Override
// 资源的监测和预留
public void deduct(String userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
// 判断free中是否有冻结记录,如果有,则CANCEL一定被执行过
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze != null){
return;
}
// 1.扣减可用余额
accountMapper.deduct(userId,money);
// 2.记录冻结金额,记录事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}

@Override
public boolean confirm(BusinessActionContext ctx) {
// 获取事务id
String xid = ctx.getXid();
// 根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}

@Override
public boolean cancel(BusinessActionContext ctx) {
// 获取事务id
String xid = ctx.getXid();
// 1.查询冻结金额
AccountFreeze freeze = freezeMapper.selectById(xid);
// 空回滚判断,try为null代表没有执行try,需要空回滚
if (freeze == null){
freeze = new AccountFreeze();
freeze.setUserId(ctx.getActionContext("userId").toString());
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
// 判断幂等,防止多次执行
if(freeze.getState().equals(AccountFreeze.State.CANCEL)){
// 已经处理过CANCEL,无需处理
return true;
}

// 2.恢复可用余额
accountMapper.refund(freeze.getUserId(),freeze.getFreezeMoney());
// 3.将冻结金额清0
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("account")
public class AccountController {

@Autowired
private AccountTCCService accountTCCService;

@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money){
accountTCCService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}

10.3.4 Saga模式

image-20230905215318891

image-20230905220025721

高可用

TC服务做为Seata的核心服务,一定要保证高可用和异地容灾。