微服务总结
一、服务调用
服务拆分之后,不可避免的会出现跨微服务的业务,此时微服务之间就需要进行远程调用。微服务之间的远程调用被称为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
利用RestTemplate发送http请求与前端ajax发送请求非常相似,包含以下信息:
- 1 请求路径
- 2 请求方式
- 3 请求体
- 4 返回值类型
- 5 请求参数
案例代码
1 | // 2.查询商品 |
代码详解
- 第四个参数中为什么不能直接写
List<ItemDTO>.class
?
在 Java 中,泛型类型在运行时会被擦除(即类型信息丢失)。这意味着 List<ItemDTO>
在运行时会被当作普通的 List
处理,而 ItemDTO
的类型信息会被丢弃。因此,List<ItemDTO>.class
是不合法的,因为 Java 无法在运行时识别 List<ItemDTO>
的具体类型。
ParameterizedTypeReference
的作用
ParameterizedTypeReference
是 Spring 提供的一个工具类,用于显式地指定泛型类型。它通过匿名内部类的方式保留了泛型类型信息,使得 Spring 的 RestTemplate
可以正确解析返回值。
1 | new ParameterizedTypeReference<List<ItemDTO>>() {} |
这行代码告诉 RestTemplate
,返回值是一个 List<ItemDTO>
类型的对象。
- 如果直接使用
List.class
会发生什么?
如果你直接使用 List.class
,代码可能如下:
1 | ResponseEntity<List> response = restTemplate.exchange( |
这种情况下,RestTemplate
只知道返回值是一个 List
,但不知道 List
中的元素类型是 ItemDTO
。因此,RestTemplate
无法正确解析返回值为 List<ItemDTO>
,可能会导致运行时错误。
- 为什么需要
ParameterizedTypeReference
?
ParameterizedTypeReference
的设计正是为了解决泛型擦除的问题。它允许你在运行时保留泛型类型信息,使得 RestTemplate
能够正确解析返回值。
2.注册中心
问题引入
假设当前微服务面对高并发的调用请求,压力大。这时我们需要部署多个服务实例。
此时,每个item-service
的实例其IP或端口不同,那么问题来了:
- item-service这么多实例,cart-service如何知道每一个实例的地址?
- http请求要写url地址,
cart-service
服务到底该调用哪个实例呢? - 如果在运行过程中,某一个
item-service
实例宕机,cart-service
依然在调用该怎么办? - 如果并发太高,
item-service
临时多部署了N台实例,cart-service
如何知道新实例的地址?
为了解决上述问题,就必须引入注册中心。
注册中心原理
在微服务远程调用的过程中,包括两个角色:
- 服务提供者:提供接口供其它微服务访问,比如
item-service
- 服务消费者:调用其它微服务提供的接口,比如
cart-service
在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:
流程如下:
- 服务启动时服务提供者就会注册自己的服务信息(服务名、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 | docker run -d \ |
启动完成后,访问下面地址:http://192.168.10.101:8848/nacos,注意将`192.168.10.101`替换为你自己的虚拟机IP地址。
首次访问会跳转到登录页,账号密码都是nacos
服务注册
接下来,我们把item-service
注册到Nacos,步骤如下:
- 引入依赖
- 配置Nacos地址
- 重启
1.添加依赖
在item-service
的pom.xml
中添加依赖:
1 | <!--nacos 服务注册发现--> |
2.配置Nacos
在item-service
的application.yml
中添加nacos地址配置:
1 | spring: |
3.启动服务实例
为了测试一个服务多个实例的情况,我们再配置一个item-service
的部署实例:
然后配置启动项,注意重命名并且配置新的端口,避免冲突:
重启item-service
的两个实例:
访问nacos控制台,可以发现服务注册成功:
点击详情,可以查看到item-service
服务的两个实例信息:
服务发现
服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:
- 引入依赖
- 配置Nacos地址
- 发现并调用服务
1.引入依赖
服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。
我们在cart-service
中的pom.xml
中添加下面的依赖:
1 | <!--nacos 服务注册发现--> |
可以发现,这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者。
因此,等一会儿cart-service
启动,同样会注册到Nacos
2.配置Nacos地址
在cart-service
的application.yml
中添加nacos地址配置:
1 | spring: |
3.发现并调用服务
接下来,服务调用者cart-service
就可以去订阅item-service
服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。
因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:
- 随机
- 轮询
- IP的hash
- 最近最少访问
- …
这里我们可以选择最简单的随机负载均衡。
另外,服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用:
接下来,我们就可以对原来的远程调用做修改了,之前调用时我们需要写死服务提供者的IP和端口:
但现在不需要了,我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用:
3.OpenFeign
虽然上面我们可以使用RestTemplate发起远程调用,但还是有些复杂了,可以用更简单的方式实现,那就是OpenFeign。
快速入门
以cart-service为例
1.引入依赖
1 | <!--openFeign--> |
2.给启动类添加@EnableFeignClients注解
3.新建一个ItemClient接口
参考所需的item微服务下的api信息
1 |
|
依照上面请求方式注解和请求参数,添加信息到ItemClient中
1 |
|
这里只需要声明接口,无需实现方法。接口中的几个关键信息:
@FeignClient("item-service")
:声明服务名称@GetMapping
:声明请求方式@GetMapping("/items")
:声明请求路径@RequestParam("ids") Collection<Long> ids
:声明请求参数List<ItemDTO>
:返回值类型
注意,这里@GetMapping(“/items”)中的items是对应controller的RequestMapping中的参数,别忘了加上。
还有,别忘了在启动类里添加需要的client
1 |
4.使用OpenFeign
在CartServiceImpl中注入ItemClient后直接调用对应的方法即可
如上所示,大大减少了代码的复杂度。
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-service
的pom.xml
中引入依赖:
1 | <!--OK http 的依赖 --> |
2.开启连接池
在cart-service
的application.yml
配置文件中开启Feign的连接池功能:
1 | feign: |
重启服务后,连接池就生效了
公共module抽取
问题引入:当前项目中除了购物车模块需要远程调用商品模块,下单模块也需要,此时,又需要在下单模块里创建ItemClient接口,这不是就相当于重复编码了吗。
避免重复编码的方法就是抽取,抽取一共有以下两种方法:
方法一:抽取到微服务之外的公共module
方法二:每个微服务自己抽取一个module(模块可以被其他服务引入)
两种方法的项目结构图如下所示
方法1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。
方法2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。
因为当前item-service已经创建好了(已经是服务启动类了),无法继续创建模块。所以这里我们采用方法一
针对方法2的思路理清:
方法2本质就是在每个微服务平级旁边再加两个模块,专门用于给其他模块调用的。
针对方法1的耦合度高理解:
方法1因为是一个公共模块,所以所有需要远程调用的服务都需要引入该依赖,导致这一个公共模块包含了很多针对于某一个调用者模块来说多余的dto类和client接口。
1.创建模块,导入依赖
这里我已经创建好了,注意以hmall为父工程
创建好模块后导入如下依赖
1 | <dependencies> |
然后,把cart-service中的ItemDTO和ItemClient复制到当前模块中
现在,任何微服务要调用item-service
中的接口,只需要引入hm-api
模块依赖即可,无需自己编写Feign客户端了。此时,任何微服务都无需在自己包下面创建所需的其他服务的dto和client了。
2.启动报错
此时我们删除cart-service中的client和dto,并引入这个公共模块,会发现启动后报错了
1 | <dependency> |
这里因为ItemClient
现在定义到了com.hmall.api.client
包下,而cart-service的启动类定义在com.hmall.cart
包下,扫描不到ItemClient
,所以报错了。
注意:@SpringBootApplication这个注解中包含了@ComponentScan, 这个注解会扫描当前包(com.hmall.cart)中的文件。
3.解决方法
解决方法有两种,都是配置启动类中的**@EnableFeignClients**注解参数:
1.在@EnableFeignClients中添加basePackages参数为client所在位置
2.在@EnableFeignClients中添加clients参数为所需的client.class
日志配置
OpenFeign只会在FeignClient所在包的日志级别为DEBUG时(这里指的是springboot项目的日志级别,也就是说只有当前所在的包的日志级别为DEBUG时,feign才有可能输出日志),才会输出日志(feign的日志)。而且其日志级别有4级:
- NONE:不记录任何日志信息,这是默认值。
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
Openfegin默认的日志级别是None,所以我们看不到任何连接相关的日志信息
注意:一般也不开启feign的日志配置,只有在需要调试feign的时候才开启日志,因为日志输出的内容有很多,输出时会影响性能。
1.定义日志级别
在hm-api模块下新建一个配置类,定义Feign的日志级别:
注意不要导错包了,是feign.Logger
这个包。
2.配置
尽管我们将Logger.Level.FULL这个日志级别引入了容器,但我们还需要配置这个日志级别才能成功输出日志。而配置方式有两种。
- 局部生效:在某个
FeignClient
中配置,只对当前FeignClient
生效
1 |
- 全局生效:在
@EnableFeignClients
(启动类上)中配置,针对所有FeignClient
生效。
1 |
日志格式:
1 | 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 |
总结
nacos就是一个微服务注册中心,解决了路由写死问题。
而openFeign是一个依赖,利用到了nacos中注册的微服务,方便我们进行远程调用,此外也通过引入负载均衡器依赖实现负载均衡。
二、网关
1.网关路由
网关顾名思义,就是指网络的关口。数据在网络间传输时,从一个网络传输到另一个网络时,需要经过网关来进行安全校验,并做数据的路由和转发。
所以,有了网关之后,前端的请求就不能直接访问相应的微服务,而是需要先请求网关。
- 网关可以做安全控制,例如登录身份校验,校验通过才放行
- 通过身份认证后,会根据请求判断应该访问的是哪个微服务,并把请求转发过去。
在SpringCloud当中,提供了两种网关实现方案:
- Netflix Zuul:早期实现,目前已经淘汰
- SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强
这里以SpringCloudGateway为例。
1.快速入门
由于网关本身也是个独立的微服务,所以需要新创建一个模块实现功能。大概步骤如下:
- 创建网关微服务
- 引入SpringCloudGateway、NacosDiscovery依赖
- 编写启动类
- 配置网关路由
1.创建网关模块
2.引入依赖
1 | <dependencies> |
3.新建启动类
4.配置网关路由
1 | server: |
5.测试
启动网关微服务(8080端口)和item微服务(8081端口),并访问如下地址
localhost:8080/items/page?pageNo=1&pageSize=1
可以看到,成功访问到了item微服务的接口。
地址转换规则如下
localhost:8080/items/page?pageNo=1&pageSize=1 =》 localhost:8081/items/page?pageNo=1&pageSize=1
可以看到,仅仅是端口号不同,访问的到的内容却是一致的。
主要是因为我们配置文件中的如下内容
1 | - id: item # 路由规则id,自定义,唯一 |
其中,id只是一个标识符,无实际意义。
2.路由过滤
路由规则的定义语法如下:
1 | spring: |
我们可以查询对应的配置类是如何定义配置信息的
其中,四个属性的详细意义如下
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的秘钥,不安全
- 每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发登录校验功能
此时,登录校验流程如图
不过,这里存在几个问题:
- 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
- 网关校验JWT之后,如何将用户信息传递给微服务?
- 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
这些问题在下面内容会解决。
2.网关过滤器
登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway
内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway
内部工作的基本原理。
如图所示:
- 客户端请求进入网关后由
HandlerMapping
对请求做判断,找到与当前请求匹配的路由规则(**Route
**),然后将请求交给WebHandler
去处理。 WebHandler
则会加载当前路由下需要执行的过滤器链(**Filter chain
),然后按照顺序逐一执行过滤器(后面称为Filter
**)。- 图中
Filter
被虚线分为左右两部分,是因为Filter
内部的逻辑分为pre
和post
两部分,分别会在请求路由到微服务之前和之后被执行。之前指的是在请求发送到微服务之前处理,而之后指的是对微服务响应的信息进行处理。 - 只有所有
Filter
的pre
逻辑都依次顺序执行通过后,请求才会被路由到微服务。 - 微服务返回结果后,再倒序执行
Filter
的post
逻辑。 - 最终把响应结果返回。
如图中所示,最终请求转发是有一个名为NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter
之前,这就符合我们的需求了!
那么,该如何实现一个网关过滤器呢?
网关过滤器链中的过滤器有两种:
- **
GatewayFilter
**:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
. - **
GlobalFilter
**:全局过滤器,作用范围是所有路由,不可配置。
GatewayFilter路由过滤器
Gateway
内置的GatewayFilter
过滤器使用起来非常简单,无需编码,只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route
下,就作用于哪个Route
.
例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry
,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。
使用的使用只需要在application.yaml中这样配置:
1 | spring: |
如果想要让过滤器作用于所有的路由,则可以这样配置:
1 | spring: |
这里我们需要先提一下,filters使用的AddRequestHeader=key, value
中,AddRequestHeader是AddRequestHeaderGatewayFilterFacotry去掉GateWayFilterFactory后缀的结果。使用其他过滤器也要去掉相同的后缀。
自定义过滤器GatewayFilter
自定义GatewayFilter
不是直接实现GatewayFilter
,而是继承AbstractGatewayFilterFactory
。最简单的方法如下:
1 |
|
注意:该类的名称一定要以GatewayFilterFactory
为后缀!
然后在yaml配置中这样使用:
1 | spring: |
另外,这种过滤器还可以支持动态配置参数,不过实现起来比较复杂,示例:
1 |
|
然后在yaml文件中使用:
1 | spring: |
上面这种配置方式参数必须严格按照shortcutFieldOrder()方法的返回参数名顺序来赋值。
还有一种用法,无需按照这个顺序,就是手动指定参数名:
1 | spring: |
自定义GlobalFilter
自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:
1 |
|
3.登录校验
这里我们使用GlobalFiilter来实现登录校验
前提条件:
1.将配置类和工具类信息从hm-service中复制过来
2.复制密钥文件
3.编写代码
登录校验过滤代码:
1 |
|
注意,别忘了加上注解@EnableConfigurationProperties(AuthProperties.class)
启用相应配置文件类,以及注入相应的所需实例,为避免对每个实例使用@Autowried注入,可以使用@RequiredArgsConstructor注解,这样只需要定义全局常量即可。
4.微服务获取用户信息
1.首先在网关的过滤器中,将用户信息存入请求头传递给下游微服务
注意传递用户信息的api写法,mutate()方法可以对下游请求做更改,.request表示对请求做处理,利用builder可以对请求中各种信息做修改。
2.给每个微服务设置拦截器
拦截器代码
1 | public class UserInfoIterceptor implements HandlerInterceptor { |
3.拦截器配置类
1 |
|
因为每个微服务都需要用户信息,且这里拦截器返回的都是true(网关已经帮我们对需要校验的路径校验好了,所以没必要再次校验了),所以可以设置拦截器和其配置类在公共模块hm-common,供所有微服务注入。
不过,要注意,这个公共模块的配置类默认是扫描不到的。我只知道有两种方法可以设置。
法1.给微服务启动类设置组件扫描路径
这里是以com.hmall为前缀的所有包都会被扫描,而hm-common这个公共模块正好也是com.hmall为前缀,所以导入这个依赖也会扫描相应的组件。
法2.在公共模块设置spring.factories
基于SpringBoot的自动装配原理,我们要将扫描路径添加到公共模块的resources
目录下的META-INF/spring.factories
文件中:
文件内容:
1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ |
这样每个导入这个模块的微服务都会扫描这个模块下的组件。
5.OpenFeign传递用户
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。
但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:
下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?
这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
1 | public interface RequestInterceptor { |
我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate
类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。
由于FeignClient
全部都是在hm-api
模块,因此我们在hm-api
模块的com.hmall.api.config.DefaultFeignConfig
中编写这个拦截器
1 |
|
注意:
1.这里UserContext在common模块,记得引入依赖
2.这里创建配置类后,微服务默认是扫描不到这个配置类的,记得给每个需要OpenFign拦截器的启动类上添加配置类.class到注解属性中
1 |
3.此外,这里注册RequestInterceptor类型的bean能生效的原因:当你使用 Feign 客户端发起调用时,Feign 会创建一个 RequestTemplate 对象来准备请求。
在请求发送之前,Feign 会遍历所有已注册的 RequestInterceptor 拦截器,并依次调用它们的 apply(RequestTemplate template) 方法。
三、配置管理
到目前为止我们已经解决了微服务相关的几个问题:
- 微服务远程调用
- 微服务注册、发现
- 微服务请求路由、负载均衡
- 微服务登录用户信息传递
不过,现在依然还有几个问题需要解决:
- 网关路由在配置文件中写死了,如果变更必须重启微服务
- 某些业务配置在配置文件中写死了,每次修改都要重启服务
- 每个微服务都有很多重复的配置,维护成本高
这些问题都可以通过统一的配置管理器服务解决。而Nacos不仅仅具备注册中心功能,也具备配置管理的功能:
微服务共享的配置可以统一交给Nacos保存和管理,在Nacos控制台修改配置后,Nacos会将配置变更推送给相关的微服务,并且无需重启即可生效,实现配置热更新。
网关的路由同样是配置,因此同样可以基于这个功能实现动态路由功能,无需重启网关即可修改路由配置。
1.配置共享
我们可以把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。分为两步:
- 在Nacos中添加共享配置
- 微服务拉取配置
添加共享配置
至于具体怎么配置就不进行相关介绍了,具体可以去看相关文档day04-微服务02 - 飞书云文档
拉取共享配置
这里要注意几个问题:
1.Nacos中的配置文件的读取是先于微服务配置文件application.yaml
的。所以如果我们把nacos地址写在application.yaml
文件中,那么这个微服务启动注册到nacos后也没法读取nacos中的相关配置了。
这里就需要使用一个名为bootstrap.yaml
文件。这个配置文件的加载优先于nacos中的配置文件。所以需要把nacos相关配置信息写入这个文件中。
具体步骤如下
(1)给微服务引入相关依赖
注意力,这里的nacos配置依赖和注册不是同一个依赖包
1 | <!--nacos配置管理--> |
(2)编写bootstrap.yaml配置文件
如上所示,除了写nacos地址之外,还记得配置微服务名称和要读取的nacos中的配置文件
2.配置文件中的配置为了通用性,防止写死。在写值时可以使用{配置信息路径: 默认值}
(例如{hm.db.user:root}
)。写了这个之后,将来会读取微服务中的配置文件application.yaml中的信息,若存在则赋值,否则使用默认值。
2.配置热更新
配置热更新就是为了防止某些参数写死,导致需要修改时需要重新打包部署。而是通过配置文件的更新,配置类会监听配置信息的变更,实时更新相关参数信息。
具体实习简单来说就是将参数信息配置到nacos中,其他的像是创建配置类、使用配置类都和正常使用没区别。
问题引入
有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,默认是10,对应代码如下:
现在这里购物车是写死的固定值,我们应该将其配置在配置文件中,方便后期修改。
但现在的问题是,即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。能不能不用重启,直接生效呢?
这就要用到Nacos的配置热更新能力了,分为两步:
- 在Nacos中添加配置
- 在微服务读取配置
在Nacos中添加配置
在微服务中读取配置
配置信息类
1 |
|
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 | String serverAddr = "{serverAddr}"; |
这里核心的步骤有2步:
- 创建ConfigService,目的是连接到Nacos
- 添加配置监听器,编写配置变更的通知处理逻辑
由于我们采用了spring-cloud-starter-alibaba-nacos-config
自动装配,因此ConfigService
已经在com.alibaba.cloud.nacos.NacosConfigAutoConfiguration
中自动创建好了:
NacosConfigManager中是负责管理Nacos的ConfigService的,具体代码如下:
因此,只要我们拿到NacosConfigManager
就等于拿到了ConfigService
,第一步就实现了。
第二步,编写监听器。虽然官方提供的SDK是ConfigService中的addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的API是这个:
1 | String getConfigAndSignListener( |
既可以配置监听器,并且会根据dataId和group读取配置并返回。我们就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。
2.更新路由
更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter
这个接口:
1 | package org.springframework.cloud.gateway.route; |
这里更新的路由,也就是RouteDefinition,之前我们见过,包含下列常见字段:
- id:路由id
- predicates:路由匹配规则
- filters:路由过滤器
- uri:路由目的地
将来我们保存到Nacos的配置也要符合这个对象结构,将来我们以JSON来保存,格式如下:
1 | { |
以上JSON配置就等同于:
1 | spring: |
之所以使用Json格式存储路由是因为Json有现成的工具类可以转换成RouteDefinition这个类,而yaml格式文件则不好转换。
3.实现动态路由
1.引入依赖
1 | <!--统一配置管理--> |
2.编写bootstrap.yml
1 | spring: |
3.创建动态路由类
1 |
|
其中引入的如下两个类,write用于保存路由信息,nacosConfigManager用于获取拉取配置并设置监听器。
1 | private final RouteDefinitionWriter writer; |
四、微服务保护
问题引入
1.业务健壮性问题
当微服务之间相互调用时,若某个调用的微服务发生故障,那么整体业务就失败了。但从业务角度来说,即使某些情况下失败,依然需要展示其余某些数据。
拿购物车举例,在获取购物车列表时,还会微服务远程调用获取商品最新的价格是,而就算获取最新商品价格失败,也不应该整个业务失败,毕竟我看的是购物车,不是最新价格。
2.级联问题
当前微服务并发较高,又需要远程调用其他微服务,而当被调用微服务阻塞时,会导致当前微服务也阻塞。这种因为某个接口占用资源过多,导致多个微服务的受到影响的情况就是级联问题。
依旧以购物车为例,查询购物车时,会查询当前购物车中所有商品的最新价格,当商品微服务响应时间过长时,购物车微服务也会受到影响,导致购物车Tomcat连接占用较多,所有接口的响应时间都会增加。
依次类推,整个微服务群中与购物车服务、商品服务等有调用关系的服务可能都会出现问题,最终导致整个集群不可用。
这就叫级联失败问题,或者说雪崩问题
服务保护方案
保证服务运行的健壮性,避免级联失败导致的雪崩问题,就属于微服务保护。
以下是三种保护微服务的方法
- 请求限流
- 线程隔离
- 服务熔断
请求限流
请求限流就是处理短时间大量请求打过来的问题。我们可以设个请求阈值在接口处,每秒最多可以处理多少请求,超过这个数的后面要么请求失败要么排队等待。
线程隔离
给每个接口设置最大可以占用的线程数,以保证当前接口即使故障也不会占用当前服务的所有线程资源,以避免雪崩问题。
服务熔断
服务熔断主要是处理业务健壮性问题,即当前微服务调用的其他微服务发生异常时,走相应的降级方案。
所以,我们要做两件事情:
- 编写服务降级逻辑:就是服务调用失败后的处理逻辑,根据业务场景,可以抛出异常,也可以返回友好提示或默认数据。
- 异常统计和熔断:统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑。
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
也可以直接使用黑马资料中提供的版本:
2)运行
将jar包放在任意非中文、不包含特殊字符的目录下,重命名为sentinel-dashboard.jar
:
打开终端输入如下命令即可启动成功
java '-Dserver.port=8090' '-Dcsp.sentinel.dashboard.server=[localhost:8090](http://localhost:8090)' -jar sentinel-dashboard.jar
此时访问localhost:8090
可看到sentinel的控制台
登录的账号密码默认都是sentinel
2.微服务整合
(1)引入sentinel依赖
1 | <!--sentinel--> |
(2)配置控制台
在application.yml文件添加如下内容
1 | spring: |
(3)访问cart-service
的任意端点
重启cart-service
,然后访问查询购物车接口,sentinel的客户端就会将服务访问的信息提交到sentinel-dashboard
控制台。并展示出统计信息:
点击簇点链路菜单,会看到下面的页面:
所谓簇点链路,就是单机调用链路,是一次请求进入服务后经过的每一个被Sentinel
监控的资源。默认情况下,Sentinel
会监控SpringMVC
的每一个Endpoint
(接口)。
因此,我们看到/carts
这个接口路径就是其中一个簇点,我们可以对其进行限流、熔断、隔离等保护措施。
不过,需要注意的是,我们的SpringMVC接口是按照Restful风格设计,因此购物车的查询、删除、修改等接口全部都是/carts
路径:
默认情况下Sentinel会把路径作为簇点资源的名称,无法区分路径相同但请求方式不同的接口,查询、删除、修改等都被识别为一个簇点资源,这显然是不合适的。
所以我们可以选择打开Sentinel的请求方式前缀,把请求方式 + 请求路径
作为簇点资源名:
首先,在cart-service
的application.yml
中添加下面的配置:
1 | spring: |
然后,重启服务,通过页面访问购物车的相关接口,可以看到sentinel控制台的簇点链路发生了变化:
即使路径相同,也会有请求方式前缀做区分。
实操
请求限流
在簇点链路页面点击流控即可做限流配置
QPS:Queries Per Second是衡量信息检索系统(例如搜索引擎或数据库)在一秒钟内接收到的搜索流量的一种常见度量。该术语在任何请求-响应系统中都得到更广泛的使用,更正确地称为每秒请求数(RPS:Request Per Second)。
线程隔离
限流可以降低服务器压力,尽量减少因并发流量引起的服务故障的概率,但并不能完全避免服务故障。一旦某个服务出现故障,我们必须隔离对这个服务的调用,避免发生雪崩。
比如,查询购物车的时候需要查询商品,为了避免因商品服务出现故障导致购物车服务级联失败,我们可以把购物车业务中查询商品的部分隔离起来,限制可用的线程资源。这样,即便商品服务出现故障,最多导致查询购物车业务故障,并且可用的线程资源也被限定在一定范围,不会导致整个购物车服务崩溃。
1.OpenFeign整合Sentinel
(1)修改cart-service模块的application.yml文件,开启Feign的sentinel功能:
1 | feign: |
尽管开启了Sentinel功能,但我们还需要配置最大排队数量等参数,因为不是说超了当前QPS的部分就一定拒绝请求了,依然可以排队等待处理。
(2)配置一下cart-service模块的application.yml文件,修改tomcat连接:
1 | server: |
注意,默认情况下SpringBoot项目的tomcat最大线程数是200,允许的最大连接是8492,单机测试很难打满。
然后重启cart-service服务,可以看到查询商品的FeignClient自动变成了一个簇点资源:
2.配置线程隔离
点击流控,选择并发线程数即可。
假设当线程每秒可以处理2个请求,那么5个线程最多每秒可以处理10个请求,即10QPS。而超出的请求自然会被拒绝。
服务熔断
对于上面的线程隔离还是存在几个问题。
第一,超出的QPS上限的请求就只能抛出异常,从而导致购物车的查询失败。但从业务角度来说,即便没有查询到最新的商品信息,购物车也应该展示给用户,用户体验更好。也就是给查询失败设置一个降级处理逻辑。
第二,由于查询商品的延迟较高(模拟的500ms),从而导致查询购物车的响应时间也变的很长。这样不仅拖慢了购物车服务,消耗了购物车服务的更多资源,而且用户体验也很差。对于商品服务这种不太健康的接口,我们应该直接停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断。
1.编写降级逻辑
触发限流或熔断后的请求不一定要直接报错,也可以返回一些默认数据或者友好提示,用户体验会更好。
给FeignClient编写失败后的降级逻辑有两种方式:
- 方式一:FallbackClass,无法对远程调用的异常做处理
- 方式二:FallbackFactory,可以对远程调用的异常做处理,我们一般选择这种方式。
这里以方式二为例:
(1)在hm-api模块新建一个包存放各个降级处理类
如图所示,其实很简单,就是实现FallbackFactory<>这个类,泛型里填写要降级处理的Client接口。此时会让你实现create方法,在方法里直接return 相应的匿名内部类。
匿名内部类是什么?
匿名:没有名字的意思。内部类:写在其他类内部的类。匿名内部类的作用是简化代码。
原本我们需要创建子类或者实现类,去继承父类和实现接口,才能重写其中的方法。但是有时候我们这样做了,然而子类和实现类却只需要使用一次(定义了一个对象)。这个时候我们就可以使用匿名内部类,不用去写子类和实现类,起到简化代码的作用。
代码如下
1 |
|
(2)将降级处理类注册为一个Bean
(3)在Client接口的@FeignClient注解添加fallbackFactory属性,引入降级处理类
2.服务熔断
现在已经实现了降级逻辑,但有的时候某些接口可能频繁或者一直有问题,这个时候就没必要去访问这些微服务接口了,直接在发请求时就熔断,走降级逻辑,以减少不必要的请求时长。
这就需要我们使用Sentinel的断路器来设置熔断阈值。
Sentinel中的断路器不仅可以统计某个接口的慢请求比例,还可以统计异常请求比例。当这些比例超出阈值时,就会熔断该接口,即拦截访问该接口的一切请求,降级处理;当该接口恢复正常时,再放行对于该接口的请求。
断路器的工作状态切换有一个状态机来控制:
状态机包括三个状态:
- closed:关闭状态,断路器放行所有请求,并开始统计异常比例、慢请求比例。超过阈值则切换到open状态
- open:打开状态,服务调用被熔断,访问被熔断服务的请求会被拒绝,快速失败,直接走降级逻辑。Open状态持续一段时间后会进入half-open状态
- half-open:半开状态,放行一次请求,根据执行结果来判断接下来的操作。
- 请求成功:则切换到closed状态
- 请求失败:则切换到open状态
同样,在簇点链路中点击熔断按钮
图中所示是慢调用比例的熔断策略。对应的参数详解如下所示:
最大RT:响应时长超过最大RT的值就是慢调用。
熔断时长:熔断的持续时长
统计时长:多长时间统计一次
比例阈值:慢调用的比例
最小请求数:在统计时长内最少统计5个请求计算比例阈值来判断是否需要熔断
五、分布式事务
分布式事务,顾名思义就是处理分布式情况下的事务问题。
以下单业务为例
由于订单、购物车、商品分别在三个不同的微服务,而每个微服务都有自己独立的数据库,因此下单过程中就会跨多个数据库完成业务。而每个微服务都会执行自己的本地事务:
- 交易服务:下单事务
- 购物车服务:清理购物车事务
- 库存服务:扣减库存事务
整个业务中,各个本地事务是有关联的。因此每个微服务的本地事务,也可以称为分支事务。多个有关联的分支事务一起就组成了全局事务。我们必须保证整个全局事务同时成功或失败。
我们知道每一个分支事务就是传统的单体事务,都可以满足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架构图
其中,TM和RM可以理解为Seata的客户端部分,引入到参与事务的微服务依赖中即可。将来TM和RM就会协助微服务,实现本地分支事务与TC之间交互,实现事务的提交或回滚。
而TC服务则是事务协调中心,是一个独立的微服务,需要单独部署。
(1)部署TC服务
1.准备seata数据库表并导入到数据库中
执行该sql后的数据库结构
2.将seata文件夹拷贝到虚拟机的/root目录下
3.docker部署
在虚拟机/root目录下执行如下命令
1 | docker run --name seata \ |
注意把SEATA_IP换成你的虚拟机ip以及加入和mysql、nacos同一个的网络中。
(2)微服务集成Seata
1.引入相应依赖
1 | <!--统一配置管理--> |
因为我们需要让seata相关配置可以热更新,所以除了seata依赖外,还需要引入nacos统一配置管理和bootstrap依赖。
共享的seata配置信息如下
2.bootstrap.yml文件如下
1 | spring: |
3.给每个引入seata的微服务所连接的数据库创建undo_log表
seata的客户端在解决分布式事务的时候需要记录一些中间数据,保存在数据库中。因此我们要先准备一个这样的表。
如此,我们的分布式事务实现所需的所有环境都以及配好了。
(3)测试
使用分布式事务需要使用@GlobalTransactional
注解
@GlobalTransactional
注解就是在标记事务的起点,将来TM就会基于这个方法判断全局事务范围,初始化全局事务。
所以我们只需要在方法起始入口将@Transactional
注解修改为@GlobalTransactional
即可。
我们重启trade-service
、item-service
、cart-service
三个服务。再次测试,发现分布式事务的问题解决了!
2.分布式事务的解决方案
Seata可以解决分布式事务问题主要是因为它支持四种分布式事务解决方案。
- XA
- TCC
- AT
- SAGA
这里介绍XA模式和AT模式
1.XA模式
XA
规范 是 X/Open
组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM
与局部的RM
之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
(1)两阶段提交
A是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况下:
异常情况下:
一阶段:
- 事务协调者(TC)通知每个事务参与者执行本地事务
- 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
- 事务协调者(TC)基于一阶段的报告来判断下一步操作
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
(2)Seata的XA模型
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:
如图所示,可知
RM
一阶段的工作:
- 注册分支事务到
TC
- 执行分支业务sql但不提交
- 报告执行状态到
TC
TC
二阶段的工作:
TC
检测各分支事务执行状态- 如果都成功,通知所有RM提交事务
- 如果有失败,通知所有RM回滚事务
RM
二阶段的工作:
- 接收
TC
指令,提交或回滚事务
(3)优缺点
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则
- 常用数据库都支持,实现简单,并且没有代码入侵
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
(4)实现步骤
要将Seata的默认模式(AT)改为XA模式可以在配置文件中指定要使用的模式。
2.AT模式
AT
模式同样是分阶段提交的事务模型,不过缺弥补了XA
模型中资源锁定周期过长的缺陷。
(1)Seata的AT模型
基本流程图:
阶段一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模式下,当前分支事务执行流程如下:
一阶段:
TM
发起并注册全局事务到TC
TM
调用分支事务- 分支事务准备执行业务SQL
RM
拦截业务SQL,根据where条件查询原始数据,形成快照。
1 | { |
RM
执行业务SQL,提交本地事务,释放数据库锁。此时 money = 90RM
报告本地事务状态给TC
二阶段:
TM
通知TC
事务结束TC
检查分支事务状态- 如果都成功,则立即删除快照
- 如果有分支事务失败,需要回滚。读取快照数据({“id”: 1, “money”: 100}),将快照恢复到数据库。此时数据库再次恢复为100
流程图:
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持有的,所以不会发生脏写的问题。