一、服务调用

服务拆分之后,不可避免的会出现跨微服务的业务,此时微服务之间就需要进行远程调用。微服务之间的远程调用被称为RPC,即远程过程调用。RPC的实现方式有很多,比如:

  • 基于Http协议
  • 基于Dubbo协议

1.RestTemplate

Spring给我们提供了一个RestTemplate的API,可以方便的实现Http请求的发送。

使用RestTemplate的基本步骤如下

  • 注册RestTemplate到Spring容器
  • 调用RestTemplate的API发送请求,常见方法有:
    • getForObject:发送Get请求并返回指定类型对象
    • PostForObject:发送Post请求并返回指定类型对象
    • put:发送PUT请求
    • delete:发送Delete请求
    • exchange:发送任意类型请求,返回ResponseEntity

image-20250410102942741

利用RestTemplate发送http请求与前端ajax发送请求非常相似,包含以下信息:

  • 1 请求路径
  • 2 请求方式
  • 3 请求体
  • 4 返回值类型
  • 5 请求参数

案例代码

1
2
3
4
5
6
7
8
9
// 2.查询商品
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
"http://127.0.0.1:8081/items?ids={ids}",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<ItemDTO>>() {
},
Map.of("ids", CollUtils.join(itemIds, ","))
);

代码详解

  1. 第四个参数中为什么不能直接写 List<ItemDTO>.class

在 Java 中,泛型类型在运行时会被擦除(即类型信息丢失)。这意味着 List<ItemDTO> 在运行时会被当作普通的 List 处理,而 ItemDTO 的类型信息会被丢弃。因此,List<ItemDTO>.class 是不合法的,因为 Java 无法在运行时识别 List<ItemDTO> 的具体类型。

  1. ParameterizedTypeReference 的作用

ParameterizedTypeReference 是 Spring 提供的一个工具类,用于显式地指定泛型类型。它通过匿名内部类的方式保留了泛型类型信息,使得 Spring 的 RestTemplate 可以正确解析返回值。

1
new ParameterizedTypeReference<List<ItemDTO>>() {}

这行代码告诉 RestTemplate,返回值是一个 List<ItemDTO> 类型的对象。

  1. 如果直接使用 List.class 会发生什么?

如果你直接使用 List.class,代码可能如下:

1
2
3
4
5
6
7
ResponseEntity<List> response = restTemplate.exchange(
"http://127.0.0.1:8081/items?ids={ids}",
HttpMethod.GET,
null,
List.class,
Map.of("ids", CollUtils.join(itemIds, ","))
);

这种情况下,RestTemplate 只知道返回值是一个 List,但不知道 List 中的元素类型是 ItemDTO。因此,RestTemplate 无法正确解析返回值为 List<ItemDTO>,可能会导致运行时错误。

  1. 为什么需要 ParameterizedTypeReference

ParameterizedTypeReference 的设计正是为了解决泛型擦除的问题。它允许你在运行时保留泛型类型信息,使得 RestTemplate 能够正确解析返回值。

2.注册中心

问题引入

假设当前微服务面对高并发的调用请求,压力大。这时我们需要部署多个服务实例。

image-20250410103750346

此时,每个item-service的实例其IP或端口不同,那么问题来了:

  • item-service这么多实例,cart-service如何知道每一个实例的地址?
  • http请求要写url地址,cart-service服务到底该调用哪个实例呢?
  • 如果在运行过程中,某一个item-service实例宕机,cart-service依然在调用该怎么办?
  • 如果并发太高,item-service临时多部署了N台实例,cart-service如何知道新实例的地址?

为了解决上述问题,就必须引入注册中心。

注册中心原理

在微服务远程调用的过程中,包括两个角色:

  • 服务提供者:提供接口供其它微服务访问,比如item-service
  • 服务消费者:调用其它微服务提供的接口,比如cart-service

在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:

image-20250410104201511

流程如下:

  • 服务启动时服务提供者就会注册自己的服务信息(服务名、IP、端口)到注册中心(按服务名决定归属哪个服务)。
  • 服务调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(一个服务可能有多实例部署)。
  • 服务调用者会对实例列表负载均衡,挑选一个实例调用。
  • 服务调用者挑选后向该实例发起远程调用

当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?

  • 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳)
  • 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,从而将其从其服务的实例列表中剔除
  • 当服务有新的实例启动时,会发送注册服务的请求,其信息会被记录在注册中心的服务实例列表
  • 当注册中心的服务实例列表更新时,会主动通知微服务,更新本地服务列表

注意:微服务通常会维护一个本地缓存的服务列表,用于优化服务调用的性能(避免每次调用时都从注册中心获取服务列表)。当注册中心通知微服务服务列表变更时,微服务会根据收到的增量更新,及时刷新它们本地缓存的服务列表。这样,在下一次进行跨服务调用时,微服务可以确保使用的是最新的服务实例信息。

Nacos注册中心

目前开源的注册中心框架有很多,国内比较常见的有:

  • Eureka:Netflix公司出品,目前被集成在SpringCloud当中,一般用于Java应用
  • Nacos:Alibaba公司出品,目前被集成在SpringCloudAlibaba中,一般用于Java应用
  • Consul:HashiCorp公司出品,目前集成在SpringCloud中,不限制微服务语言

以上几种注册中心都遵循SpringCloud中的API规范,因此在业务开发使用上没有太大差异。Nacos是国内产品,中文文档比较丰富,而且同时具备配置管理功能。

部署步骤

这里是基于docker部署nacos

1.将nacos的sql文件导入到docker的mysql容器中

2.在nacos/custom.env文件中,有一个MYSQL_SERVICE_HOST也就是mysql地址,需要修改为你自己的虚拟机IP地址

3.将nacos目录上传至虚拟机的/root目录。

4.进入root目录,然后执行下面的docker命令:

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

启动完成后,访问下面地址:http://192.168.10.101:8848/nacos,注意将`192.168.10.101`替换为你自己的虚拟机IP地址。

首次访问会跳转到登录页,账号密码都是nacos

服务注册

接下来,我们把item-service注册到Nacos,步骤如下:

  • 引入依赖
  • 配置Nacos地址
  • 重启

1.添加依赖

item-servicepom.xml中添加依赖:

1
2
3
4
5
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

2.配置Nacos

item-serviceapplication.yml中添加nacos地址配置:

1
2
3
4
5
6
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: 192.168.10.101:8848 # nacos地址

3.启动服务实例

为了测试一个服务多个实例的情况,我们再配置一个item-service的部署实例:

image-20250410120015322

然后配置启动项,注意重命名并且配置新的端口,避免冲突:

image-20250410120036519

重启item-service的两个实例:

访问nacos控制台,可以发现服务注册成功:

image-20250410202356900

点击详情,可以查看到item-service服务的两个实例信息:

image-20250410120125855

服务发现

服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:

  • 引入依赖
  • 配置Nacos地址
  • 发现并调用服务

1.引入依赖

服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。

我们在cart-service中的pom.xml中添加下面的依赖:

1
2
3
4
5
<!--nacos 服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

可以发现,这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者。

因此,等一会儿cart-service启动,同样会注册到Nacos

2.配置Nacos地址

cart-serviceapplication.yml中添加nacos地址配置:

1
2
3
4
spring:
cloud:
nacos:
server-addr: 192.168.150.101:8848

3.发现并调用服务

接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。

因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:

  • 随机
  • 轮询
  • IP的hash
  • 最近最少访问

这里我们可以选择最简单的随机负载均衡。

另外,服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用:

image-20250410121727177

接下来,我们就可以对原来的远程调用做修改了,之前调用时我们需要写死服务提供者的IP和端口:

但现在不需要了,我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用:

image-20250410121817031

3.OpenFeign

image-20250410204717449

虽然上面我们可以使用RestTemplate发起远程调用,但还是有些复杂了,可以用更简单的方式实现,那就是OpenFeign。

快速入门

以cart-service为例

1.引入依赖

1
2
3
4
5
6
7
8
9
10
<!--openFeign-->
<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>

2.给启动类添加@EnableFeignClients注解

image-20250410203057493

3.新建一个ItemClient接口

image-20250410203222224

参考所需的item微服务下的api信息

1
2
3
4
5
@ApiOperation("根据id批量查询商品")
@GetMapping
public List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids){
return itemService.queryItemByIds(ids);
}

依照上面请求方式注解和请求参数,添加信息到ItemClient中

1
2
3
4
5
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids);
}

这里只需要声明接口,无需实现方法。接口中的几个关键信息:

  • @FeignClient("item-service") :声明服务名称
  • @GetMapping :声明请求方式
  • @GetMapping("/items") :声明请求路径
  • @RequestParam("ids") Collection<Long> ids :声明请求参数
  • List<ItemDTO> :返回值类型

注意,这里@GetMapping(“/items”)中的items是对应controller的RequestMapping中的参数,别忘了加上。

image-20250410203800827

还有,别忘了在启动类里添加需要的client

1
@EnableFeignClients(clients = {UserClient.class, OrderClient.class}, defaultConfiguration = DefaultFeignConfig.class)

4.使用OpenFeign

在CartServiceImpl中注入ItemClient后直接调用对应的方法即可

image-20250410210633955

如上所示,大大减少了代码的复杂度。

feign替我们完成了服务拉取、负载均衡、发送http请求的所有工作,是不是看起来优雅多了。

而且,这里我们不再需要RestTemplate了,还省去了RestTemplate的注册。

连接池

Feign底层发起http请求时,依赖于其他的框架。其底层支持的http客户端实现包括:

  • HttpURLConnection: 默认实现,不支持连接池
  • Apache HttpClient:支持连接池
  • OKHttp:支持连接池

所以我们需要使用带有连接池的客户端来代替默认的HttpURLConnection。下面以OKHttp为例。

提问:这里的连接池是指什么?

连接池指的是一组预先创建的 HTTP 连接,这些连接可以被重复使用,而不是每次请求都创建一个新的连接。

在 Feign 中,不同的 HTTP 客户端实现对连接池的支持有所不同:

HttpURLConnection:

这是 Java 的默认 HTTP 客户端实现,不支持连接池。每次请求都会创建一个新的连接,请求完成后连接会被关闭。这种方式在高并发场景下性能较差。

Apache HttpClient:

这是一个功能强大的 HTTP 客户端库,支持连接池。通过配置连接池,可以复用连接,提高性能。

OKHttp:

这是另一个流行的 HTTP 客户端库,也支持连接池。OKHttp 的连接池实现高效且易于配置,适合在高并发场景下使用

1.引入依赖

cart-servicepom.xml中引入依赖:

1
2
3
4
5
<!--OK http 的依赖 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>

2.开启连接池

cart-serviceapplication.yml配置文件中开启Feign的连接池功能:

1
2
3
feign:
okhttp:
enabled: true #启用OKHttp连接池

重启服务后,连接池就生效了

公共module抽取

问题引入:当前项目中除了购物车模块需要远程调用商品模块,下单模块也需要,此时,又需要在下单模块里创建ItemClient接口,这不是就相当于重复编码了吗。

避免重复编码的方法就是抽取,抽取一共有以下两种方法:

方法一:抽取到微服务之外的公共module

方法二:每个微服务自己抽取一个module(模块可以被其他服务引入)

两种方法的项目结构图如下所示

image-20250411190709970

方法1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。

方法2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。

因为当前item-service已经创建好了(已经是服务启动类了),无法继续创建模块。所以这里我们采用方法一

针对方法2的思路理清:

方法2本质就是在每个微服务平级旁边再加两个模块,专门用于给其他模块调用的。

针对方法1的耦合度高理解:

方法1因为是一个公共模块,所以所有需要远程调用的服务都需要引入该依赖,导致这一个公共模块包含了很多针对于某一个调用者模块来说多余的dto类和client接口

1.创建模块,导入依赖

这里我已经创建好了,注意以hmall为父工程

image-20250411191431781

创建好模块后导入如下依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependencies>
<!--open feign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- load balancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- swagger 注解依赖 -->
<dependency>
<groupId>io.swagger</groupId>
<artifactId>swagger-annotations</artifactId>
<version>1.6.6</version>
<scope>compile</scope>
</dependency>
</dependencies>

然后,把cart-service中的ItemDTO和ItemClient复制到当前模块中

image-20250411191826445

现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。此时,任何微服务都无需在自己包下面创建所需的其他服务的dto和client了。

2.启动报错

此时我们删除cart-service中的client和dto,并引入这个公共模块,会发现启动后报错了

1
2
3
4
5
<dependency>
<groupId>com.ldy</groupId>
<artifactId>hm-api</artifactId>
<version>1.0.0</version>
</dependency>

image-20250411192641481

这里因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了。

注意:@SpringBootApplication这个注解中包含了@ComponentScan, 这个注解会扫描当前包(com.hmall.cart)中的文件。

3.解决方法

解决方法有两种,都是配置启动类中的**@EnableFeignClients**注解参数:

1.在@EnableFeignClients中添加basePackages参数为client所在位置

image-20250411192959123

2.在@EnableFeignClients中添加clients参数为所需的client.class

image-20250411193127752

日志配置

OpenFeign只会在FeignClient所在包的日志级别为DEBUG时(这里指的是springboot项目的日志级别,也就是说只有当前所在的包的日志级别为DEBUG时,feign才有可能输出日志),才会输出日志(feign的日志)。而且其日志级别有4级:

  • NONE:不记录任何日志信息,这是默认值。
  • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
  • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

Openfegin默认的日志级别是None,所以我们看不到任何连接相关的日志信息

注意:一般也不开启feign的日志配置,只有在需要调试feign的时候才开启日志,因为日志输出的内容有很多,输出时会影响性能。

1.定义日志级别

在hm-api模块下新建一个配置类,定义Feign的日志级别:

image-20250411194425248

注意不要导错包了,是feign.Logger这个包。

2.配置

尽管我们将Logger.Level.FULL这个日志级别引入了容器,但我们还需要配置这个日志级别才能成功输出日志。而配置方式有两种。

  • 局部生效:在某个FeignClient中配置,只对当前FeignClient生效
1
@FeignClient(value = "item-service", configuration = FeignConfig.class.class)
  • 全局生效:在@EnableFeignClients(启动类上)中配置,针对所有FeignClient生效。
1
@EnableFeignClients(defaultConfiguration = FeignConfig.class.class)

日志格式:

1
2
3
4
5
6
7
8
9
10
11
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] ---> GET http://item-service/items?ids=100000006163 HTTP/1.1
17:35:32:148 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] ---> END HTTP (0-byte body)
17:35:32:278 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] <--- HTTP/1.1 200 (127ms)
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] connection: keep-alive
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] content-type: application/json
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] date: Fri, 26 May 2023 09:35:32 GMT
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] keep-alive: timeout=60
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] transfer-encoding: chunked
17:35:32:279 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds]
17:35:32:280 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] [{"id":100000006163,"name":"巴布豆(BOBDOG)柔薄悦动婴儿拉拉裤XXL码80片(15kg以上)","price":67100,"stock":10000,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t23998/350/2363990466/222391/a6e9581d/5b7cba5bN0c18fb4f.jpg!q70.jpg.webp","category":"拉拉裤","brand":"巴布豆","spec":"{}","sold":11,"commentCount":33343434,"isAD":false,"status":2}]
17:35:32:281 DEBUG 18620 --- [nio-8082-exec-1] com.hmall.api.client.ItemClient : [ItemClient#queryItemByIds] <--- END HTTP (369-byte body)

总结

nacos就是一个微服务注册中心,解决了路由写死问题。

而openFeign是一个依赖,利用到了nacos中注册的微服务,方便我们进行远程调用,此外也通过引入负载均衡器依赖实现负载均衡。

二、网关

1.网关路由

网关顾名思义,就是指网络的关口。数据在网络间传输时,从一个网络传输到另一个网络时,需要经过网关来进行安全校验,并做数据的路由和转发。

image-20250413005456114

所以,有了网关之后,前端的请求就不能直接访问相应的微服务,而是需要先请求网关。

  • 网关可以做安全控制,例如登录身份校验,校验通过才放行
  • 通过身份认证后,会根据请求判断应该访问的是哪个微服务,并把请求转发过去。

image-20250413010110735

在SpringCloud当中,提供了两种网关实现方案:

  • Netflix Zuul:早期实现,目前已经淘汰
  • SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强

这里以SpringCloudGateway为例。

1.快速入门

由于网关本身也是个独立的微服务,所以需要新创建一个模块实现功能。大概步骤如下:

  • 创建网关微服务
  • 引入SpringCloudGateway、NacosDiscovery依赖
  • 编写启动类
  • 配置网关路由

1.创建网关模块

image-20250413010441595

2.引入依赖

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
<dependencies>
<!--common-->
<dependency>
<groupId>com.heima</groupId>
<artifactId>hm-common</artifactId>
<version>1.0.0</version>
</dependency>
<!--网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos discovery-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

3.新建启动类

image-20250413010713403

4.配置网关路由

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
server:
port: 8080
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 192.168.10.101:8848
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
- id: cart
uri: lb://cart-service
predicates:
- Path=/carts/**
- id: user
uri: lb://user-service
predicates:
- Path=/users/**,/addresses/**
- id: trade
uri: lb://trade-service
predicates:
- Path=/orders/**
- id: pay
uri: lb://pay-service
predicates:
- Path=/pay-orders/**

5.测试

启动网关微服务(8080端口)和item微服务(8081端口),并访问如下地址

localhost:8080/items/page?pageNo=1&pageSize=1

image-20250413011111028

可以看到,成功访问到了item微服务的接口。

地址转换规则如下

localhost:8080/items/page?pageNo=1&pageSize=1 =》 localhost:8081/items/page?pageNo=1&pageSize=1

可以看到,仅仅是端口号不同,访问的到的内容却是一致的。

主要是因为我们配置文件中的如下内容

1
2
3
4
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则

其中,id只是一个标识符,无实际意义。

2.路由过滤

路由规则的定义语法如下:

1
2
3
4
5
6
7
8
spring:
cloud:
gateway:
routes:
- id: item
uri: lb://item-service
predicates:
- Path=/items/**,/search/**

我们可以查询对应的配置类是如何定义配置信息的

image-20250413012112284

image-20250413012133585

image-20250413012242147

其中,四个属性的详细意义如下

  • id:路由的唯一标示
  • predicates:路由断言,其实就是匹配条件
  • filters:路由过滤条件
  • uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。

对于predicates,也就是路由断言。SpringCloudGateway中支持的断言类型有很多:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些header - Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) - Host=.somehost.org,.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack或者- Query=name
RemoteAddr 请求者的ip必须是指定范围 - RemoteAddr=192.168.1.1/24
weight 权重处理

2.网关登录校验

在单体架构项目里,对于每次请求,我们只需要进行一次用户信息校验,把用户信息存入ThreadLocal中即可实现所有服务在当前线程中均可访问用户信息。而在微服务架构下,微服务之间没有共享数据,意味着每个微服务都要进行登录校验。但假设当前微服务需要远程调用其他微服务,那么势必要将校验信息再次传递过去,类似:微服务1获取token -> 微服务1校验通过,保存token -> 远程调用,传递token -> 微服务2校验通过,保存token -> 远程调用….. 。这么看,还需要保存token,并传递,显然不优雅。

1.鉴权思路分析

我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:

  • 每个微服务都需要知道JWT的秘钥,不安全
  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:

  • 只需要在网关和用户服务保存秘钥
  • 只需要在网关开发登录校验功能

此时,登录校验流程如图

image-20250413014653831

不过,这里存在几个问题:

  • 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
  • 网关校验JWT之后,如何将用户信息传递给微服务?
  • 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?

这些问题在下面内容会解决。

2.网关过滤器

登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。

image-20250413234231290

如图所示:

  1. 客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(**Route**),然后将请求交给WebHandler去处理。
  2. WebHandler则会加载当前路由下需要执行的过滤器链(**Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter**)。
  3. 图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为prepost两部分,分别会在请求路由到微服务之前之后被执行。之前指的是在请求发送到微服务之前处理,而之后指的是对微服务响应的信息进行处理。
  4. 只有所有Filterpre逻辑都依次顺序执行通过后,请求才会被路由到微服务。
  5. 微服务返回结果后,再倒序执行Filterpost逻辑。
  6. 最终把响应结果返回。

如图中所示,最终请求转发是有一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合我们的需求了!

那么,该如何实现一个网关过滤器呢?

网关过滤器链中的过滤器有两种:

  • **GatewayFilter**:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
  • **GlobalFilter**:全局过滤器,作用范围是所有路由,不可配置。

GatewayFilter路由过滤器

Gateway内置的GatewayFilter过滤器使用起来非常简单,无需编码,只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route.

例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。

使用的使用只需要在application.yaml中这样配置:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
filters:
- AddRequestHeader=key, value # 逗号之前是请求头的key,逗号之后是value

如果想要让过滤器作用于所有的路由,则可以这样配置:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
default-filters: # default-filters下的过滤器可以作用于所有路由
- AddRequestHeader=key, value
routes:
- id: test_route
uri: lb://test-service
predicates:
-Path=/test/**

这里我们需要先提一下,filters使用的AddRequestHeader=key, value中,AddRequestHeader是AddRequestHeaderGatewayFilterFacotry去掉GateWayFilterFactory后缀的结果。使用其他过滤器也要去掉相同的后缀。

自定义过滤器GatewayFilter

自定义GatewayFilter不是直接实现GatewayFilter,而是继承AbstractGatewayFilterFactory。最简单的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory {
@Override
public GatewayFilter apply(Object config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取请求
ServerHttpRequest request = exchange.getRequest();
//处理信息略
System.out.println("过滤器执行了");
//放行
return chain.filter(exchange);
}
};
}
}

注意:该类的名称一定要以GatewayFilterFactory为后缀!

然后在yaml配置中这样使用:

1
2
3
4
5
spring:
cloud:
gateway:
default-filters:
- PrintAny # 此处直接以自定义的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
38
39
40
41
42
43
44
45
@Component
public class PrintAnyGatewayFilterFactory // 父类泛型是内部类的Config类型
extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {

@Override
public GatewayFilter apply(Config config) {
// OrderedGatewayFilter是GatewayFilter的子类,包含两个参数:
// - GatewayFilter:过滤器
// - int order值:值越小,过滤器执行优先级越高
return new OrderedGatewayFilter(new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取config值
String a = config.getA();
String b = config.getB();
String c = config.getC();
// 编写过滤器逻辑
System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("c = " + c);
// 放行
return chain.filter(exchange);
}
}, 100);
}

// 自定义配置属性,成员变量名称很重要,下面会用到
@Data
static class Config{
private String a;
private String b;
private String c;
}
// 将变量名称依次返回,顺序很重要,将来读取参数时需要按顺序获取
@Override
public List<String> shortcutFieldOrder() {
return List.of("a", "b", "c");
}
// 返回当前配置类的类型,也就是内部的Config
@Override
public Class<Config> getConfigClass() {
return Config.class;
}

}

然后在yaml文件中使用:

1
2
3
4
5
spring:
cloud:
gateway:
default-filters:
- PrintAny=1,2,3 # 注意,这里多个参数以","隔开,将来会按照shortcutFieldOrder()方法返回的参数顺序依次复制

上面这种配置方式参数必须严格按照shortcutFieldOrder()方法的返回参数名顺序来赋值。

还有一种用法,无需按照这个顺序,就是手动指定参数名:

1
2
3
4
5
6
7
8
9
spring:
cloud:
gateway:
default-filters:
- name: PrintAny
args: # 手动指定参数名,无需按照参数顺序
a: 1
b: 2
c: 3

自定义GlobalFilter

自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 编写过滤器逻辑
System.out.println("未登录,无法访问");
// 放行
// return chain.filter(exchange);

// 拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}

@Override
public int getOrder() {
// 过滤器执行顺序,值越小,优先级越高
return 0;
}
}

3.登录校验

这里我们使用GlobalFiilter来实现登录校验

前提条件:

1.将配置类和工具类信息从hm-service中复制过来

image-20250414003120862

image-20250414003147782

2.复制密钥文件

image-20250414003210845

3.编写代码

登录校验过滤代码:

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
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtTool jwtTool;
private final AuthProperties authProperties;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取请求信息
ServerHttpRequest request = exchange.getRequest();
//2.判断是否需要拦截
if(isExclude(request.getPath().toString())){
//不需要则直接放行
return chain.filter(exchange);
}
//3.获取请求头的token
String token = null;
List<String> headers = request.getHeaders().get("authorization");
if(headers != null && !headers.isEmpty()){
token = headers.get(0);
}
//4.获取用户id
Long userId = null;
try{
//校验token,错误会抛异常
userId = jwtTool.parseToken(token);
}catch(Exception e){
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}
//5.保存用户id信息
System.out.println("用户id:" + userId);
//6.放行
return chain.filter(exchange);
}

@Override
public int getOrder() {
return 0;
}
//判断是否在拦截路径之外
private boolean isExclude(String antPath) {
for (String pathPattern : authProperties.getExcludePaths()) {
if(antPathMatcher.match(pathPattern, antPath)){
return true;
}
}
return false;
}

}

注意,别忘了加上注解@EnableConfigurationProperties(AuthProperties.class)启用相应配置文件类,以及注入相应的所需实例,为避免对每个实例使用@Autowried注入,可以使用@RequiredArgsConstructor注解,这样只需要定义全局常量即可。

4.微服务获取用户信息

1.首先在网关的过滤器中,将用户信息存入请求头传递给下游微服务

image-20250414231001233

注意传递用户信息的api写法,mutate()方法可以对下游请求做更改,.request表示对请求做处理,利用builder可以对请求中各种信息做修改。

2.给每个微服务设置拦截器

拦截器代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UserInfoIterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取用户信息
String userInfo = request.getHeader("user-info");
//2.判断是否为空
if(StrUtil.isNotBlank(userInfo)){
//不为空串则保存到ThreadLocal里
UserContext.setUser(Long.valueOf(userInfo));
}
//3.放行
return true;
}

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

3.拦截器配置类

1
2
3
4
5
6
7
8
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoIterceptor());
}
}

因为每个微服务都需要用户信息,且这里拦截器返回的都是true(网关已经帮我们对需要校验的路径校验好了,所以没必要再次校验了),所以可以设置拦截器和其配置类在公共模块hm-common,供所有微服务注入。

不过,要注意,这个公共模块的配置类默认是扫描不到的。我只知道有两种方法可以设置。

法1.给微服务启动类设置组件扫描路径

image-20250414231646335

这里是以com.hmall为前缀的所有包都会被扫描,而hm-common这个公共模块正好也是com.hmall为前缀,所以导入这个依赖也会扫描相应的组件。

法2.在公共模块设置spring.factories

基于SpringBoot的自动装配原理,我们要将扫描路径添加到公共模块的resources目录下的META-INF/spring.factories文件中:

image-20250414231846835

文件内容:

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

这样每个导入这个模块的微服务都会扫描这个模块下的组件。

5.OpenFeign传递用户

前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。

但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:

image-20250414232306472

下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头

微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?

这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor

1
2
3
4
5
6
7
8
public interface RequestInterceptor {

/**
* Called for every request.
* Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}

我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。

由于FeignClient全部都是在hm-api模块,因此我们在hm-api模块的com.hmall.api.config.DefaultFeignConfig中编写这个拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class DeafualtFeignConfig {
@Bean
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate requestTemplate) {
//1.获取用户信息
Long userId = UserContext.getUser();
//2.判断用户信息是否为空
if(userId == null){
//如果为空则直接跳过
return;
}
//3.不为空,则设置到请求头中,传递给下游微服务
requestTemplate.header("user-info", userId.toString());
}
};
}
}

注意:

1.这里UserContext在common模块,记得引入依赖

2.这里创建配置类后,微服务默认是扫描不到这个配置类的,记得给每个需要OpenFign拦截器的启动类上添加配置类.class到注解属性中

1
@EnableFeignClients(clients = ItemClient.class, defaultConfiguration = DeafualtFeignConfig.class)

3.此外,这里注册RequestInterceptor类型的bean能生效的原因:当你使用 Feign 客户端发起调用时,Feign 会创建一个 RequestTemplate 对象来准备请求。

在请求发送之前,Feign 会遍历所有已注册的 RequestInterceptor 拦截器,并依次调用它们的 apply(RequestTemplate template) 方法。

三、配置管理

到目前为止我们已经解决了微服务相关的几个问题:

  • 微服务远程调用
  • 微服务注册、发现
  • 微服务请求路由、负载均衡
  • 微服务登录用户信息传递

不过,现在依然还有几个问题需要解决:

  • 网关路由在配置文件中写死了,如果变更必须重启微服务
  • 某些业务配置在配置文件中写死了,每次修改都要重启服务
  • 每个微服务都有很多重复的配置,维护成本高

这些问题都可以通过统一的配置管理器服务解决。而Nacos不仅仅具备注册中心功能,也具备配置管理的功能:

image-20250414234305911

微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新

网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。

1.配置共享

我们可以把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:

  • 在Nacos中添加共享配置
  • 微服务拉取配置

添加共享配置

至于具体怎么配置就不进行相关介绍了,具体可以去看相关文档day04-微服务02 - 飞书云文档

拉取共享配置

这里要注意几个问题:

1.Nacos中的配置文件的读取是先于微服务配置文件application.yaml的。所以如果我们把nacos地址写在application.yaml文件中,那么这个微服务启动注册到nacos后也没法读取nacos中的相关配置了。

这里就需要使用一个名为bootstrap.yaml文件。这个配置文件的加载优先于nacos中的配置文件。所以需要把nacos相关配置信息写入这个文件中。

image-20250415183255455

具体步骤如下

(1)给微服务引入相关依赖

注意力,这里的nacos配置依赖和注册不是同一个依赖包

1
2
3
4
5
6
7
8
9
10
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

(2)编写bootstrap.yaml配置文件

image-20250415183333079

如上所示,除了写nacos地址之外,还记得配置微服务名称和要读取的nacos中的配置文件

2.配置文件中的配置为了通用性,防止写死。在写值时可以使用{配置信息路径: 默认值}(例如{hm.db.user:root})。写了这个之后,将来会读取微服务中的配置文件application.yaml中的信息,若存在则赋值,否则使用默认值。

2.配置热更新

配置热更新就是为了防止某些参数写死,导致需要修改时需要重新打包部署。而是通过配置文件的更新,配置类会监听配置信息的变更,实时更新相关参数信息。

具体实习简单来说就是将参数信息配置到nacos中,其他的像是创建配置类、使用配置类都和正常使用没区别。

问题引入

有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,默认是10,对应代码如下:

image-20250415184557786

现在这里购物车是写死的固定值,我们应该将其配置在配置文件中,方便后期修改。

但现在的问题是,即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。能不能不用重启,直接生效呢?

这就要用到Nacos的配置热更新能力了,分为两步:

  • 在Nacos中添加配置
  • 在微服务读取配置

在Nacos中添加配置

在微服务中读取配置

配置信息类

1
2
3
4
5
6
@Component
@ConfigurationProperties(prefix = "hm.cart")
@Data
public class CartProperties {
private String maxCount;
}

image-20250415185145800

3.动态路由

网关的路由配置全部是在项目启动时由org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个Map),不会改变。也不会监听路由变更,所以,我们无法利用配置热更新来实现路由更新。

因此,我们必须监听Nacos的配置变更,然后手动把最新的路由更新到路由表中。这里有两个难点:

  • 如何监听Nacos配置变更?
  • 如何把路由信息更新到路由表?

1.监听Nacos配置变更

在Nacos官网中给出了手动监听Nacos配置变更的SDK:

https://nacos.io/zh-cn/docs/sdk.html

如果希望 Nacos 推送配置变更,可以使用 Nacos 动态监听配置接口来实现。

1
public void addListener(String dataId, String group, Listener listener)

请求参数说明:

参数名 参数类型 描述
dataId string 配置 ID,保证全局唯一性,只允许英文字符和 4 种特殊字符(”.”、”:”、”-“、”_”)。不超过 256 字节。
group string 配置分组,一般是默认的DEFAULT_GROUP。
listener Listener 监听器,配置变更进入监听器的回调函数。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String serverAddr = "{serverAddr}";
String dataId = "{dataId}";
String group = "{group}";
// 1.创建ConfigService,连接Nacos
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
// 2.读取配置
String content = configService.getConfig(dataId, group, 5000);
// 3.添加配置监听器
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
// 配置变更的通知处理
System.out.println("recieve1:" + configInfo);
}
@Override
public Executor getExecutor() {
return null;
}
});

这里核心的步骤有2步:

  • 创建ConfigService,目的是连接到Nacos
  • 添加配置监听器,编写配置变更的通知处理逻辑

由于我们采用了spring-cloud-starter-alibaba-nacos-config自动装配,因此ConfigService已经在com.alibaba.cloud.nacos.NacosConfigAutoConfiguration中自动创建好了:

image-20250415213319784

NacosConfigManager中是负责管理Nacos的ConfigService的,具体代码如下:

image-20250415213403322

因此,只要我们拿到NacosConfigManager就等于拿到了ConfigService,第一步就实现了。

第二步,编写监听器。虽然官方提供的SDK是ConfigService中的addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API是这个:

1
2
3
4
5
6
String getConfigAndSignListener(
String dataId, // 配置文件id
String group, // 配置组,走默认
long timeoutMs, // 读取配置的超时时间
Listener listener // 监听器
) throws NacosException;

既可以配置监听器,并且会根据dataId和group读取配置并返回。我们就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。

2.更新路由

更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter这个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.springframework.cloud.gateway.route;

import reactor.core.publisher.Mono;

/**
* @author Spencer Gibb
*/
public interface RouteDefinitionWriter {
/**
* 更新路由到路由表,如果路由id重复,则会覆盖旧的路由
*/
Mono<Void> save(Mono<RouteDefinition> route);
/**
* 根据路由id删除某个路由
*/
Mono<Void> delete(Mono<String> routeId);

}

这里更新的路由,也就是RouteDefinition,之前我们见过,包含下列常见字段:

  • id:路由id
  • predicates:路由匹配规则
  • filters:路由过滤器
  • uri:路由目的地

将来我们保存到Nacos的配置也要符合这个对象结构,将来我们以JSON来保存,格式如下:

1
2
3
4
5
6
7
8
9
{
"id": "item",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
}],
"filters": [],
"uri": "lb://item-service"
}

以上JSON配置就等同于:

1
2
3
4
5
6
7
8
spring:
cloud:
gateway:
routes:
- id: item
uri: lb://item-service
predicates:
- Path=/items/**,/search/**

之所以使用Json格式存储路由是因为Json有现成的工具类可以转换成RouteDefinition这个类,而yaml格式文件则不好转换。

3.实现动态路由

1.引入依赖

1
2
3
4
5
6
7
8
9
10
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--加载bootstrap-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

2.编写bootstrap.yml

1
2
3
4
5
6
7
8
9
10
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 192.168.10.101
config:
file-extension: yaml
shared-configs:
- dataId: shared-log.yaml # 共享日志配置

3.创建动态路由类

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
@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {

private final RouteDefinitionWriter writer;
private final NacosConfigManager nacosConfigManager;

// 路由配置文件的id和分组
private final String dataId = "gateway-routes.json";
private final String group = "DEFAULT_GROUP";
// 保存更新过的路由id
private final Set<String> routeIds = new HashSet<>();

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

@Override
public void receiveConfigInfo(String configInfo) {
updateConfigInfo(configInfo);
}
});
// 2.首次启动时,更新一次配置
updateConfigInfo(configInfo);
}

private void updateConfigInfo(String configInfo) {
log.debug("监听到路由配置变更,{}", configInfo);
// 1.反序列化
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 2.更新前先清空旧路由
// 2.1.清除旧路由
for (String routeId : routeIds) {
writer.delete(Mono.just(routeId)).subscribe();
}
routeIds.clear();
// 2.2.判断是否有新的路由要更新
if (CollUtils.isEmpty(routeDefinitions)) {
// 无新路由配置,直接结束
return;
}
// 3.更新路由
routeDefinitions.forEach(routeDefinition -> {
// 3.1.更新路由
writer.save(Mono.just(routeDefinition)).subscribe();
// 3.2.记录路由id,方便将来删除
routeIds.add(routeDefinition.getId());
});
}
}

其中引入的如下两个类,write用于保存路由信息,nacosConfigManager用于获取拉取配置并设置监听器。

1
2
private final RouteDefinitionWriter writer;
private final NacosConfigManager nacosConfigManager;

四、微服务保护

问题引入

1.业务健壮性问题

当微服务之间相互调用时,若某个调用的微服务发生故障,那么整体业务就失败了。但从业务角度来说,即使某些情况下失败,依然需要展示其余某些数据。

拿购物车举例,在获取购物车列表时,还会微服务远程调用获取商品最新的价格是,而就算获取最新商品价格失败,也不应该整个业务失败,毕竟我看的是购物车,不是最新价格。

2.级联问题

当前微服务并发较高,又需要远程调用其他微服务,而当被调用微服务阻塞时,会导致当前微服务也阻塞。这种因为某个接口占用资源过多,导致多个微服务的受到影响的情况就是级联问题。

依旧以购物车为例,查询购物车时,会查询当前购物车中所有商品的最新价格,当商品微服务响应时间过长时,购物车微服务也会受到影响,导致购物车Tomcat连接占用较多,所有接口的响应时间都会增加。

依次类推,整个微服务群中与购物车服务、商品服务等有调用关系的服务可能都会出现问题,最终导致整个集群不可用。

image-20250415225307819

这就叫级联失败问题,或者说雪崩问题

服务保护方案

保证服务运行的健壮性,避免级联失败导致的雪崩问题,就属于微服务保护。

以下是三种保护微服务的方法

  • 请求限流
  • 线程隔离
  • 服务熔断

请求限流

请求限流就是处理短时间大量请求打过来的问题。我们可以设个请求阈值在接口处,每秒最多可以处理多少请求,超过这个数的后面要么请求失败要么排队等待。

image-20250415225901562

线程隔离

给每个接口设置最大可以占用的线程数,以保证当前接口即使故障也不会占用当前服务的所有线程资源,以避免雪崩问题。

服务熔断

服务熔断主要是处理业务健壮性问题,即当前微服务调用的其他微服务发生异常时,走相应的降级方案

image-20250416003526607

所以,我们要做两件事情:

  • 编写服务降级逻辑:就是服务调用失败后的处理逻辑,根据业务场景,可以抛出异常,也可以返回友好提示或默认数据。
  • 异常统计和熔断:统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑。

Sentinel

1.安装和使用

Sentinel是阿里巴巴开源的一款服务保护框架,目前已经加入SpringCloudAlibaba中。官方网站:

https://sentinelguard.io/zh-cn/

Sentinel 的使用可以分为两个部分:

  • 核心库(Jar包):不依赖任何框架/库,能够运行于 Java 8 及以上的版本的运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。在项目中引入依赖即可实现服务限流、隔离、熔断等功能。
  • 控制台(Dashboard):Dashboard 主要负责管理推送规则、监控、管理机器信息等。

为了方便监控微服务,我们先把Sentinel的控制台搭建出来。

1)下载jar包

下载地址:

https://github.com/alibaba/Sentinel/releases

也可以直接使用黑马资料中提供的版本:

image-20250416003758078

2)运行

将jar包放在任意非中文、不包含特殊字符的目录下,重命名为sentinel-dashboard.jar

打开终端输入如下命令即可启动成功

java '-Dserver.port=8090' '-Dcsp.sentinel.dashboard.server=[localhost:8090](http://localhost:8090)' -jar sentinel-dashboard.jar

image-20250416004139210

此时访问localhost:8090可看到sentinel的控制台

登录的账号密码默认都是sentinel

2.微服务整合

(1)引入sentinel依赖

1
2
3
4
5
<!--sentinel-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

(2)配置控制台

在application.yml文件添加如下内容

1
2
3
4
5
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090

(3)访问cart-service的任意端点

重启cart-service,然后访问查询购物车接口,sentinel的客户端就会将服务访问的信息提交到sentinel-dashboard控制台。并展示出统计信息:

image-20250416004756912

点击簇点链路菜单,会看到下面的页面:

image-20250416004905066

所谓簇点链路,就是单机调用链路,是一次请求进入服务后经过的每一个被Sentinel监控的资源。默认情况下,Sentinel会监控SpringMVC的每一个Endpoint(接口)。

因此,我们看到/carts这个接口路径就是其中一个簇点,我们可以对其进行限流、熔断、隔离等保护措施。

不过,需要注意的是,我们的SpringMVC接口是按照Restful风格设计,因此购物车的查询、删除、修改等接口全部都是/carts路径:

image-20250416005004505

默认情况下Sentinel会把路径作为簇点资源的名称,无法区分路径相同但请求方式不同的接口,查询、删除、修改等都被识别为一个簇点资源,这显然是不合适的。

所以我们可以选择打开Sentinel的请求方式前缀,把请求方式 + 请求路径作为簇点资源名:

首先,在cart-serviceapplication.yml中添加下面的配置:

1
2
3
4
5
6
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090
http-method-specify: true # 开启请求方式前缀

然后,重启服务,通过页面访问购物车的相关接口,可以看到sentinel控制台的簇点链路发生了变化:

image-20250416005226598

即使路径相同,也会有请求方式前缀做区分。

实操

请求限流

在簇点链路页面点击流控即可做限流配置

image-20250416005402826

QPS:Queries Per Second是衡量信息检索系统(例如搜索引擎或数据库)在一秒钟内接收到的搜索流量的一种常见度量。该术语在任何请求-响应系统中都得到更广泛的使用,更正确地称为每秒请求数(RPS:Request Per Second)。

线程隔离

限流可以降低服务器压力,尽量减少因并发流量引起的服务故障的概率,但并不能完全避免服务故障。一旦某个服务出现故障,我们必须隔离对这个服务的调用,避免发生雪崩。

比如,查询购物车的时候需要查询商品,为了避免因商品服务出现故障导致购物车服务级联失败,我们可以把购物车业务中查询商品的部分隔离起来,限制可用的线程资源。这样,即便商品服务出现故障,最多导致查询购物车业务故障,并且可用的线程资源也被限定在一定范围,不会导致整个购物车服务崩溃。

1.OpenFeign整合Sentinel

(1)修改cart-service模块的application.yml文件,开启Feign的sentinel功能:

1
2
3
feign:
sentinel:
enabled: true # 开启feign对sentinel的支持

尽管开启了Sentinel功能,但我们还需要配置最大排队数量等参数,因为不是说超了当前QPS的部分就一定拒绝请求了,依然可以排队等待处理。

(2)配置一下cart-service模块的application.yml文件,修改tomcat连接:

1
2
3
4
5
6
7
server:
port: 8082
tomcat:
threads:
max: 50 # 允许的最大线程数
accept-count: 50 # 最大排队等待数量
max-connections: 100 # 允许的最大连接

注意,默认情况下SpringBoot项目的tomcat最大线程数是200,允许的最大连接是8492,单机测试很难打满。

然后重启cart-service服务,可以看到查询商品的FeignClient自动变成了一个簇点资源:

image-20250416010616317

2.配置线程隔离

点击流控,选择并发线程数即可。

image-20250416010648431

假设当线程每秒可以处理2个请求,那么5个线程最多每秒可以处理10个请求,即10QPS。而超出的请求自然会被拒绝。

服务熔断

对于上面的线程隔离还是存在几个问题。

第一,超出的QPS上限的请求就只能抛出异常,从而导致购物车的查询失败。但从业务角度来说,即便没有查询到最新的商品信息,购物车也应该展示给用户,用户体验更好。也就是给查询失败设置一个降级处理逻辑。

第二,由于查询商品的延迟较高(模拟的500ms),从而导致查询购物车的响应时间也变的很长。这样不仅拖慢了购物车服务,消耗了购物车服务的更多资源,而且用户体验也很差。对于商品服务这种不太健康的接口,我们应该直接停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断

1.编写降级逻辑

触发限流或熔断后的请求不一定要直接报错,也可以返回一些默认数据或者友好提示,用户体验会更好。

给FeignClient编写失败后的降级逻辑有两种方式:

  • 方式一:FallbackClass,无法对远程调用的异常做处理
  • 方式二:FallbackFactory,可以对远程调用的异常做处理,我们一般选择这种方式。

这里以方式二为例:

(1)在hm-api模块新建一个包存放各个降级处理类

image-20250416100536500

如图所示,其实很简单,就是实现FallbackFactory<>这个类,泛型里填写要降级处理的Client接口。此时会让你实现create方法,在方法里直接return 相应的匿名内部类

匿名内部类是什么?

匿名:没有名字的意思。内部类:写在其他类内部的类。匿名内部类的作用是简化代码。

原本我们需要创建子类或者实现类,去继承父类和实现接口,才能重写其中的方法。但是有时候我们这样做了,然而子类和实现类却只需要使用一次(定义了一个对象)。这个时候我们就可以使用匿名内部类,不用去写子类和实现类,起到简化代码的作用。
代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
public class ItemClientFallback implements FallbackFactory<ItemClient> {

@Override
public ItemClient create(Throwable cause) {
return new ItemClient() {
@Override
public List<ItemDTO> queryItemByIds(Collection<Long> ids) {
log.error("远程调用ItemClient#queryItemByIds方法出现异常,参数{}", ids, cause);
//返回空集合
return CollUtils.emptyList();
}
@Override
public void deductStock(List<OrderDetailDTO> items) {
//库存扣减失败就是失败了,需要事务回滚
throw new BizIllegalException(cause);
}
};
}
}

(2)将降级处理类注册为一个Bean

image-20250416101626684

(3)在Client接口的@FeignClient注解添加fallbackFactory属性,引入降级处理类

image-20250416102007002

2.服务熔断

现在已经实现了降级逻辑,但有的时候某些接口可能频繁或者一直有问题,这个时候就没必要去访问这些微服务接口了,直接在发请求时就熔断,走降级逻辑,以减少不必要的请求时长。

这就需要我们使用Sentinel的断路器来设置熔断阈值。

Sentinel中的断路器不仅可以统计某个接口的慢请求比例,还可以统计异常请求比例。当这些比例超出阈值时,就会熔断该接口,即拦截访问该接口的一切请求,降级处理;当该接口恢复正常时,再放行对于该接口的请求。

断路器的工作状态切换有一个状态机来控制:

image-20250416102833959

状态机包括三个状态:

  • closed:关闭状态,断路器放行所有请求,并开始统计异常比例、慢请求比例。超过阈值则切换到open状态
  • open:打开状态,服务调用被熔断,访问被熔断服务的请求会被拒绝,快速失败,直接走降级逻辑。Open状态持续一段时间后会进入half-open状态
  • half-open:半开状态,放行一次请求,根据执行结果来判断接下来的操作。
    • 请求成功:则切换到closed状态
    • 请求失败:则切换到open状态

同样,在簇点链路中点击熔断按钮

image-20250416103725143

图中所示是慢调用比例的熔断策略。对应的参数详解如下所示:

最大RT:响应时长超过最大RT的值就是慢调用。

熔断时长:熔断的持续时长

统计时长:多长时间统计一次

比例阈值:慢调用的比例

最小请求数:在统计时长内最少统计5个请求计算比例阈值来判断是否需要熔断

五、分布式事务

分布式事务,顾名思义就是处理分布式情况下的事务问题。

以下单业务为例

image-20250416104416162

由于订单、购物车、商品分别在三个不同的微服务,而每个微服务都有自己独立的数据库,因此下单过程中就会跨多个数据库完成业务。而每个微服务都会执行自己的本地事务:

  • 交易服务:下单事务
  • 购物车服务:清理购物车事务
  • 库存服务:扣减库存事务

整个业务中,各个本地事务是有关联的。因此每个微服务的本地事务,也可以称为分支事务。多个有关联的分支事务一起就组成了全局事务。我们必须保证整个全局事务同时成功或失败。

我们知道每一个分支事务就是传统的单体事务,都可以满足ACID特性,但全局事务跨越多个服务、多个数据库,是否还能满足呢?

ACID特性:

原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败,不会出现部分执行的情况

一致性(Consistency):事务执行前后,数据库从一个一致的状态转换到另一个一致的状态,不会出现数据违反完整性约束(例如银行扣钱不能扣成负数)的情况

隔离性(Isolation):多个事务并发执行时,每个事务都像是在独立的环境中运行,一个事务的执行不会被其他事务干扰。隔离有四个级别,最高级别时串行化(频繁的加锁)

持久性(Durability):事务一旦提交,其对数据库的改变就是永久性的,即使系统发生故障也不会丢失

1.Seata

解决分布式事务的方案有很多,但实现起来都比较复杂,因此我们一般会使用开源的框架来解决分布式事务问题。在众多的开源分布式事务框架中,功能最完善、使用最多的就是阿里巴巴在2019年开源的Seata了。

https://seata.io/zh-cn/docs/overview/what-is-seata.html

其实分布式事务产生的一个重要原因,就是参与事务的多个分支事务互相无感知,不知道彼此的执行状态。因此解决分布式事务的思想非常简单:

就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。

Seata也不例外,在Seata的事务管理中有三个重要的角色:

  • TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
  • TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - 资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

seata架构图

image-20250416112026023

其中,TMRM可以理解为Seata的客户端部分,引入到参与事务的微服务依赖中即可。将来TMRM就会协助微服务,实现本地分支事务与TC之间交互,实现事务的提交或回滚。

TC服务则是事务协调中心,是一个独立的微服务,需要单独部署。

(1)部署TC服务

1.准备seata数据库表并导入到数据库中

image-20250416152012466

执行该sql后的数据库结构

image-20250416152051837

2.将seata文件夹拷贝到虚拟机的/root目录下

image-20250416152239331

image-20250416152235697

3.docker部署

在虚拟机/root目录下执行如下命令

1
2
3
4
5
6
7
8
9
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.150.101 \
-v ./seata:/seata-server/resources \
--privileged=true \
--network hm-net \
-d \
seataio/seata-server:1.5.2

注意把SEATA_IP换成你的虚拟机ip以及加入和mysql、nacos同一个的网络中。

(2)微服务集成Seata

1.引入相应依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

因为我们需要让seata相关配置可以热更新,所以除了seata依赖外,还需要引入nacos统一配置管理和bootstrap依赖。

共享的seata配置信息如下

image-20250416153948770

2.bootstrap.yml文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
application:
name: trade-service # 服务名称
profiles:
active: local
cloud:
nacos:
server-addr: 192.168.10.101 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
- dataId: cart-service.yaml # cart参数配置
- dataId: shared-seata.yaml # seata配置

3.给每个引入seata的微服务所连接的数据库创建undo_log表

seata的客户端在解决分布式事务的时候需要记录一些中间数据,保存在数据库中。因此我们要先准备一个这样的表。

image-20250416155701064

如此,我们的分布式事务实现所需的所有环境都以及配好了。

(3)测试

使用分布式事务需要使用@GlobalTransactional注解

@GlobalTransactional注解就是在标记事务的起点,将来TM就会基于这个方法判断全局事务范围,初始化全局事务。

所以我们只需要在方法起始入口将@Transactional注解修改为@GlobalTransactional即可。

image-20250416175056045

我们重启trade-serviceitem-servicecart-service三个服务。再次测试,发现分布式事务的问题解决了!

2.分布式事务的解决方案

Seata可以解决分布式事务问题主要是因为它支持四种分布式事务解决方案。

  • XA
  • TCC
  • AT
  • SAGA

这里介绍XA模式和AT模式

1.XA模式

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

(1)两阶段提交

A是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。

正常情况下:

image-20250416180420469

异常情况下:

image-20250416180450436

一阶段:

  • 事务协调者(TC)通知每个事务参与者执行本地事务
  • 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交继续持有数据库锁

二阶段:

  • 事务协调者(TC)基于一阶段的报告来判断下一步操作
  • 如果一阶段都成功,则通知所有事务参与者,提交事务
  • 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务

(2)Seata的XA模型

Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:

image-20250416182627127

如图所示,可知

RM一阶段的工作:

  1. 注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段的工作:

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

RM二阶段的工作:

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

(3)优缺点

XA模式的优点是什么?

  • 事务的强一致性,满足ACID原则
  • 常用数据库都支持,实现简单,并且没有代码入侵

XA模式的缺点是什么?

  • 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
  • 依赖关系型数据库实现事务

(4)实现步骤

要将Seata的默认模式(AT)改为XA模式可以在配置文件中指定要使用的模式。

image-20250416183654161

2.AT模式

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

(1)Seata的AT模型

基本流程图:

image-20250416183855259

阶段一RM的工作:

  • 注册分支事务
  • 记录undo-log(数据快照)
  • 执行业务sql并提交
  • 报告事务状态

阶段二提交时RM的工作:

  • 删除undo-log即可

阶段二回滚时RM的工作:

  • 根据undo-log恢复数据到更新前

(2)流程梳理

我们用一个真实的业务来梳理下AT模式的原理。

比如,现在有一个数据库表,记录用户余额:

id money
1 100

其中一个分支业务要执行的SQL为:

1
update tb_account set money = money - 10 where id = 1

AT模式下,当前分支事务执行流程如下:

一阶段

  1. TM发起并注册全局事务到TC
  2. TM调用分支事务
  3. 分支事务准备执行业务SQL
  4. RM拦截业务SQL,根据where条件查询原始数据,形成快照。
1
2
3
{
"id": 1, "money": 100
}
  1. RM执行业务SQL,提交本地事务,释放数据库锁。此时 money = 90
  2. RM报告本地事务状态给TC

二阶段

  1. TM通知TC事务结束
  2. TC检查分支事务状态
    1. 如果都成功,则立即删除快照
    2. 如果有分支事务失败,需要回滚。读取快照数据({“id”: 1, “money”: 100}),将快照恢复到数据库。此时数据库再次恢复为100

流程图:

image-20250416184528983

AT与XA的区别

简述AT模式与XA模式最大的区别是什么?

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

可见,AT模式使用起来更加简单,无业务侵入,性能更好(一阶段过后其他线程也可以进入一阶段)。因此企业90%的分布式事务都可以用AT模式来解决。

提问

AT模式是否会出现脏写问题

脏写问题:指的是事务1对某个数据项进行了修改,但尚未提交(即事务1的修改处于未完成状态)。此时,事务2读取了事务1修改后的数据,并基于这个数据进行了进一步的修改。如果事务1最终回滚,那么事务1的修改会被撤销,但事务2已经基于事务1的中间状态进行了修改,这会导致数据库中出现不一致的状态。

脏写问题的关键在于事务1的未提交修改被事务2依赖,而事务1的回滚会导致数据不一致。

例子

假设有一个账户余额表,初始余额为100元。

  • 事务1:将余额从100元修改为150元(未提交)。
  • 事务2:读取了150元的余额,并将其修改为200元(提交)。
  • 事务1:回滚,余额恢复为100元。

此时,事务2的修改(200元)是基于事务1的中间状态(150元),而事务1回滚后,余额又变成了100元。这就导致了数据不一致,因为事务2的修改没有考虑到事务1的回滚操作。

答:不会,如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。因为整个过程全局锁在tx1结束前一直是被 tx1持有的,所以不会发生脏写的问题。