Spring Cloud

概述

微服务是一种软件架构风格,它是以专注于单一职责的很多小型项目为基础,组合出复杂的大型应用
  • 服务拆分
  • 远程调用
  • 服务治理
  • 请求路由
  • 身份认证
  • 配置管理
  • 服务保护
  • 分布式事务
  • 异步通信
  • 消息可靠性
  • 延迟消息
  • 分布式搜索
  • 倒排索引
  • 数据聚合

单体架构

将业务中的所有功能集中在一个项目中开发,打成一个包部署,就是单体架构

优点:

  • 架构简单
  • 部署成本低

缺点:

  • 团队协作成本高
  • 系统发布效率低
  • 系统可用性差

总结:

单体架构适合开发功能相对简单,规模较小的项目

微服务

微服务架构,是服务化思想指导下的一套最佳实践架构方案。服务化,就是把单体架构中的功能模块拆分为多个独立的项目

  • 粒度小
  • 团队自治
  • 服务自治

Spring Cloud

  • SpringCloud是目前国内使用最广泛的微服务框架。官网地址:https://spring.io/projects/spring-cloud
  • SpringCloud集成了各种微服务功能组件,并基于 SpringBoot 实现了这些组件的自动装配,从而提供了良好的开箱即用体验。

微服务拆分

首先需要熟悉项目的功能模块划分,以黑马商城为例:

项目 git 地址:https://github.com/HelloCode66/hmall

gitee:https://gitee.com/java-navigation/hmall

image-20231102130945355

服务拆分原则

什么时候拆分?

  • 创业型项目:先采用单体架构,快速开发,快速试错。随着规模扩大,逐渐拆分
  • 确定的大型项目:资金充足,目标明确,可以直接选择微服务架构,避免后续拆分的麻烦

怎么拆分?

  • 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高
  • 低耦合:每个微服务的功能要相对独立,尽量减少对其他微服务的依赖

从拆分方式来说,一般包含两种方式:

  • 纵向拆分:按照业务模块来拆分
  • 横向拆分:抽取公共服务,提高复用性

拆分服务

  • 独立 Project(每个服务都是一个 Project):适合很大型、复杂的项目
  • Maven 聚合(一个Project 对应 多个 Module):适合中小型微服务项目

服务拆分

单体结构:

image-20231102135319107

  • common 是公共模块(异常处理、切面、工具类等)
  • service 是真正的业务模块,项目所有的业务都在这里(商品、订单、购物车等)
  • 耦合度较高

微服务拆分:

需求:

  • 将 hm-service 中与商品管理相关的功能拆分到一个微服务 module 中,命名为:item-service
  • 将 hm-service 中与购物车相关的功能拆分到一个微服务 module 中,命名为:cart-service

步骤:

  1. 修改 pom 文件,只保留该模块所需要的依赖项
  2. 将商品相关数据库表拆分出来,放在一个新库
  3. 修改配置文件信息(端口号、数据库名等)
  4. 拆分对应模块所需要的类
  5. 启动服务
其余模块类似

商品服务拆分:

image-20231102152708756

image-20231102152722836

image-20231102152733265

购物车服务拆分:

image-20231102155309530

image-20231102155250880

在 Cart 模块中,涉及到 Item 模块的操作,这里需要用到远程调用解决

image-20231102155453691

image-20231102155355089

image-20231102155410026

远程调用

image-20231102155653325

拆分后,某些数据在不同服务,无法直接调用本地方法查询数据

Spring 给我们提供了一个 RestTemplate 工具,可以方便的实现 Http 请求的发送。使用步骤如下:

  1. 注入 RestTemplate 到 Spring 容器

    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
  2. 发起远程调用

    public <T> ResponseEntity<T> exchange(
        String url,        // 请求路径
        HttpMethod method,        // 请求方式
        @Nullable HttpEntity<?> requestEntity,        // 请求实体,可以为空
        Class<T> responseType,        // 返回值类型
        Map<String, ?> uriVariables        // 请求参数
    )

案例:

@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
    public static void main(String[] args) {
        SpringApplication.run(CartApplication.class, args);
    }

    @Bean
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}
private void handleCartItems(List<CartVO> vos) {
    // 1.获取商品id
    Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());

    // 发起远程调用(利用restTemplate 发起 Http 请求,得到 Http 响应)
    ResponseEntity<List<ItemDTO>> resp = restTemplate.exchange(
        "http://localhost:8081/items?ids={ids}",
        HttpMethod.GET,
        null,
        // 返回值类型复杂时,使用该方式防止泛型擦除
        new ParameterizedTypeReference<List<ItemDTO>>() {
        },
        Map.of("ids", CollUtil.join(itemIds, ","))
    );
    // 解析响应
    if(!resp.getStatusCode().is2xxSuccessful()){
        // 查询失败,直接结束
        return;
    }
    List<ItemDTO> items = resp.getBody();

    if (CollUtils.isEmpty(items)) {
        return;
    }
    // 3.转为 id 到 item的map
    Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
    // 4.写入vo
    for (CartVO v : vos) {
        ItemDTO item = itemMap.get(v.getItemId());
        if (item == null) {
            continue;
        }
        v.setNewPrice(item.getPrice());
        v.setStatus(item.getStatus());
        v.setStock(item.getStock());
    }
}

补充

在 Spring 中进行对象注入时,习惯使用 @Autowird注解自动注入,但是 Spring 不推荐,推荐通过构造函数的形式进行注入,Spring 也会帮助我们进行依赖注入,例如:

public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {

    private final IItemService itemService;

    // 构造注入
    public CartServiceImpl(IItemService itemService){
        this.itemService = itemService;
    }
}

但是上面的方式有一个缺陷,当需要注入的类很多时,构造方法就比较臃肿,为此,可以使用 Lombok 帮我们生成构造方法,但是我们的成员变量,并不一定都需要 Spring 帮我们注入,解决方法:

  1. 将需要注入的成员变量使用 final 修饰
  2. 使用 Lombok 的 RequiredArgsConstructor 替换 AllArgsConstructor注解(final 修饰的变量,如果声明时没有初始化,就必须在构造方法初始化,就是必须初始化的,lombok 就会通过构造方法进行初始化)

服务治理

image-20231102165120971

通过 RestTemplate 进行远程调用,uri 是写死的,耦合度太高:

  • 当部署多个实例,进行集群时,无法进行负载均衡
  • 如果有服务宕机,启动了新服务,无法及时直到最新的服务地址
  • 无法感知服务状态的变更
以上问题统称为服务治理问题

注册中心原理

image-20231102165933770

三个角色:

  • 服务提供者:暴露服务接口,供其他服务调用
  • 服务消费者:调用其他服务提供的接口
  • 注册中心:记录并监控微服务各实例状态,推送服务变更信息

整体概念:

  • 每个服务都既可以是服务提供者,也可以是服务调用者
  • 在每个服务启动时,都会向注册中心进行注册,并不断的通过心跳机制进行续约

    • 如果有服务宕机,通过心跳机制就能检测到,注册中心就会更新注册表
    • 同时,注册表有变更的话,注册中心也会及时的通知服务调用者
    • 有新服务上线,也会向注册中心注册,同时更改注册表,通知调用者
  • 服务调用者调用对应服务后,注册中心就会发送相关的注册信息,此时服务调用者就可以根据相应的负载均衡算法来决定调用哪个实例

    • 随机
    • 轮询
    • 加权
    • ......

Nacos注册中心

image-20231102170532945

Nacos 是目前国内企业中占比最多的注册中心组件。它是阿里巴巴的产品,目前已经加入 SpringCloudAlibaba 中

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

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

Nacos 注册中心需要部署后使用,推荐使用 docker进行部署(步骤省略)

需要开放:8848、9848、9849 端口

image-20231102173033563

部署成功后可以访问:http://ip:8848/nacos 访问控制台(默认账户密码均为 nacos)

image-20231102174441992

服务注册
  1. 引入 nacos依赖

    <!--nacos-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
  2. 配置 nacos 地址
spring:
  application:
    name: item-service    # 服务名称
  cloud:
    nacos:
      server-addr: 192.168.36.128:8848    # nacos 地址
  1. 启动服务

image-20231102175934117

image-20231102175948719

服务发现

消费者需要连接 nacos 以拉取和订阅服务,因此服务发现的前两步与服务注册是一样的,后面再加上服务调用即可

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

    private final DiscoveryClient discoveryClient;
    
    private void handleCartItems(List<CartVO> vos){
        // 1. 根据服务名称,拉取服务的实例列表
        List<ServiceInstance> instances = discoveryClient.getInstance("item-service");
        // 2. 负载均衡,挑选一个实例
        ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
        // 3. 获取实例的 IP 和端口
        URI uri = instance.getUri();
        // ...... 略
    }
private void handleCartItems(List<CartVO> vos) {
    // 1.获取商品id
    Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
    // 2.查询商品
    // 获取商品服务的实例列表
    List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
    if(CollUtil.isEmpty(instances)){
        return;
    }
    // 根据负载均衡策略从实例列表选取实例
    ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
    URI uri = instance.getUri();

    // 发起远程调用(利用restTemplate 发起 Http 请求,得到 Http 响应)
    ResponseEntity<List<ItemDTO>> resp = restTemplate.exchange(
        uri + "/items?ids={ids}",
        HttpMethod.GET,
        null,
        // 返回值类型复杂时,使用该方式防止泛型擦除
        new ParameterizedTypeReference<List<ItemDTO>>() {
        },
        Map.of("ids", CollUtil.join(itemIds, ","))
    );
    // 解析响应
    if(!resp.getStatusCode().is2xxSuccessful()){
        // 查询失败,直接结束
        return;
    }
    List<ItemDTO> items = resp.getBody();

    if (CollUtils.isEmpty(items)) {
        return;
    }
    // 3.转为 id 到 item的map
    Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
    // 4.写入vo
    for (CartVO v : vos) {
        ItemDTO item = itemMap.get(v.getItemId());
        if (item == null) {
            continue;
        }
        v.setNewPrice(item.getPrice());
        v.setStatus(item.getStatus());
        v.setStock(item.getStock());
    }
}
此时即使 item-service 有实例宕机,注册中心也能及时感知并通知给调用者

OpenFeign

在上述代码中,发起远程调用的步骤还是太繁琐了,当需要远程调用时,代码量太大,因此需要使用 OpenFeign 来简化远程调用的过程

快速入门

OpenFeign 是一个声明式的 HTTP 客户端,是SpringCloud 在 Eureka 公司开源的 Feign 基础上改造而来。官方地址:https://github.com/OpenFeign/feign

其作用就是基于 SpringMVC 的常见注解,帮我们优雅的实现 http 请求的发送

image-20231102182233456

原始方式:

image-20231102183015074

OpenFeign方式:

OpenFeign 已经被 SpringCloud 自动装配,实现起来非常简单:

  1. 引入依赖,包括 OpenFeign 和负载均衡组件 SpringCloudLoadBalancer

    <!--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>
    在早期负载均衡使用的是 Ribbon,现在主流已经是 loadbalancer 了
  2. 通过@EnableFeignClients注解,启用 OpenFeign 功能

    @MapperScan("com.hmall.cart.mapper")
    @SpringBootApplication
    @EnableFeignClients
    public class CartApplication {
        public static void main(String[] args) {
            SpringApplication.run(CartApplication.class, args);
        }
    
        @Bean
        public RestTemplate restTemplate(){
            return new RestTemplate();
        }
    }
  3. 编写 FeignClient

    @FeignClient("item-service")
    public interface ItemClient {
        @GetMapping("/items")
        List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
    }
  4. 使用 FeignClient(注入即可对应的接口即可,如 ItemClient),实现远程调用

    private final ItemClient itemClient;
    
    private void handleCartItems(List<CartVO> vos) {
        // 1.获取商品id
        Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
        // 2.查询商品
        List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
    
        // 3.转为 id 到 item的map
        Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
        // 4.写入vo
        for (CartVO v : vos) {
            ItemDTO item = itemMap.get(v.getItemId());
            if (item == null) {
                continue;
            }
            v.setNewPrice(item.getPrice());
            v.setStatus(item.getStatus());
            v.setStock(item.getStock());
        }
    }

连接池

OpenFeign 对 HTTP 请求做了优雅的伪装,不过其底层发起 Http 请求,依赖于其他的框架。这些框架可以自己选择,包括以下三种:

  • HttpURLConnection:默认实现,不支持连接池,性能差
  • Apache HttpClient:支持连接池,性能好
  • OKHttp:支持连接池,性能好
具体的源码可以参考 FeignBlockingLoadBalancerClient 类中的 delegate 成员变量

OpenFeign 整合 OKHttp

  1. 引入依赖

    <!-- OKHttp -->
    <dependency>
        <groupId>io.github.openfeign</groupId>
        <artifactId>feign-okhttp</artifactId>
    </dependency>
  2. 开启连接池功能

    feign:
      okhttp:
        enabled: true    # 开启 OKHttp 连接池支持
整合其他的也是同理

最佳实践

目前购物车服务需要使用商品服务的接口,我们在购物车服务中编写了 ItemClient,后续又有订单服务,也需要 商品服务,那么就又需要编写相应的 FeignClient,造成了代码的冗余,如果这种现象很多,那么我们就写了很多重复的 Client,如果原服务相应接口做了修改,那么都需要修改,耦合太高。

解决方式一:将 Client 和 Dto 等类交给服务提供者来写(独立出相应的模块,如果其他模块需要使用,直接引用即可)

  • 优点:对应的模块由服务提供者负责编写,符合逻辑
  • 缺点:项目结构变复杂了

image-20231102190421033

解决方式二:抽取出一个通用的 api 模块,其中包含通用的 client、config、dto等信息(调用者引入该模块即可)

  • 优点:结构简单清晰
  • 缺点:代码耦合度增加

image-20231102190806238

两种方案使用的都比较多,需要结合具体的场景来抉择(一般根据微服务拆分方式来选择)

  • 独立 Project 的选择方式一
  • Maven 聚合的选择方式二

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

  • 方式一:指定 FeignClient 所在包

    @SpringBootApplication
    @EnableFeignClients(basePackages = "com.hmall.api.client")
    public class CartApplication {
    }
  • 方式二:指定 FeignClient 字节码

    @SpringBootApplication
    @EnableFeignClients(basePackageClasses = {ItemClient.class})
    public class CartApplication {
    }

日志

OpenFeign 只会在 FeignClient 所在包的日志级别是DEBUG时,才会输出日志。而且其日志级别有4级:

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

由于 Feign 默认的日志级别是 NONE,所以默认是看不到请求日志的

要自定义日志级别需要声明一个类型为 Logger.Level 的 Bean,在其中定义日志级别:

public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLoggerLevel(){
        return Logger.Level.FULL;
    }
}

但此时这个 Bean 并未生效,要想配置某个 FeignClient 的日志,可以在 @FeignClient注解中声明:

@FeignClient(value = "item-service",configuration = DefaultFeignConfig.class)

如果想要全局配置,让所有的 FeignClient 都按照这个日志配置,则需要在@EnableFeignClients注解中声明:

@EnableFeignClients(basePackages = "com.hmall.api.client",defaultConfiguration = DefaultFeignConfig.class)

网关及配置管理

image-20231103135118115

网关路由

在 Spring Cloud Gateway 中网关的实现包括两种:

  • SpringCloud Gateway

    • Spring官方出品
    • 基于 WebFlux 响应式编程
    • 无需调优即可获得优异性能
  • Netfilx Zuul

    • Netflix 出品
    • 基于 Servlet 的阻塞式编程
    • 需要调优才能获得与 SpringCloudGateway 类似的性能
快速入门

网关是一个独立服务,因此步骤如下:

  1. 创建网关服务
  2. 引入依赖

    <dependencies>
        <!--网关-->
        <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>
  3. 编写启动类

    @SpringBootApplication
    public class GatewayApplication {
        public static void main(String[] args) {
            SpringApplication.run(GatewayApplication.class,args);
        }
    }
  4. 配置路由

    spring:
      cloud:
        gateway:
          routes:
            - id: item # 路由规则id,自定义,唯一
              uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
              predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
                - Path=/items/**,/search/** # 这里是以请求路径作为判断规则
启动网关和对应的服务后,通过访问网关就可以访问到配置的其他服务
路由断言

网关路由对应的 Java 类型是 RouteDefinition,其中可配置的属性有:

  • id:路由唯一标识
  • uri:路由目的地,支持 lb 和 http 两种
  • predicates:路由断言,断言配置都由路由断言工厂(RoutePredicateFactory)来处理
  • filters:路由过滤器,处理请求或响应

Spring 提供了 12 种基本的 RoutePredicateFactory 的默认实现:

image-20231103142142083

网关鉴权

  • 登录授权交给 user 服务,由网关来进行校验
  • 服务之间通信都是基于 Http,可以将用户信息以明文方式存储在请求头(服务内部,也是安全的)

image-20231103143357112

网关过滤器

image-20231103143944555

网关过滤器有两种,分别是:

  • GatewayFilter:路由过滤器,作用范围灵活,作用于任意指定的路由
  • GlobalFilter:全局过滤器,作用范围是所有路由

两种过滤器的过滤方法签名完全一致,只是作用域不同

image-20231103144926119

Spring 内置了很多 GatewayFilter 和 GlobalFilter,其中 GlobalFilter 直接对所有请求生效,而 GatewayFilter 则需要在 yaml 文件配置指定作用的路由范围。常见的 GatewayFilter 有:

image-20231103144152905

此处 RewritePatg 的作用是 路径重写,说明有误
自定义过滤器

GatewayFilter

自定义 GatewayFilter 不是直接实现 GatewayFilter,而是实现 AbstractGatewayFilterFactory

image-20231103145253686

过滤器类名固定格式为:XxxGatewayFilterFactory,其中 Xxx 就是过滤器名,在yml配置中配置

如果想要指定过滤器的顺序,则在return时 new OrderedGatewayFilter 即可

注意,需要添加@Component才生效

如果我们的自定义过滤器需要有配置参数,则我们在过滤器内部定义一个自定义的 Config 类,来存储配置,继承AbstractGatewayFilterFactory 时,泛型指定为我们自定义的 Config 类即可

image-20231103145626740

配置参数:

image-20231103145658995

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  15:09
 * @Description: GatewayFilter 测试
 */
@Component
public class TestGatewayFilterFactory extends AbstractGatewayFilterFactory<TestGatewayFilterFactory.Config> {


    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter(new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                System.out.println("Gateway Filter doing....");
                System.out.println(config.getA());
                System.out.println(config.getB());
                System.out.println(config.getC());
                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{
        int a;
        int b;
        int c;
    }
}
spring:
  cloud:
    gateway:
      routes:
        - id: item-service
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**
          filters:
            - Test=1,2,3

GlobalFilter(全局过滤器)

自定义GlobalFilter 就简单多了,直接实现 GlobalFilter 接口即可:

image-20231103150749840

两种过滤器,局部需要在配置文件配置,可以指定全局,也可以指定服务生效

全局过滤器不需要配置,会自动生效(前提是添加了 Component 注解,由 Spring 管理)

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  15:18
 * @Description: 全局过滤器测试
 */
@Component
public class TestGlobalGatewayFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        System.out.println("Global Filter doing......");
        return chain.filter(exchange);
    }
    @Override
    public int getOrder() {
        return 0;
    }
}

image-20231103152017117

实现登录校验

需求:在 Gateway 模块基于过滤器实现登录校验功能

配置文件:

server:
  port: 10086
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.36.128:8848
    gateway:
      routes:
        - id: item-service
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**
          filters:
            - Test=1,2,3
        - 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/**
logging:
  level:
    com.hmall: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"
hm:
  jwt:
    location: classpath:hmall.jks
    alias: hmall
    password: hmall123
    tokenTTL: 30m
  auth:
    excludePaths:
      - /search/**
      - /users/login
      - /items/**
      - /hi
# keytool -genkeypair -alias hmall -keyalg RSA -keypass hmall123 -keystore hmall.jks -storepass hmall123

拦截器:

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  15:34
 * @Description: 全局网关登录过滤器
 */
@Component
@RequiredArgsConstructor
public class LoginGlobalFilter implements GlobalFilter, Ordered {
    private final AntPathMatcher pathMatcher = new AntPathMatcher();
    private final AuthProperties authProperties;
    private final JwtTool jwtTool;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取 Request
        ServerHttpRequest request = exchange.getRequest();
        // 判断当前请求是否需要被拦截
        if(isAllowPath(request)){
            // 无需拦截
            return chain.filter(exchange);
        }
        // 获取token
        String token = null;
        List<String> headers = request.getHeaders().get("authorization");
        if(CollUtil.isNotEmpty(headers)){
            token = headers.get(0);
        }
        // 解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (Exception e) {
            ServerHttpResponse response = exchange.getResponse();
            // 设置状态为未授权
            response.setRawStatusCode(401);
            // 结束后续的请求传递
            return response.setComplete();
        }
        System.out.println("userId = " + userId);

        // 传递用户信息到下游服务
        String userInfo = userId.toString();
        ServerWebExchange exec = exchange.mutate()
                .request(builder -> builder.header("user-info", userInfo))
                .build();
        // 放行
        return chain.filter(exec);
    }

    @Override
    public int getOrder() {
        return 0;
    }

    /**
     * @author: HelloCode.
     * @date: 2023/11/3 15:36
     * @param: request
     * @return: boolean
     * @description: 判断是否是放行路径
     */
    private boolean isAllowPath(ServerHttpRequest request){
        boolean flag = false;
        // 请求路径
        String path = request.getPath().toString();
        // 判断是否要放行
        for (String excludePath : authProperties.getExcludePaths()) {
            boolean match = pathMatcher.match(excludePath, path);
            if(match){
                // 放行
                flag = true;
                break;
            }
        }
        return flag;
    }
}

其他类:

@Data
@ConfigurationProperties(prefix = "hm.auth")
@Component
public class AuthProperties {
    private List<String> includePaths;
    private List<String> excludePaths;
}
@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
    private Resource location;
    private String password;
    private String alias;
    private Duration tokenTTL = Duration.ofMinutes(10);
}
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public KeyPair keyPair(JwtProperties properties){
        // 获取秘钥工厂
        KeyStoreKeyFactory keyStoreKeyFactory =
                new KeyStoreKeyFactory(
                        properties.getLocation(),
                        properties.getPassword().toCharArray());
        //读取钥匙对
        return keyStoreKeyFactory.getKeyPair(
                properties.getAlias(),
                properties.getPassword().toCharArray());
    }
}
@Component
public class JwtTool {
    private final JWTSigner jwtSigner;

    public JwtTool(KeyPair keyPair) {
        this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
    }

    /**
     * 创建 access-token
     *
     * @param userDTO 用户信息
     * @return access-token
     */
    public String createToken(Long userId, Duration ttl) {
        // 1.生成jws
        return JWT.create()
                .setPayload("user", userId)
                .setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
                .setSigner(jwtSigner)
                .sign();
    }

    /**
     * 解析token
     *
     * @param token token
     * @return 解析刷新token得到的用户信息
     */
    public Long parseToken(String token) {
        // 1.校验token是否为空
        if (token == null) {
            throw new UnauthorizedException("未登录");
        }
        // 2.校验并解析jwt
        JWT jwt;
        try {
            jwt = JWT.of(token).setSigner(jwtSigner);
        } catch (Exception e) {
            throw new UnauthorizedException("无效的token", e);
        }
        // 2.校验jwt是否有效
        if (!jwt.verify()) {
            // 验证失败
            throw new UnauthorizedException("无效的token");
        }
        // 3.校验是否过期
        try {
            JWTValidator.of(jwt).validateDate();
        } catch (ValidateException e) {
            throw new UnauthorizedException("token已经过期");
        }
        // 4.数据格式校验
        Object userPayload = jwt.getPayload("user");
        if (userPayload == null) {
            // 数据为空
            throw new UnauthorizedException("无效的token");
        }

        // 5.数据解析
        try {
           return Long.valueOf(userPayload.toString());
        } catch (RuntimeException e) {
            // 数据格式有误
            throw new UnauthorizedException("无效的token");
        }
    }
}
网关传递用户

image-20231103170604902

需求:修改 gatway 模块中的登录校验拦截器,在校验成功后保存用户到下游请求的请求头中

提示:要修改转发到微服务的请求,需要用到 ServerWebExchange 类提供的 API,示例如下:

image-20231103171405775

因为有很多服务,我们不用在每个服务中都写拦截器,只需要在 common 模块中配置即可:

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  17:22
 * @Description: 用户信息拦截器
 */
public class UserInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取请求头
        String userId = request.getHeader("user-info");

        // 判空
        if(StrUtil.isNotBlank(userId)){
            UserContext.setUser(Long.valueOf(userId));
        }
        // 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 清除用户信息,防止内存泄漏
        UserContext.removeUser();
    }
}
/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  17:25
 * @Description: MVC 配置类
 */
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

在 common 模块写的 SpringMVC 配置类(配置拦截器),其他微服务并不会扫描到(都是扫描的自己的包),因此有两种解决方案:

  • 通过 SpringBoot 的自动装配原理,通过 META/INF/spring.factories 来实现配置类的装配(只要引用了 common,拦截器就会生效)

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.hmall.common.config.MyBatisConfig,\
      com.hmall.common.config.MvcConfig
  • 按需装配(通过 @Import 注解导入,哪个服务需要就在对应启动类添加该自定义注解即可)

    /**
     * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
     * @Author: HelloCode.
     * @CreateTime: 2023-11-03  17:27
     * @Description: 按需加载注解
     */
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Import(MvcConfig.class)
    public @interface EnableUserInterceptor {
    }
使用第一种方式装配时,因为网关也引用了 common 模块,而我们使用的 Spring-Cloud-Gateway 并没有使用 MVC 技术,而是用了 WebFlux,因此会报错,我们需要在 MvcConfig 使用 @ConditionalOnClass(DispatcherServlet.class) 注解来控制
OpenFeign 传递用户

微服务项目中的很多业务要多个微服务共同合作完成,而这个过程也需要传递登录用户信息,例如:

image-20231103175639970

  • 当交易服务向购物车服务发送请求时,因为是内部调用,MVC拦截器并不会生效,用户信息拿不到

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

image-20231103175814667

因为每个 OpenFeignClient 都需要有这一步,因此对应的拦截器我们放在 api 模块中(client也在这个模块)
/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-03  17:59
 * @Description: OpenFeign 用户信息拦截器
 */
public class UserInfoInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        Long userId = UserContext.getUser();
        if(userId != null){
            requestTemplate.header("user-info",userId.toString());
        }
    }
}
/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-02  19:31
 * @Description: OpenFeign 配置类
 */
public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLoggerLevel(){
        return Logger.Level.FULL;
    }
  
    @Bean
    public RequestInterceptor requestInterceptor(){
        return new UserInfoInterceptor();
    }
}

image-20231103181944967

配置管理

image-20231103182213765

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

添加配置到 nacos

添加一些共享配置到 Nacos 中,包括:JDBC、MybatisPlus、日志、Swagger、OpenFeign 等

image-20231103183223749

image-20231103183352859

使用 yaml ${}占位符时,可以给默认值,比如:${hm.db.host:192.168.36.128}

拉取共享配置

image-20231103183944427

application 配置文件的加载时机靠后,为了让 SpringCloud 项目知道配置中心地址,因此引入了 bootstrap 配置文件(有就优先加载这个)
  1. 引入依赖

    <!--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

    spring:
      application:
        name: cart-service  # 服务名称
      profiles:
        active: dev
      cloud:
        nacos:
          server-addr: 192.168.36.128:8848  # nacos 地址
          config:
            file-extension: yaml  # 文件后缀名
            shared-configs:   # 共享配置
              - dataId: shared-jdbc.yaml  # 共享 jdbc 配置
              - dataId: shared-mp.yaml  # 共享 mybatis 配置
              - dataId: shared-log.yaml  # 共享 log 配置
              - dataId: shared-swagger.yaml  # 共享 swagger 配置
              - dataId: shared-feign.yaml  # 共享 feign 配置
  3. 修改application.yaml

    由于一些配置挪到了bootstrap.yaml,因此application.yaml需要修改为:

    server:
      port: 8082
    hm:
      db:
        host: 192.168.36.128
        pw: lh18391794828
        database: hm-cart
      swagger:
        title: 购物车服务接口文档
        package: com.hmall.cart.controller
    feign:
      okhttp:
        enabled: true
配置热更新

除了 Spring 的配置以外,业务中自定义的基于 @ConfigurationProperties 的自定义配置属性也都可以从 Nacos 读取。而且当 Nacos 中的这些业务配置变更时,Nacos 会推送变更信息到微服务,无需重启即可生效,实现配置热更新

image-20231103185638440

image-20231103190450561

配置文件默认会加载名为:cart-service.yamlcart-service-local.yaml

image-20231103190902015

动态路由

实现动态路由需要将路由配置保存到 Nacos,然后在网关监听 Nacos 中的路由配置,并实现配置热更新。然而网关路由并不是自定义业务配置属性,本身不具备热更新功能!参考:CompositeRouteDefinitionLocator

路由信息会在启动时加载,保存到内存中(Map),默认情况下并不支持热更新

因此我们需要自己完成两件事情:

  1. 监听 Nacos 配置变更的消息
  2. 当配置变更时,将最新的路由信息更新到网关路由表
监听 Nacos 配置变更可以参考官方文档:https://nacos.io/zh-cn/docs/sdk.html

image-20231104112751333

为了在项目启动时,执行对应的方法,可以通过@PostConstruct注解标记方法,当类被加载后就会执行方法

监听到路由信息后,可以利用来更新路由表

image-20231104114810216

这个类直接通过 Spring 注入即可,无需手动创建

最终我们需要向 Nacos 中添加一个 json 格式的路由配置,模板如下:

image-20231104115103194

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-04  11:33
 * @Description: 动态路由配置
 */
@Component
@RequiredArgsConstructor
public class RouteConfigLoader {
    private final NacosConfigManager configManager;

    private final RouteDefinitionWriter writer;

    private final Set<String> routeIds = new HashSet<>();

    // 网关路由不支持 yaml,只支持 json
    private final static String DATA_ID = "gateway-routes.json";
    private final static String GROUP = "DEFAULT_GROUP";

    @PostConstruct
    public void initRouteConfiguration() throws NacosException {
        // 第一次启动时,拉取路由表,并添加监听器
        String configInfo = configManager.getConfigService().getConfigAndSignListener(DATA_ID, GROUP, 1000, new Listener() {
            @Override
            public Executor getExecutor() {
                return Executors.newSingleThreadExecutor();
            }

            @Override
            public void receiveConfigInfo(String configInfo) {
                // 监听到路由变更,更新路由表
                updateRouteConfigInfo(configInfo);
            }
        });
        // 写入路由表
        updateRouteConfigInfo(configInfo);
    }

    private void updateRouteConfigInfo(String configInfo) {
        // 解析路由信息
        List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
        // 删除旧的路由(为了拿到路由 id,在每次更新的时候将这些id保存起来)
        for (String routeId : routeIds) {
            writer.delete(Mono.just(routeId)).subscribe();
        }
        routeIds.clear();

        // 判断是否有新路由
        if(CollUtil.isEmpty(routeDefinitions)){
            // 无新路由
            return;
        }

        // 更新路由表
        for (RouteDefinition routeDefinition : routeDefinitions) {
            // 保存本次路由id(便于下次删除)
            routeIds.add(routeDefinition.getId());
            // 写入路由表
            writer.save(Mono.just(routeDefinition)).subscribe();
        }
    }
}
[
    {
        "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://pau-service"
    }
]

服务保护与分布式事务

雪崩问题

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

image-20231104154148088

产生原因:

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

解决思路:

  • 尽量避免服务出现故障或阻塞

    • 保证代码的健壮性
    • 保证网络畅通
    • 能应对较高的并发请求
  • 服务调用者做好远程调用异常的后备方案,避免故障扩散
服务保护方案

请求限流

限制访问接口的请求的并发量,避免服务因流量激增出现故障。

image-20231104154911805

线程隔离

也叫做舱壁模式,模拟船舱隔板的防水原理。通过限定每个业务能使用的线程数量而将故障业务隔离,避免故障扩散

image-20231104155202114

服务熔断

由断路器统计请求的异常比例或慢调用比例,如果超出阈值则会熔断该业务,则拦截该接口的请求。熔断期间,所有请求快速失败,全部走 fallback 逻辑

image-20231104155623642

以上三种方案统称为服务降级方案(舍弃边缘业务,保证核心业务)
服务保护技术

image-20231104160019379

Hystrix 比较老,有一些项目还是在用的

Sentinel 相对来说新一些,是阿里巴巴出品的

Sentinel

初识

Sentinel 是阿里巴巴开源的一款微服务流量控制组件。官网地址:https://sentinelguard.io/zh-cn/index.html

image-20231104160610118

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

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

引入核心库,就可以实现相应的降级服务,但是需要编码实现,太麻烦了

因此配合控制台服务,可以实现接口监控,降级的配置等功能(搭建过程省略)

控制台服务启动好之后,就可以根据配置的ip和端口进行访问了:

image-20231104161409417

账号密码默认都是:sentinel

image-20231104161452937

微服务整合

  1. 引入依赖

    <!--sentinel-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId> 
        <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    </dependency>
  2. 配置控制台

    修改application:

    spring:
      cloud: 
        sentinel:
          transport:
            dashboard: localhost:8090
  3. 重启服务(比如 cart-service),访问某个接口,然后就可以在 Sentinel 控制台中看到相应的信息

簇点链路

就是单机调用链路。是一次请求进入服务后经过的每一个被 Sentinel 监控的资源链。默认 Sentinel 会监控 SpringMVC 的每一个 Endpoint(http接口)。限流、熔断等都是针对簇点链路中的资源设置的。而资源名默认就是接口的请求路径:

image-20231104161932469

但是 Restful 风格的 API 请求路径一般都相同,是以请求方式区分,这会导致簇点资源名称重复。因此我们要修改配置,把 请求方式 + 请求路径 作为簇点资源名称,需要进行配置:

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8090   # 控制台地址
      http-method-specify: true   # 开启请求方式匹配
请求限流

在簇点链路后面点击流控按钮,即可对其做限流配置:

image-20231104163100644

image-20231104163109789

QPS 就是每秒的请求次数

配置好限流之后通过 jmeter 进行测试,发现已经生效(QPS 为 6)

image-20231104165116846

image-20231104164649670

线程隔离

当商品服务出现阻塞或故障时,调用商品服务的购物车服务可能因此而被拖慢,甚至资源耗尽(所有的线程都被调用购物车给占用了)。所以必须限制购物车服务中查询商品这个业务的可用线程数,实现线程隔离

此处隔离应该是给某个服务的远程调用做配置,因此首先应该开启 OpenFeign 的支持(整合 Sentinel)
feign:
  sentinel:
    enabled: true   # 开启 Feign的 Sentinel 整合
案例中 cart-service 会远程调用 item-service

在开启 Feign 的 Sentinel 整合之后,访问 cart 的相关接口后,控制台就已经监控到了远程调用的信息,我们针对该信息,又可以做相应的配置和降级方案:

image-20231104165941479

这里还是通过流控来配置线程隔离,阈值类型选择并发线程数即可:

image-20231104170243277

image-20231104170315029

测试:

我们给 item-service 中被 cart-service 远程调用的接口加上 sleep(500),模拟阻塞现象(理论上 5 个最大线程的 QPS 最大为10)

image-20231104170526751

image-20231104171251033

Fallback

image-20231104172733201

FeignClient 的 Fallback 有两种配置方式:

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

以方式一为例:

步骤一:编写 Fallback 逻辑:

package com.hmall.api.client.fallback;

import com.hmall.api.client.ItemClient;
import com.hmall.api.dto.ItemDTO;
import com.hmall.api.dto.OrderDetailDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;

import java.util.Collection;
import java.util.Collections;
import java.util.List;

/**
 * @blog: <a href="https://www.hellocode.top">HelloCode.</a>
 * @Author: HelloCode.
 * @CreateTime: 2023-11-04  17:34
 * @Description: Item Fallback 函数
 */
@Slf4j
public class ItemClientFallbackFactory implements FallbackFactory<ItemClient> {
    @Override
    public ItemClient create(Throwable cause) {
        return new ItemClient() {
            @Override
            public List<ItemDTO> queryItemByIds(Collection<Long> ids) {
                log.error("查询商品异常:",cause);
                return Collections.emptyList();
            }

            @Override
            public void deductStock(List<OrderDetailDTO> items) {
                throw new RuntimeException(cause);
            }
        };
    }
}

在编写 fallback 时,也要根据业务来处理,比如查询商品,返回空集合即可(更友好);但是扣减库存,如果也只做日志记录,不抛出异常,那么用户也不会知道扣减库存并没有成功(应该把异常抛出去)

当开启限流或线程隔离时,请求被拒绝也会走 fallback 逻辑

步骤二:配置 FallbackFactory 为 Spring 的Bean

image-20231104175055543

步骤三:配置 Feign

image-20231104175135337

重启后,再次测试,发现被限流的请求不再报错,走了降级逻辑:

image-20231104175157456

服务熔断

image-20231104175414648

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

在请求限流和线程隔离时,拒绝的请求也会走 fallback 逻辑,但是他们都会去发送请求,出现异常或拒绝时才会走 fallback,而断路器不会,熔断后直接走 fallback 逻辑

image-20231104175916149

点击控制台簇点资源后的熔断按钮,即可配置熔断策略:

image-20231104180015028

image-20231104180027532

RT:Response Time(最大响应时间)

比例阈值在 0 到 1 之间

熔断时间截至后,就会到 Half-Open 状态,尝试放行一次请求,检查服务是否正常

控制台的配置在服务重启后就会失效,可以通过 nacos 远程配置来实现持久化,对应的方法可以搜索了解

分布式事务

下单业务,前端请求首先进入订单服务,创建订单并写入数据库。然后订单服务调用购物车服务和库存服务:

  • 购物车服务负责清理购物车信息
  • 库存服务负责扣减商品库存

image-20231105130028331

初识 Seata
  • Seata 是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
  • 官网地址:http://seata.io/,其中的文档、播客中提供了大量的使用说明、源码解析

image-20231105130253574

解决分布式事务,各个子事务之间必须能感知到彼此的事务状态,才能保证状态一致。(使用一个事务协调者来监控每一个服务的处理状态)

image-20231105130439462

Seata 事务管理中有三个重要的角色:

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

image-20231105130953989

任何服务都既有可能是 TM,也有可能是 RM(事务发起者就是 TM,参与者是 RM)

TC 服务也需要手动部署并启动,具体教程可以百度,参考:https://b11et3un53m.feishu.cn/wiki/QfVrw3sZvihmnPkmALYcUHIDnff

image-20231105135154818

微服务继承 Seata
  1. 在项目中引入 Seata 依赖:

    <!--seata-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    </dependency>
  2. 在 application.yml 中添加配置,让服务找到 TC 服务地址:

image-20231105133931586

配置文件还是很复杂的,因此通过 Nacos 配置中心来保存,达到共享的效果
seata:
  registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
    type: nacos # 注册中心类型 nacos
    nacos:
      server-addr: 192.168.36.128:8848 # nacos地址
      namespace: "" # namespace,默认为空
      group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
      application: seata-server # seata服务名称
  tx-service-group: hmall # 事务组名称
  service:
    vgroup-mapping: # 事务组与tc集群的映射关系
      hmall: "default"

最后给参与分布式事务的服务引入 Seata 依赖,导入共享配置即可(bootstrap.yaml)

image-20231105140116249

XA 模式
  • 前面已经引入了分布式事务的相关依赖,但是并没有使用分布式事务
  • 下单操作:创建订单,清空购物车,扣减库存;假设库存不足,那么扣减库存失败,但是没有分布式事务,创建订单和清空购物车不会回滚,就出现了问题
Seata 提供了很多分布式事务解决方案,不止 XA 和 AT 模式

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

image-20231105140741882

一阶段的工作:

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

二阶段的工作:

  • TC 检查各分支事务执行状态

    • 如果都成功,通知所有 RM 提交事务
    • 如果有失败,通知所有 RM 回滚事务
  • RM 接收 TC 指令,提交或回滚事务

优点:

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

缺点:

  • 因为一阶段不提交,会锁死数据资源,等待二阶段结束才释放,性能比较差
  • 依赖关系型数据库实现事务

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

  1. 修改 application.yml 文件(每个参与事务的微服务都需要,可以写在共享配置里),开启 XA 模式:

    seata:
      data-source-proxy-mode: XA    # 开启 XA 模式
  2. 给发起全局事务的入口方法添加 @GlobalTransactional 注解,本例是 OrderServiceImpl 的 create 方法:

    @Override
    @GlobalTransactional
    public Long createOrder(OrderFormDTO orderFormDTO) {
        // 省略......
    }
  3. 重启服务测试
AT 模式

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

image-20231105143649246

阶段一 RM 的工作:

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

阶段二提交时 RM 的工作:

  • 删除 undo-log

阶段二回滚时 RM 的工作:

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

优缺点:

  • 优点:阶段一直接提交,不锁定资源,性能高
  • 缺点:利用数据快照实现回滚,有短暂的不一致情况,是最终一致性

使用步骤:

  1. AT 模式需要记录快照,因此要给需要使用分布式事务的库添加一个 undo_log 表:
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `branch_id`     BIGINT       NOT NULL COMMENT 'branch transaction id',
    `xid`           VARCHAR(128) NOT NULL COMMENT 'global transaction id',
    `context`       VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
    `rollback_info` LONGBLOB     NOT NULL COMMENT 'rollback info',
    `log_status`    INT(11)      NOT NULL COMMENT '0:normal status,1:defense status',
    `log_created`   DATETIME(6)  NOT NULL COMMENT 'create datetime',
    `log_modified`  DATETIME(6)  NOT NULL COMMENT 'modify datetime',
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
  1. 修改 application.yml 文件,将事务模式修改为 AT 模式(因为是默认的,删除该配置项也可以)

    seata:
        data-source-proxy-mode: AT    # 开启数据源代理的 AT 模式
  2. 其他和 XA 模式一样,添加 @GlobalTransactional 注解即可
解决分布式事务的最佳实践是避免出现分布式事务,真正企业中很少用分布式事务框架解决分布式问题(分布式事务框架对性能消耗较大),大多采用的通知机制(mq)
最后修改:2023 年 11 月 13 日
如果觉得我的文章对你有用,请随意赞赏