微服务基础

代码地址

练手项目github地址

Docker

image-20240727131821320

image-20240727142536189

image-20240727142715858

image-20240727144422081

自定义网络中可以通过容器名相互访问。

image-20240727144551100

image-20240727151818613

version: "3.8"

services:
  mysql:
    image: mysql
    container_name: mysql
    ports:
      - "3306:3306"
    environment:
      TZ: Asia/Shanghai
      MYSQL_ROOT_PASSWORD: 123
    volumes:
      - "./mysql/conf:/etc/mysql/conf.d"
      - "./mysql/data:/var/lib/mysql"
      - "./mysql/init:/docker-entrypoint-initdb.d"
    networks:
      - hm-net
  hmall:
    build: 
      context: .
      dockerfile: Dockerfile
    container_name: hmall
    ports:
      - "8080:8080"
    networks:
      - hm-net
    depends_on:
      - mysql
  nginx:
    image: nginx
    container_name: nginx
    ports:
      - "18080:18080"
      - "18081:18081"
    volumes:
      - "./nginx/nginx.conf:/etc/nginx/nginx.conf"
      - "./nginx/html:/usr/share/nginx/html"
    depends_on:
      - hmall
    networks:
      - hm-net
networks:
  hm-net:
    name: hmall

image-20240727152244156

# mysql docker run
docker run -d \
  --name mysql \
  -p 3306:3306 \
  -e TZ=Asia/Shanghai \
  -e MYSQL_ROOT_PASSWORD=123 \
  -v /root/mysql/data:/var/lib/mysql \
  -v /root/mysql/conf:/etc/mysql/conf.d \
  -v /root/mysql/init:/docker-entrypoint-initdb.d \
  --network my-net\
  mysql

微服务

注册中心

原理:注册,订阅,续约,负载均衡

image-20240728195733298

nacos配置:

docker run -d \
--name nacos \
--env-file /root/docker/nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
--network my-net\
nacos/nacos-server:v2.1.0-slim

OpenFeign连接池:

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

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

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

<!--OK http 的依赖 -->
<dependency>
  <groupId>io.github.openfeign</groupId>
  <artifactId>feign-okhttp</artifactId>
</dependency>
feign:
  okhttp:
    enabled: true # 开启OKHttp功能

拆出api时调用方找不到bean——调用方配置包扫描路径

@EnableFeignClients(basePackages = "com.hmall.api.client")

feign配置日志级别:

  • OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。
  • 而且其日志级别有4级:
    • NONE:不记录任何日志信息,这是【默认值】。
    • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
    • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
    • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

配置日志方式:

// api包下
public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.FULL;
    }
}

// 需要调试的服务的启动类注解中, defaultConfig=xxx
@EnableFeignClients(basePackages = "com.hmall.api.client", defaultConfiguration = com.hmall.api.config.DefaultFeignConfig.class)

网关

解决问题:身份校验,路由image-20240729035649959

网关请求处理流程:定义处理逻辑,定义过滤器链顺序

image-20240729105844554

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

  • GlobalFilter:全局过滤器,作用范围是所有路由,不可配置.【只需要添加自定义路由即可】

  • GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route. 【由于携带参数,所以实际上是定义一个工厂类;Springboot内置了许多GatewayFilter】

关于身份校验:

image-20240729132045994

mvc拦截器配置:

  • 编写拦截器逻辑:preHandle, postHandle, afterCompletion
  • 写配置类,配置拦截器:addInterceptors
  • 自动装配配置类:spring.facotories

image-20240729122944704

为什么定义在hm-common
因为所有的微服务(除了网关),都要添加这个拦截器,而所有微服务都引用了hm-common,所以直接在common添加配置类
如何让配置在网关不生效

使用条件装配注解@Conditional,由于只有网关没有导入mvc,所以可以用是否存在mvc的核心类作为区分,即@ConditionalOnClass((DispatcherServlet.class)

配置管理

配置流程:

image-20240730130255876

添加依赖:

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

配置管理:

  1. 配置bootstrap.yml
  2. 把公共的配置配置到nacos中

image-20240730142040438

配置热更新:

image-20240730141603355

网关动态路由:略

服务保护

服务保护方案:

  1. 请求限流(水坝)
  2. 线程隔离(船舱)
  3. 服务熔断(保险丝)

image-20240803143726986

Sentinel Hystrix
限流 基于QPS,支持流量整形 有限的支持
线程隔离 信号量隔离 线程池隔离/信号量隔离
Fallback 支持 支持
熔断策略 基于慢调用比例或异常比例 基于异常比率
控制台 开箱即用,可配置规则、查看秒级监控、机器发现等 不完善
配置方式 基于控制台,重启后失效 基于注解或配置文件,永久生效

Sentinel快速入门:

  1. 引入依赖:
<!--sentinel-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>
  1. 添加配置:(可以配置到nacos共享中)
spring:
  cloud: 
    sentinel:
        transport:
            dashboard: 192.168.10.100:8858
        http-method-specify: true #开启请求方式前缀
  1. docker部署dashboard:
# 查看版本
docker search sentinel
# 下载镜像的最新版本
docker pull bladex/sentinel-dashboard
# 运行容器 Sentinel默认端口 8858
docker run --name sentinel -p 8858:8858 -td bladex/sentinel-dashboard
# 访问Sentinel监控平台 http://localhost:8858/
账户:sentinel
密码:sentinel
(监控的接口需要被访问一下才会显示在监控台中)

Feign调用添加fallback【Feign整合Sentinel】

sentinel的监控对象为触点,那么怎么监视feign调用添加fallback逻辑呢?

  1. 添加配置(服务模块配置)
feign:
  sentinel:
    enabled: true # 开启feign对sentinel的支持
  1. 编写fallback工厂类,注册成Bean

  2. FeignClient注解添加fallback参数

@FeignClient(value = "item-service", fallback = ItemClientFallbackFactory.class)

image-20240803165806837

分布式事务

Seata架构:

image-20240803174343395

Seata服务部署:

seata是一个独立的微服务,需要自己部署,步骤如下:

  1. 由于采用mysql持久化,先导入数据表 seata-tc.sql
  2. 复制配置文件夹seata/application.yml
  3. 加载并启动容器

| 文件连接

docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.10.100 \ #虚拟机地址
-v /root/docker/seata:/seata-server/resources \
--privileged=true \
--network my-net \
-d \
seataio/seata-server:1.5.2

导入Seata:

  1. 导入依赖
  <!--统一配置管理-->
  <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>
  1. 添加配置文件【做成共享配置,shared-seata.yml】
seata:
  registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
    type: nacos # 注册中心类型 nacos
    nacos:
      server-addr: 192.168.10.100:8848 # nacos地址
      namespace: "" # namespace,默认为空
      group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
      application: seata-server # seata服务名称
      username: nacos
      password: nacos
  tx-service-group: hmall # 事务组名称
  service:
    vgroup-mapping: # 事务组与tc集群的映射关系
      hmall: "default"

nacos中确定一个服务: namespace-group-service-cluster

image-20240804145522970

  1. 如果报错 Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int, 是因为jdk不是1.8导致的,解决方法如下
# 添加虚拟机选项
--add-opens java.base/java.lang=ALL-UNNAMED

XA模式(强一致性):

image-20240804154032449

  1. 修改配置文件,开启XA模式
seata:
	data-source-proxy-mode: XA
  1. 全局事务入口添加 @GlobalTransational注解

AT模式(最终一致性):

image-20240804154105167

  1. 每个服务的数据库添加undolog数据表 seata-at.log
  2. 修改配置(默认就是AT)
seata:
	data-source-proxy-mode: TA
  1. 全局事务入口添加 @GlobalTransational注解

RabbitMQ

基本概念

docker部署:

docker run \
 -e RABBITMQ_DEFAULT_USER=rabbitmq \
 -e RABBITMQ_DEFAULT_PASS=rabbitmq \
 -v mq-plugins:/plugins \ #挂载数据卷,挂载本地目录会报错
 --name mq \
 --hostname mq \
 -p 15672:15672 \
 -p 5672:5672 \
 --network my-net\
 -d \
 rabbitmq:management

基本概念:

image-20240812215716446

配置

<!--AMQP依赖,包含RabbitMQ-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
spring:
  rabbitmq:
    host: localhost
    virtual-host: /
    port: 5672
    username: rabbitmq
    password: rabbitmq

队列模型

  1. workQueue:默认轮询,每个消息处理一次。加快消息处理速度

image-20240812230615441

能者多劳【通过配置prefetch】

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
  1. fanout exchange:广播

image-20240812232142009

  1. direct exchange:定向(基于bingKey做路由)

image-20240812233002721

  1. topic exchange:订阅(bingKey支持通配符)

image-20240812234156263

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

声明队列和交换机

  1. 基于Bean
@Configuration
public class FanoutConfiguration {
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange("my.fanout");
    }

    @Bean
    public Queue fanoutQueue1() {
        return new Queue("fanout.queue1");
    }

    @Bean
    public Binding bindingFanoutQueue1(FanoutExchange fanoutExchange, Queue fanoutQueue1) {
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }
}
  1. 基于注解
// 基于注解声明队列和交换机
@RabbitListener(bindings = @QueueBinding(
    value = @Queue("direct.queue1"),
    exchange = @Exchange(value = "my.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "green"}
))
public void directQueueListener1(String message) {
    log.info("监听到来自direct.queue1的消息: {}", message);
}

消息转换器

采用JSON序列化代替默认的JDK序列化

  • 引入jackson依赖
<!--Jackson-->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.10</version>
</dependency>
  • 在publisher和consumer中都要配置MessageConverter
@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jackson2JsonMessageConverter.setCreateMessageIds(true);
    return jackson2JsonMessageConverter;
}

消息转换器中添加的messageId可以便于我们将来做幂等性判断

消息可靠性

发送者可靠性

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

SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。

如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。

  1. 发送者确认
  • ReturnCallback:是否成功路由。只需要绑定一个统一回调。
  • ConfirmCallback:确认是否到达 Exchange 【不管路由是否成功,到达队列】。每个消息都要绑定自己的回调。

ReturnCallback在交换器路由不到队列时触发回调,除非代码绑定队列出错,否则不会触发,所以一般不用。

ConfirmCallback会导致发消息时频繁与Mq通信,所以如果不是必要,也一般不用,如果使用,注意在收到nack时不要重试太多次,避免严重影响性能。

spring:
  rabbitmq:
    publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型(simple:同步阻塞, correlated:异步回调)
    publisher-returns: true # 开启publisher return机制

returnCallback配置:

@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配置:

@Test
public void testConfirmCallback() {
    // 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("my.direct", "green", "hello spring amq", cd);
}

MQ可靠性

  1. 数据持久化

交换机持久化【默认】,队列持久化【默认】,消息持久化。

临时消息存在内存,消息过多时会导致扇出,造成阻塞。

image-20240813205617405

持久化的消息存在内存,同时也直接保存到磁盘,避免扇出造成太大阻塞,且性能也不差。

image-20240813205524492

Message message = MessageBuilder.withBody("hello spring amq".getBytes())
                .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
                .build();
  1. lazyQueue

消息直接存入磁盘,不存到内存,且针对写磁盘进行优化,解决了一般队列+数据持久化的并发问题。

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

Bean配置:

@Bean
public Queue lazyQueue() {
    return QueueBuilder
        .durable("lazy.queue")
        .lazy() // 声明队列为延迟队列
        .build();
}

注解配置:

@RabbitListener(queuesToDeclare = @Queue(
    value = "lazy.queue",
    durable = "true",
    arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void lazyQueueListener(String message) {
    log.info("监听到来自lazy.queue的消息: {}", message);
}

控制台配置:

image-20240813215853389

消费者可靠性

  1. 消费者消息确认【默认开启】

image-20240814101547876

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活
  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:
    • 如果是业务异常,会自动返回nack
    • 如果是消息处理或校验异常,自动返回reject;

配置:

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto #自动处理【默认】
  1. 失败重试【默认关闭】

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。

极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力:

配置

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          #下面都是默认配置
          initial-interval: 1000ms # 初始的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false

默认情况下,如果重试三次失败,就会直接丢弃消息,造成可靠性下降,可以修改策略。

image-20240814103120696

配置第三种方式

@Configuration
@Slf4j
public class ErrorMessageConfig {
    @Bean
    public Queue errorMessageQueue() {
        return new Queue("error.queue");
    }

    @Bean
    public DirectExchange errorMessageExchange() {
        return new DirectExchange("error.exchange");
    }

    @Bean
    public Binding errorMessageBinding(Queue errorMessageQueue, DirectExchange errorMessageExchange) {
        return BindingBuilder.bind(errorMessageQueue).to(errorMessageExchange).with("error");
    }

    @Bean
    public RepublishMessageRecoverer errorMessageRecoverer(RabbitTemplate rabbitTemplate) {
        return new RepublishMessageRecoverer(rabbitTemplate, "error.exchange", "error");
    }
}

image-20240814104913500

  1. 幂等业务处理

唯一消息id

  • 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
  • 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
  • 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jjmc.setCreateMessageIds(true);
    return jjmc;
}

延迟消息

采用延时消息实现延时任务

应用场景:对于超过一定时间未支付的订单,取消订单并释放占用的库存。

image-20240814221732408

  1. 死信交换机+TTL

死信(Dead Letter):

  • 消费者使用basic.rejectbasic.nack声明消费失败,并且消息的requeue参数设置为false
  • 消息是一个过期消息,超时无人消费
  • 要投递的队列消息满了,无法投递

如果一个队列中的消息已经成为死信,并且这个队列通过dead-letter-exchange属性指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机就称为死信交换机(Dead Letter Exchange)。

image-20240814222950153

image-20240814231640835

image-20240814231658085

发送消息时指定过期时间:

@Test
public void testDelayMessage() {
    rabbitTemplate.convertAndSend("normal.exchange", "normal.key", "hello dl message", message -> {
        message.getMessageProperties().setExpiration("10000");
        return message;
    });
}
  1. 延时消息插件

image-20240814231145788

下载插件:github地址

将插件文件移动到对应的插件目录下(一般为/plugins)后,执行:

docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange

注解方式声明:

// 延时交换机
@RabbitListener(bindings = @QueueBinding(
    value = @Queue("delay.queue"),
    exchange = @Exchange(value = "delay.exchange", type = ExchangeTypes.DIRECT, delayed = "true"),
    key = {"delay.key"}
))
public void delayQueue(String message) {
    log.info("监听到来自delay.queue的消息: {}", message);
}

Bean方式声明:

@Slf4j
@Configuration
public class DelayExchangeConfig {

    @Bean
    public DirectExchange delayExchange(){
        return ExchangeBuilder
                .directExchange("delay.exchange") // 指定交换机类型和名称
                .delayed() // 设置delay的属性为true
                .durable(true) // 持久化
                .build();
    }

    @Bean
    public Queue delayedQueue(){
        return new Queue("delay.queue");
    }
    
    @Bean
    public Binding delayQueueBinding(){
        return BindingBuilder.bind(delayedQueue()).to(delayExchange()).with("delay");
    }
}

发送时指定延时时间:

@Test
public void testDelayMessageByPlugin() {
    rabbitTemplate.convertAndSend("delay.exchange", "delay.key", "hello delay message by plugin", message -> {
        message.getMessageProperties().setDelay(10000);
        return message;
    });
}

延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时。

如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。

因此,不建议设置延迟时间过长的延迟消息

最佳实践:取消订单

场景:

image-20240818080549218

  • 用户下单:调用交易服务,保存订单,扣减库存(锁单,防止超卖)

  • 用户支付:调用支付服务,扣减余额,通过MQ通知交易服务更新订单状态为已支付

  • 存在问题:

    • 如果用户迟迟没有支付,库存被扣减没有恢复
    • 如果MQ故障,那么即使用户已支付,订单状态也没有更新
  • 解决办法:一段时间后,交易服务check一下支付服务,是否有支付记录

    • 已支付:更新订单状态为已支付
    • 未支付:更新订单状态为关闭,恢复商品库存

    image-20240818081322030

  • 解决方式:延迟消息

posted @ 2024-09-07 19:21  咪啪魔女  阅读(3)  评论(0编辑  收藏  举报