Java微服务——SpringCloud

前言

本篇文章主要梳理一下,SpringCloud的一些知识的掌握及使用,这一部分知识所使用到的中间组件很多,要完成一个完整的微服务项目,难点在于这些中间组件具体是实现什么功能的、怎么把这些中间组件加入到项目中,至于这些组件在代码中具体怎么使用,在AI时代,并不是那么重要

SpringCloud的学习,主要是学习两个内容,第一个就是微服务的思想,第二个就是中间件

微服务思想

微服务思想就是把一个完整的单体项目,划分为多个子项目,每个子项目负责一部分功能,其实就是实现模块之间的高聚与解耦,这就是微服务思想。

我们在学习java后端架构时,以我为例子,写项目时有三个阶段

一:所有的功能都写在一个SpringBoot项目中,然后所有功能的代码都放在一个包甚至一个类下

二:项目进行了初步的划分,先创建一个空项目,然后在空项目中创建子项目,一个common,一个pojo,一个server

三:将项目进行微服务划分,先创建一个空项目,然后在空项目中创建子项目,每个子项目都实现一个功能,然后在这个子项目中,再编写有关负责功能的所有代码。除此之外,不同子模块使用不同的数据库,严谨来说,是不同子模块使用不同的数据库管理系统,但是我们在实际项目书写时,还是使用相同的数据库管理系统,使用不同的数据库来实现

第三个阶段就是微服务架构,这个思想看起来很简单,把业务拆分开来就完成了,确实,但是我们来看第二阶段和第三阶段,从第二阶段到第三阶段,其实架构复杂了很多,就会出现很多重复的工作

比如:每个子模块都要写配置文件

除此之外,模块与模块之间的调用也是一个问题

因此,我们下边将要学习微服务项目中,架构升级改进所带来的问题的解决办法,这些解决办法已经被很多中间件封装了,所以我们需要学习中间件的使用

实现

我们来具体看看一个微服务架构

这个架构初始的构建是:

首先先创建一个SpringBoot项目,删除一些不需要的东西

在这个项目中,创建java项目,勾选maven模块

创建完子项目之后,在java包下创建新包,这个包名尽量与父模块的包名前缀相同

这样一个完整的架构就创建出来了,我们后续所有的中间件的学习都基于这个架构来讲


我们上述是创建了一个基础的架构,仅仅做了业务模块的拆分,但是拆分之后,我们还要对其继续进行完善,确保架构的低耦合,也就是我们的中间件的功能

Nacos

我们把一个单体项目拆解成了多个项目,那么运行时, 也要运行多个项目,它们运行在不同的端口,那么如何管理?它们之间怎么通信?

服务注册与拉取

首先我们需要知道,在微服务远程调用的过程中,包括两个角色:

  • 服务提供者:提供接口供其它微服务访问

  • 服务消费者:调用其它微服务提供的接口

在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了**注册中心(Nacos)**的概念。注册中心、服务提供者、服务消费者之间的流程是这样的:

  • 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心

  • 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)

  • 调用者自己对实例列表负载均衡,挑选一个实例

  • 调用者向该实例发起远程调用

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

  • 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)

  • 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除

  • 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表

  • 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表

因此,我们要学习Nacos,它就是一个注册中心,有了Nacos,我们就知道有哪些服务启动了,并且实现负载均衡与动态加载

有了Nacos之后,我们只需要把每个组件在运行时,都注册进Nacos的服务列表

注册流程是这样的:

  • 引入依赖

  • 配置Nacos地址

  • 重启

至于具体的下载方法与注册、使用方法,不同版本的中间件是不同的,因此我不再具体的写依赖与配置的内容,大家可直接复制,去AI中,附上自己的Spring、jdk版本,即可得到正确的

服务的消费者想要使用某个组件中的内容,要去nacos订阅服务,这个过程就是服务发现,步骤如下:

  • 引入依赖

  • 配置Nacos地址

  • 发现并调用服务

通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个想要使用的服务的实例去调用即可(这个调用是HTTP请求)


上述我们实现了,服务启动时在注册中心注册,成功注册之后,后续想要调用它的服务,就可以在注册中心先拉取服务列表,查看有哪些服务可供调用,然后选择需要调用的服务,通过HTTP请求向服务的Controller中开发好的API发送请求即可拿到结果

但是在Java中发送HTTP请求,虽然不是一件麻烦的事,但是Nacos提供了更简便的做法,就是OpenFeign

OpenFeign

OpenFeign,就是在上述Nacos的基础上,提供的更简便的服务间相互调用的方式

使用方式:

  • 引入依赖

    复制代码
    <!--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>
  • 启用OpenFeign

在每个需要使用OpenFeign的模块的启动类的加入注解@EnableFeignClients

  • 编写OpenFeign客户端

    @FeignClient("模块名")
    public interface Test1Client {

    复制代码
      @GetMapping("/test")
      List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);

    }

这样写就代表向指定的模块中发送Get请求

  • 使用FeignClient

    private final Test1Client test1client;
    testclient1.queryItemByIds(ids);

这就是整个使用流程


除此之外,openfeign还有其他配置:

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

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

  • Apache HttpClient :支持连接池

  • OKHttp:支持连接池

因此我们通常会使用带有连接池的客户端来代替默认的HttpURLConnection。比如,我们使用OK Http.

使用流程:

  • 引入依赖

    <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId> </dependency>
  • 开启链接池(在配置文件中配置即可)

这样使得openfeign更加高效


此时,我们发现,如果项目足够复杂,那么每个模块都需要定义一个client类,而且每个类都需要配置连接池,因此,我们再做拆分

现在我们创建一个新的模块,模块名是统一的,就是api,代表我们后续模块间的通信

在这个模块中,我们引入openfeign的依赖,引入OKHttp的依赖,配置所有类的client

后续有哪个模块需要openfeign的功能时,只需将这个模块的依赖导入即可

注:使用OpenFeign的模块的启动类上一定要加注解,并且在注解中配置,client的位置

复制代码
@EnableFeignClients(basePackages = "com.mihuxiaobaigao.api.client")

这样才能正确使用


有关日志:

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

  • NONE:不记录任何日志信息,这是默认值。

  • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间

  • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息

  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。

如果想要规定日志,在api模块下新建一个配置类,定义Feign的日志级别:

复制代码
public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.FULL;
    }
}

同时要将其生效

复制代码
@EnableFeignClients(basePackages = "com.mihuxiaobaigao.api.client",defaultConfiguration = DefaultFeignConfig.class)

到这里,我们的OpenFeign的基础学习就完成了

配置管理

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

  • 在Nacos中添加共享配置

需要打开Nacos网站,然后在配置列表中添加统一格式的配置内容即可

  • 微服务拉取配置

我们要在微服务拉取共享配置。将拉取到的共享配置与本地的application.yaml配置合并,完成项目上下文的初始化。

不过,需要注意的是,读取Nacos配置是SpringCloud上下文(ApplicationContext)初始化时处理的,发生在项目的引导阶段。然后才会初始化SpringBoot上下文,去读取application.yaml

SpringCloud在初始化上下文的时候会先读取一个名为bootstrap.yaml(或者bootstrap.properties)的文件,如果我们将nacos地址配置到bootstrap.yaml中,那么在项目引导阶段就可以读取nacos中的配置了

因此,微服务整合Nacos配置管理的步骤如下:

  • 引入依赖

    复制代码
    <!--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>
  • 新建bootstrap.yaml

把有关的nacos的信息填写进bootstrap中,这样,就实现了配置的热更新

但是,现在又有了新的问题,那就是,这么多服务,我们的登录校验怎么做,前端无法调用nacos,它怎么知道调用我们后端的哪个端口呢?

下边我们开始讲述有关网关的配置

SpringCloudGateway

在以前的项目中,我们只有一个后端项目,启动时只有一个端口,前端也就只需要请求一个地方,但是现在,我们有多个端口,前端也不知道自己需要请求哪个,因此,我们需要定义一个新的模块gateway,这个模块的作用就是:拦截前端的请求,根据nacos判断前端请求的是哪个模块,然后把请求转发到那个模块去

配置流程:

  • 创建模块(gateway)

  • 引入依赖

    复制代码
          <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>
  • 创建启动类

  • 书写配置文件

    spring:
    application:
    name: gateway
    cloud:
    nacos:
    server-addr: 111.111.111.111:8848
    gateway:
    routes:
    - id: test1 # 路由规则id,自定义,唯一
    uri: lb://test1 # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
    predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
    - Path=/test1/,/test/ # 这里是以请求路径作为判断规则

这个配置文件很重要,它规定了,前端的请求转发到哪个模块去

这个predicates有很多的配置,SpringCloudGateway中支持的断言类型有很多,我这里不一一列举了

配置成功之后,我们就实现了前端请求的转发,但是现在问题是,单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取。

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

  • 每个微服务都需要知道JWT的秘钥,不安全

  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

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

  • 只需要在网关和用户服务保存秘钥

  • 只需要在网关开发登录校验功能

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

如图所示:

  1. 客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route ),然后将请求交给WebHandler去处理。

  2. WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain ),然后按照顺序逐一执行过滤器(后面称为**Filter**)。

  3. 图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为prepost两部分,分别会在请求路由到微服务之前之后被执行。

  4. 只有所有Filterpre逻辑都依次顺序执行通过后,请求才会被路由到微服务。

  5. 微服务返回结果后,再倒序执行Filterpost逻辑。

  6. 最终把响应结果返回。

因此我们只需要根据官方语法,创建一个拦截器并且启用,就完成了登录拦截

具体的拦截器的实现不再过多描述

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

然后我们在业务模块中,只需要定义一个拦截器,当请求到达时,根据网关存入请求头的名称,取出解析后的用户的信息,存入ThreadLocal,并且在请求结束后,将这个信息删除

这样我们就实现了前端到后端微服务的网关路由与登录

但是模块与模块之间的通信呢?

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

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

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

我们就在api模块中定义一个配置类:

复制代码
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
    return new RequestInterceptor() {
        @Override
        public void apply(RequestTemplate template) {
            // 获取登录用户
            Long userId = UserContext.getUser();
            if(userId == null) {
                // 如果为空则直接跳过
                return;
            }
            // 如果不为空则放入请求头中,传递给下游微服务
            template.header("user-info", userId.toString());
        }
    };
}

这样,在模块与模块之间发送请求时,也会带有token中解析的用户的信息

我们通过SpringCloudGateway实现了完整的请求拦截、与token解析、用户信息传递

网关的配置其实也能通过nacos进行热更新的,实现动态路由,这里不再叙述

Sentinel

经过我们上述的配置,每个模块之间都是基本都是有关联的,而且是有层级关系的,一定是从网关先进入,再进入其他模块,然后可能再调用其他模块,这样一层一层的调用

但是如果某一层的服务崩溃,就会引起连锁反应,导致上一层的服务崩溃,这样一层接一层的崩溃,就是雪崩问题,因此我们要做一些保护措施

微服务保护的方案有很多,比如:

  • 请求限流

服务故障最重要原因,就是并发太高!解决了这个问题,就能避免大部分故障。当然,接口的并发不是一直很高,而是突发的。因此请求限流,就是限制或控制接口访问的并发流量,避免服务因流量激增而出现故障。

  • 线程隔离

当一个业务接口响应时间长,而且并发高时,就可能耗尽服务器的线程资源,导致服务内的其它接口受到影响。所以我们必须把这种影响降低,或者缩减影响的范围。线程隔离正是解决这个问题的好办法。为了避免某个接口故障或压力过大导致整个服务不可用,我们可以限定每个接口可以使用的资源范围,也就是将其"隔离"起来。

  • 服务熔断

线程隔离虽然避免了雪崩问题,但故障服务依然会拖慢服务调用方的接口响应速度。

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

  • 编写服务降级逻辑:就是服务调用失败后的处理逻辑,根据业务场景,可以抛出异常,也可以返回友好提示或默认数据。

  • 异常统计和熔断:统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑。

下边,我们通过Sentinel来实现上述功能

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

  • 核心库(Jar包):不依赖任何框架/库,能够运行于 Java 8 及以上的版本的运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。在项目中引入依赖即可实现服务限流、隔离、熔断等功能。

  • 控制台(Dashboard):Dashboard 主要负责管理推送规则、监控、管理机器信息等。

在代码中配置使用:

  • 引入依赖

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

    spring:
    cloud:
    sentinel:
    transport:
    dashboard: localhost:8090
    http-method-specify: true # 开启请求方式前缀

配置完成之后,就可以在sentinel控制台中看到配置的请求

同时就可以在控制台配置:

在请求路径后的流控按钮中,配置QPS就是限流配置,配置并发线程数就是线程隔离

但是配置完成之后还有几个几个问题:

第一,超出的QPS上限的请求就只能抛出异常,从而请求失败。但从业务角度来说,请求失败,也应该展示给用户错误信息,用户体验更好。也就是给请求失败设置一个降级处理逻辑。

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

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

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

  • 方式一:FallbackClass,无法对远程调用的异常做处理

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

实现流程:

  • 在api模块中给给某个模块定义降级处理类,实现FallbackFactory

    @Slf4j
    public class Test1ClientFallback implements FallbackFactory<Test1Client> {
    @Override
    public Test1Client create(Throwable cause) {
    return new Test1Client() {
    @Override
    public List<Test1DTO> queryItemByIds(Collection<Long> ids) {
    log.error("远程调用Test1Client#queryItemByIds方法出现异常,参数:{}", ids, cause);
    // 查询失败,返回空集合
    return CollUtils.emptyList();
    }

    复制代码
              @Override
              public void deductStock(List<OrderDetailDTO> items) {
                  // 库存扣减业务需要触发事务回滚,查询失败,抛出异常
                  throw new BizIllegalException(cause);
              }
          };
      }

    }

  • 在config中,将其注册为一个Bean

  • 在Test1Client的类中,在FeignClient注解中,配置fallbackFactory

上边我们配置了错误处理,但是对于商品服务这种不太健康的接口,我们应该停止调用,直接走降级逻辑,避免影响到当前服务。也就是将商品查询接口熔断 。当商品服务接口恢复正常后,再允许调用。这其实就是断路器的工作模式了。

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

状态机包括三个状态:

  • closed:关闭状态,断路器放行所有请求,并开始统计异常比例、慢请求比例。超过阈值则切换到open状态

  • open :打开状态,服务调用被熔断,访问被熔断服务的请求会被拒绝,快速失败,直接走降级逻辑。Open状态持续一段时间后会进入half-open状态

  • half-open:半开状态,放行一次请求,根据执行结果来判断接下来的操作。

    • 请求成功:则切换到closed状态

    • 请求失败:则切换到open状态

配置方法:在控制台中,请求后的按钮中,点击熔断,然后配置熔断规则即可

这样我们就实现了分布式中服务安全的配置

Seata

Seata主要处理分布式事务,我们思考一下,我们以前的事务管理都是在一个数据库中操作的,但是在微服务架构下,每个模块都有自己的数据库,那么我们的以前的事务管理@Transtional就不管用了,就需要Seata来帮我们做分布式事务

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

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

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

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

  • TC ( Transaction Coordinator ) - **事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。

  • TM (Transaction Manager) - **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。

  • RM (Resource Manager) - **资源管理器:**管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

Seata的工作架构如图所示:

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

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

使用流程:

  • 引入依赖(每个参与事务管理的都要引)

    复制代码
    <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>
  • 改造配置(我们前边在nacos中配置了共享配置,因此我们这里在nacos中添加配置即可)

  • 把需要使用@Transtional注解的地方,改为@GlobalTranstional即可

Seata支持四种不同的分布式事务解决方案:

  • XA

  • TCC

  • AT

  • SAGA

大家有兴趣的自己了解一下

RabbitMQ

我们上边基于OpenFeign的调用都属于是同步调用,相当于我们模块间的通信都是同步进行的,按着顺序,先执行这个,再执行下一个,如果上一个没有执行完,那么有可能下一个就不会执行

同步调用的问题就是:

  • 拓展性差
  • 性能下降

由于我们采用了同步调用,调用者需要等待服务提供者执行完返回结果后,才能继续向下执行,也就是说每次远程调用,调用者都是阻塞等待状态。最终整个业务的响应时长就是每次远程调用的执行时长之和

  • 级联失效

由于我们是基于OpenFeign调用交易服务、通知服务。当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。

而要解决这些问题,我们就必须用异步调用 的方式来代替同步调用

异步调用方式其实就是基于消息通知的方式,一般包含三个角色:

  • 消息发送者:投递消息的人,就是原来的调用方

  • 消息Broker:管理、暂存、转发消息,你可以把它理解成微信服务器

  • 消息接收者:接收和处理消息的人,就是原来的服务提供方

在异步调用中,发送者不再直接同步调用接收者的业务接口,而是发送一条消息投递给消息Broker。然后接收者根据自己的需求从消息Broker那里订阅消息。每当发送方发送消息后,接受者都能获取消息并处理。

这样,发送消息的人和接收消息的人就完全解耦了。

异步调用的优势包括:

  • 耦合度更低

  • 性能更好

  • 业务拓展性强

  • 故障隔离,避免级联失败

当然,异步通信也并非完美无缺,它存在下列缺点:

  • 完全依赖于Broker的可靠性、安全性和性能

  • 架构复杂,后期维护和调试麻烦

我们这里要讲述的,也就是用的最多的就是RabbitMQ

RabbitMQ其中包含几个概念:

  • publisher:生产者,也就是发送消息的一方

  • consumer:消费者,也就是消费消息的一方

  • queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理

  • exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。

  • virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue

我们在使用RabbitMQ时,其实所有的功能都可以在网站上,图形化界面实现

实现的流程:

  • 创建一个exchange
  • 创建一个queue
  • 将exchange与queue binding起来

这样就能够通过exchange向queue发送信息了

注意:Exchange( 交换机 只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!

在Java代码中,是通过SpringAMQP实现的

发送信息实现流程:

  • 引入依赖

    复制代码
          <dependency>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-starter-amqp</artifactId>
          </dependency>
  • 书写配置

  • 编写测试类

    @SpringBootTest
    public class SpringAmqpTest {

    复制代码
      @Autowired
      private RabbitTemplate rabbitTemplate;
    
      @Test
      public void testSimpleQueue() {
          // 队列名称
          String queueName = "simple.queue";
          // 消息
          String message = "hello, spring amqp!";
          // 发送消息
          rabbitTemplate.convertAndSend(queueName, message);
      }

    }

接收信息实现流程:

  • 书写配置

  • 编写测试

    package com.itheima.consumer.listener;

    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;

    @Component
    public class SpringRabbitListener {
    // 利用RabbitListener来声明要监听的队列信息
    // 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
    // 可以看到方法体中接收的就是消息体的内容
    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueueMessage(String msg) throws InterruptedException {
    System.out.println("spring 消费者接收到消息:【" + msg + "】");
    }
    }

这就是最简单的使用

我们还可以定义多个queue接受信息,这叫做WorkQueues

但是需要注意:如果exchange发送了50条消息,此时有两个queue,那么每个queue只能拿到25条,与速度无关

这两个queue接收信息是这样的,第一个接收第一条,第二个接收第二条,然后第一个再接收第三条,这样的,但是如果两个queue的速度差距特别大,也需要先等待速度慢的接收之后,速度快的再接收。

不过我们可以在配置文件中书写相关配置,使得能者多劳,不用再等待

下边我们讲exchange

交换机的类型有四种:

  • Fanout:广播,将消息交给所有绑定到交换机的队列。我们最早在控制台使用的正是Fanout交换机

    • 1) 可以有多个队列
    • 2) 每个队列都要绑定到Exchange(交换机)
    • 3) 生产者发送的消息,只能发送到交换机
    • 4) 交换机把消息发送给绑定过的所有队列
    • 5) 订阅队列的消费者都能拿到消息
  • Direct:订阅,基于RoutingKey(路由key)发送给订阅了消息的队列

    在Direct模型下:

    • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key)
    • 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey。
    • Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断,只有队列的Routingkey与消息的 Routing key完全一致,才会接收到消息
  • Topic:通配符订阅,与Direct类似,只不过RoutingKey可以使用通配符

    Topic类型的Exchange与Direct相比,都是可以根据RoutingKey把消息路由到不同的队列。
    只不过Topic类型Exchange可以让队列在绑定BindingKey 的时候使用通配符!

    BindingKey 一般都是有一个或多个单词组成,多个单词之间以.分割,例如: item.insert

    通配符规则:

    • #:匹配一个或多个词
    • *:匹配不多不少恰好1个词

    举例:

    • item.#:能够匹配item.spu.insert 或者 item.spu
    • item.*:只能匹配item.spu
  • Headers:头匹配,基于MQ的消息头匹配,用的较少,不再过多讲述。

在java代码中,这些exchange的实现,也都有专门的类,并不困难

注意:如果使用exchange发送了一个复杂的类型,比如Map等,那么得到的结果很难理解,因此可以使用json序列化转换器得到最后的结果

这就是RabbitMQ的基础使用

下边我们讲解一些高级的,看看如何确保生产者一定能把消息发送到MQ

首先第一种情况,就是生产者发送消息时,出现了网络故障,导致与MQ的连接中断。

为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。即:当RabbitTemplate与MQ连接超时后,多次重试。

修改publisher模块的application.yaml文件即可

复制代码
spring:
  rabbitmq:
    connection-timeout: 1s # 设置MQ的连接超时时间
    template:
      retry:
        enabled: true # 开启超时重试机制
        initial-interval: 1000ms # 失败后的初始等待时间
        multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
        max-attempts: 3 # 最大重试次数

一般情况下,只要生产者与MQ之间的网路连接顺畅,基本不会出现发送消息丢失的情况,因此大多数情况下我们无需考虑这种问题。

不过,在少数情况下,也会出现消息发送到MQ之后丢失的现象,比如:

  • MQ内部处理消息的进程发生了异常

  • 生产者发送消息到达MQ后未找到Exchange

  • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由

针对上述情况,RabbitMQ提供了生产者消息确认机制,包括Publisher ConfirmPublisher Return两种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执

总结如下:

  • 当消息投递到MQ,但是路由失败时,通过Publisher Return返回异常信息,同时返回ack的确认信息,代表投递成功

  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功

  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功

  • 其它情况都会返回NACK,告知投递失败

其中acknack属于Publisher Confirm 机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。

默认两种机制都是关闭状态,需要通过配置文件来开启。

实现生产者确认机制:

  • 配置文件书写

    spring:
    rabbitmq:
    publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
    publisher-returns: true # 开启publisher return机制

这里publisher-confirm-type有三种模式可选:

  • none:关闭confirm机制

  • simple:同步阻塞等待MQ的回执

  • correlated:MQ异步回调返回回执

一般我们推荐使用correlated,回调机制。

  • 定义ReturnCallback

每个RabbitTemplate只能配置一个ReturnCallback,因此我们可以在配置类中统一设置。我们在publisher模块定义一个配置类:

复制代码
package com.itheima.publisher.config;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;

@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init(){
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.error("触发return callback,");
                log.debug("exchange: {}", returned.getExchange());
                log.debug("routingKey: {}", returned.getRoutingKey());
                log.debug("message: {}", returned.getMessage());
                log.debug("replyCode: {}", returned.getReplyCode());
                log.debug("replyText: {}", returned.getReplyText());
            }
        });
    }
}
  • 定义ConfirmCallback

由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义。具体来说,是在调用RabbitTemplate中的convertAndSend方法时,多传递一个参数

复制代码
@Test
void testPublisherConfirm() {
    // 1.创建CorrelationData
    CorrelationData cd = new CorrelationData();
    // 2.给Future添加ConfirmCallback
    cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
        @Override
        public void onFailure(Throwable ex) {
            // 2.1.Future发生异常时的处理逻辑,基本不会触发
            log.error("send message fail", ex);
        }
        @Override
        public void onSuccess(CorrelationData.Confirm result) {
            // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
            if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
                log.debug("发送消息成功,收到 ack!");
            }else{ // result.getReason(),String类型,返回nack时的异常描述
                log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
            }
        }
    });
    // 3.发送消息
    rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}

注意

开启生产者确认比较消耗MQ性能,一般不建议开启。而且大家思考一下触发确认的几种情况:

  • 路由失败:一般是因为RoutingKey错误导致,往往是编程导致

  • 交换机名称错误:同样是编程错误导致

  • MQ内部故障:这种需要处理,但概率往往较低。因此只有对消息可靠性要求非常高的业务才需要开启,而且仅仅需要开启ConfirmCallback处理nack就可以了。


消息到达MQ以后,如果MQ不能及时保存,也会导致消息丢失,所以MQ的可靠性也非常重要。

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:

  • 交换机持久化

  • 队列持久化

  • 消息持久化

配置交换机与队列持久化时,只需要在创建时,使用Durable

配置消息持久化时,只需要在创建时,使用Persistent

说明:在开启持久化机制以后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性。

不过出于性能考虑,为了减少IO次数,发送到MQ的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在100毫秒左右,这就会导致ACK有一定的延迟,因此建议生产者确认全部采用异步方式。


在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:

  • 消费者宕机或出现网络故障

  • 消息发送量激增,超过了消费者处理速度

  • 消费者处理业务发生阻塞

一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。

为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存

  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)

  • 支持数百万条的消息存储

而在3.12版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为LazyQueue模式。

在利用SpringAMQP声明队列的时候,添加x-queue-mod=lazy参数可设置队列为Lazy模式

也可以在控制台在添加队列的时候,添加x-queue-mod=lazy参数

对于已经存在的队列,也可以配置为lazy模式,但是要通过设置policy实现。


当RabbitMQ向消费者投递消息以后,需要知道消费者的处理状态如何。因为消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:

  • 消息投递的过程中出现了网络故障

  • 消费者接收到消息后突然宕机

  • 消费者接收到消息后,因处理不当导致异常

  • ...

一旦发生上述情况,消息也会丢失。因此,RabbitMQ必须知道消费者的处理状态,一旦消息处理失败才能重新投递消息。

但问题来了:RabbitMQ如何得知消费者的处理状态呢?

消费者确认机制

为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)。即:当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ从队列中删除该消息

  • nack:消息处理失败,RabbitMQ需要再次投递消息

  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

一般reject方式用的较少,除非是消息格式有问题,那就是开发问题了。因此大多数情况下我们需要将消息处理的代码通过try catch机制捕获,消息处理成功时返回ack,处理失败时返回nack.

由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用

  • manual :手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活

  • auto :自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:

    • 如果是业务异常 ,会自动返回nack

    • 如果是消息处理或校验异常 ,自动返回reject;


后续还有很多方式,来确保各个环节的稳定,到这里篇幅已经很长了,我就不再过多叙述

EleasticSearch

这个中间件主要解决搜素问题,普通的数据库的查询都是走的数据库的模糊查询,性能差,并且随着数据量的增大,查询速度会越来越慢,EleasticSearch就是解决这个问题

elasticsearch之所以有如此高性能的搜索表现,正是得益于底层的倒排索引技术。倒排 索引的概念是基于MySQL这样的正向索引而言的。

倒排索引中有两个非常重要的概念:

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息

  • 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条

创建倒排索引是对正向索引的一种特殊处理和应用,流程如下:

  • 将每一个文档的数据利用分词算法根据语义拆分,得到一个个词条

  • 创建表,每行数据包括词条、词条所在文档id、位置等信息

  • 因为词条唯一性,可以给词条创建正向索引

此时形成的这张以词条为索引的表,就是倒排索引表

那么为什么一个叫做正向索引,一个叫做倒排索引呢?

  • 正向索引 是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程

  • 倒排索引 则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程

倒排索引

  • 优点:

    • 根据词条搜索、模糊搜索时,速度非常快
  • 缺点:

    • 只能给词条创建索引,而不是字段

    • 无法根据字段做排序

这就是对于EleasticSearch的介绍,并且我们知道了为什么要用它

那是不是说,我们学习了elasticsearch就不再需要mysql了呢?

并不是如此,两者各自有自己的擅长之处:

  • Mysql:擅长事务类型操作,可以确保数据的安全和一致性

  • Elasticsearch:擅长海量数据的搜索、分析、计算

因此在企业中,往往是两者结合使用:

  • 对安全性要求较高的写操作,使用mysql实现

  • 对查询性能要求较高的搜索需求,使用elasticsearch实现

  • 两者再基于某种方式,实现数据的同步,保证一致性

后续的学习就是对DSL查询语句的使用,以及IK分词器

分词器的作用是什么?

  • 创建倒排索引时,对文档分词

  • 用户搜索时,对输入的内容分词

IK分词器有两种模式:

  • ik_smart:智能切分,粗粒度

  • ik_max_word:最细切分,细粒度

IK分词器如何拓展词条?如何停用词条?

  • 利用config目录的IkAnalyzer.cfg.xml文件添加拓展词典和停用词典

  • 在词典中添加拓展词条或者停用词条

具体的DSL的语句的使用不再说明

总结

这篇文章对于Nacos、OpenFeign、SpringCloudGateway、Sentinel、Seata的讲述还是很清晰的

至于RabbitMQ、EleasticSearch的描述中,有部分内容做了省略

不过如果能够仔细看完这篇文章,我相信,你对于微服务的了解就很深刻了

------迷糊小白告

相关推荐
qq_269870431 小时前
java rabbitmq 队列在Springboot的设计
java·rabbitmq·java-rabbitmq
abcnull1 小时前
Springboot+Vue2的Web项目小白入门Demo快速学习!
java·elementui·vue·maven·springboot·web·小白
2501_932750261 小时前
Java IO流基础全面详解:字节流、字符流
java·开发语言
逸Y 仙X2 小时前
文章二十二:ElasticSearch EQL事件查询语言
java·大数据·elasticsearch·搜索引擎·全文检索
liulilittle2 小时前
LLAMA-CLI 运行千问3.6(R9-7945HX+64G+RTX40608G)
java·前端·llama
后端漫漫2 小时前
Redis 键值对序列化
java·redis
Lenyiin2 小时前
《LeetCode 顺序刷题》61 - 70
java·c++·python·算法·leetcode·lenyiin
敲代码的瓦龙2 小时前
Android?基础UI控件!!!
java·开发语言
Hesionberger2 小时前
LeetCode 78:子集生成全攻略
java·开发语言·数据结构·python·算法·leetcode·职场和发展