剖析RabbitMQ消息可靠投递

文章目录

  • 剖析RabbitMQ消息可靠投递
    • 1. RabbitMQ的工作流程
    • 2. 消息可靠性投递
      • 2.1 剖析消息可靠发送(上半场)
      • 2.2 剖析消息可靠消费(下半场)
      • 2.3 剖析消息幂等性保障
      • 2.4 Redis消息幂等设计

剖析RabbitMQ消息可靠投递

1. RabbitMQ的工作流程

针对协议AMQP 0-9-1(开发常用协议)

剖析RabbitMQ消息可靠投递_第1张图片

消息队列工作原理分三个角色:消息生产者、消息队列服务器、消息消费者

RabbitMQ服务器内部包含多个虚拟主机,虚拟主机用于隔离不同的应用环境,每个虚拟主机都有自己的交换机、队列和绑定关系。交换机、队列和绑定关系可以通过代码或者可视化界面来构建。交换机负责接收并根据路由键将消息转发到绑定的队列,交换机和消息队列通过路由键相互绑定(路由模式下),消息队列是MQ中用于存储消息的队列(顾名思义,队列先进后出)

消息生产者和消息消费者通过TCP连接消息队列服务器,单个TCP连接对应多个信道,每个信道对应多个线程,或者说对应多个消息的生产和消费过程

消息生产者通过信道将消息发送到消息队列的交换机,交换机接收并根据路由键将消息发送并存储到消息队列,消费者通过信道将消息从消息队列中取出并完成消息

2. 消息可靠性投递

消息可靠投递是指消息从生产者发送到消息队列,再从消息队列到消费者完成消费过程,不丢失消息或重复处理消息

消息的可靠性投递分为消息可靠发送消息可靠存储消息可靠消费

剖析RabbitMQ消息可靠投递_第2张图片

消息可靠性投递分为上下两个半场

  • 上半场123:生产者发布消息,MQ服务端接收消息并落库持久化,响应ACK确认消息成功投递(对应消息可靠发送和消息可靠存储)
  • 下半场456:消费者接收消息处理业务逻辑,消费者成功消费并响应ACK到MQ服务端,MQ服务端接收到ACK后删除消息(对应消息可靠消费)

消息的可靠性投递分为上下半场,消息的幂等性保证也分为上下半场

2.1 剖析消息可靠发送(上半场)

消息可靠发送指生产者发送消息到消息队列,消息队列持久化并响应确认的过程。

消息可靠发送通过事务消息或者发布者确认机制保证,事务性消息不必要的繁重,更推荐发布确认机制来保证消息可靠发送。发布者确认机制通过消息发送回调接口或备用交换机实现

  • 回调接口(应答机制+失败重试):配置文件开启消息确认机制,配置消息发送回调接口到`rabbitTemplate``
    • ConfirmCallback接口用于确认消息是否发送到交换机(返回 truefalse 来告知消息是否到达交换机)
    • ReturnsCallback接口用于确认消息是否发送到队列(仅在消息没有发送到队列时调用)
  • 备用交换机:消息队列中的消息过期、被拒绝、达到最大重试次数,或者无匹配队列等情况,就会转发到备用交换机

消息发送流程:消息发送者向MQ服务器发送消息,MQ服务器接收并落库持久化消息,并响应ACK到消息生产者。至此消息成功发送。但是我们需要考虑以下几点

  1. 异常场景一:发布者发送消息和MQ接收消息并落库持久化,前两步出现问题意味着消息发送失败。通过发布者确认机制的回调接口(ConfirmCallback接口和ReturnsCallback接口)就能解决,是重新发送还是记录错误。业务逻辑不会出问题

  2. 异常场景二:MQ将消息落库并响应ACK丢失。MQ落库成功意味着逻辑上消息已经发送成功,但是发布者没有收到ACK啊?似乎挺严重的(不必理会)

    • 发布者检测到网络异常(心跳)未接受到ACK。内置timer尝试重新发送消息,多次失败则不再尝试返回失败(发布者会为每个消息创建Message ID,避免消息重复发送到RabbitMQ出现存储重复消息的情况,消息发送的幂等性由MQ服务端和发布者内置的机制保证)
    • 发布者确认机制下,发布者和MQ服务端信道会处于确认模式,在确认模式下,Broker和发布者都会对消息进行计数。Broker成功处理消息后,Broker在信道上发送basic.ackdelivery-tag 字段指示已确认消息的序列号。代理也可能在 basic.ack 中设置 multiple 字段以指示已处理所有直到序列号消息为止的消息(对于MQ成功落库消息后个别ack响应丢失的极端情况。在下一个消息落库成功后ack响应时,multiple字段的批量确认消息落库成功,就能解决上述的极端情况
  3. 持久消息的延迟确认:路由到持久队列的持久消息的basic.ack将在将消息持久化到磁盘后发送。RabbitMQ消息存储会在一段时间间隔(几百毫秒)后批量将消息持久化到磁盘。这意味着消息的确认是异步的,这意味着消息到达MQ和响应ACK确认到达的顺序并不准确一致,应用程序应尽可能避免依赖确认的顺序。

    这意味着在持续负载下,basic.ack的延迟可能达到几百毫秒。为了提高吞吐量,强烈建议应用程序异步处理确认(作为流)或发布消息批次并等待未完成的确认。这方面的确切API在不同的客户端库之间有所不同。(有点类似Redis种AOF文件,会先将写指令记录到AOF缓存,后续通过回写策略刷到磁盘)

2.2 剖析消息可靠消费(下半场)

RabbitMQ中消息确认机制有自动确认机制和手动确认机制(建议开启手动确认机制)

  • 自动确认机制:消费者接收到消息后自动返回ACK确认到Broker,通知RabbitMQ删除消息

    自动确认机制容易出现消费者过载。而手动确认机制通常与通道预取配合使用,限制消息同时消费数量。而自动确认机制下没有限制,若消费者处理速度不及消息到达速度,消费者可能会被传递速率压垮,可能在内存中累积积压,耗尽堆内存或被操作系统终止进程。因此,应谨慎使用自动确认模式或具有无限预取的手动确认模式。建议手动确认配合通道预取使用

  • 手动确认机制:消费者处理消息成功后显示发送ACK通知RabbitMQ删除消息。消息处理失败则发送共NACK给消息队列,消息重新投递或者直接丢弃

    消息消费失败处理:有限次重试+死信队列+人工介入(需要注意消息的幂等性)

    1. 消费失败后有限次重新投递消息队列(比如3次),应对由临时性错误(如网络抖动)引起的消费失败(立即重试或者配合延迟队列延迟重试)
    2. 超过重试次数则拒绝重新入队,避免消息循环和积压。配合死信队列和人工介入处理

消息消费业务逻辑:消费者接收并处理业务逻辑,消费成功后由发送确认ACK到MQ服务器,MQ服务器删除消息。

RabbitMQ对消费者确认采取超时保护,如果消费者超时未确认(默认情况下为 30 分钟,配置文件可配置,新版的RabbitMQ还可以为队列指定超时时间),MQ服务端会关闭通道,并显示 PRECONDITION_FAILED 通道异常。

而消费者确认模式下:任何未确认的传递(消息)在发生传递的通道(或连接)关闭时都会自动重新入队。后续重复投递消息(消费者必须业务代码种做好消息幂等性的保障!)

重新传递的消息将具有一个特殊的布尔属性 redeliver,该属性由 RabbitMQ 设置为 true。对于首次传递,它将设置为 false

这通常不会作为消息幂等性的保障,而是作为消息消费失败后,在业务种判断是否将消息重新入队的依据

考虑以下异常情况

  1. 异常场景一:MQ将消息投递消费者途中消息丢失

    MQ超时关闭队列,消息重新入队,重新消费

  2. 异常场景二:消费者成功消费消息,但是响应ACK丢失

    业务逻辑上消息已经消费成功,但是MQ等待超时依旧将消息重新入队。这种情况应注意消息的幂等性判断!避免后端重复消费消息(Redis幂等性表解决消息幂等性或者消息接待全局唯一id等等)

2.3 剖析消息幂等性保障

消息发送和消息消费,分别对应上下半场

  • 上半场:消息生产者重新发布消息如何保障幂等性:生产者心跳检测网络异常,没有收到MQ的阔库ACK,会重新投递消息。但是生产者会为消息生产唯一Message ID,避免因发布者重复导致MQ重复存储相同消息。(上半场的消息幂等性由MQ服务器和生产者内置的机制保证)
  • 下半场:MQ服务器重新投递消息到消费者如何保证幂等性:当MQ将消息投递到消费者后,超时为接收到消费者响应的ACK,会关闭通道,消息重新投递回队列。准备重新投递并消费。所以消费者可能接收到重复消息。(下半场消息幂等性由开发人员保证,那如何保障?)

幂等性保障解决方案:

  • 业务逻辑上杜绝消息重复消费:比如数据库唯一索引,假设消费消息需要新增数据,消息重复消费会有唯一索引冲突。再或者消息携带全局唯一ID,消费者判断通过全局唯一ID判断是否重复消费等等
  • 消费消息前后做记录:消息消费前记录消息消费中,然后执行业务代码,消息消费完成后记录消费消息消费成功。每次处理消息前判断当前消息是否存在消费记录以及消费状态即可。缺陷:消息在执行业务代码到一半,消费者宕机。系统回复正常后,业务会认为消息正在消费,不容许再次消费(极端场景具体问题具体分析咯)

消息幂等性思考:

  1. 消息幂等设计并非万能:消息幂等性保障需要保障消息消费每一个子步骤均幂等

    假设消息消费的流程包含:

    1. 检查库存(RPC)
    2. 锁库存(RPC)
    3. 开启事务,插入订单表(MySQL)
    4. 调用某些其他下游服务(RPC)
    5. 更新订单状态
    6. commit 事务(MySQL)

    当消息消费到第三步的时候假设 MySQL 异常导致失败,触发消息重试。由于消息前一次消费失败,所以消息重试会重新进入消费代码,那么步骤 1 和步骤 2 就会重新再执行一遍。

    如果步骤 2 本身不是幂等的,那么这个业务消息消费依旧没有做好完整的幂等处理。

  2. 消息幂等设计的价值:通过消息幂等设计,绝大多数情况就能保障消息幂等性。比如上游生产者导致的业务级别消息重复问题、MQ重复投递消费等。对于之前说的消息幂等性并非万能,我们能做的就是尽量保证子步骤幂等,比如异常那就解锁库存。但是毕竟异常和宕机此类情况是极少数的情况

2.4 Redis消息幂等设计

剖析RabbitMQ消息可靠投递_第3张图片

使用Redis消息去重表不依赖事务,用kv键值对存储消息的状态,key键可以考虑使用消息种携带的唯一标识(分布式全局唯一ID),value值对应消息消费状态:消费者共,消费过。kv键值对设置过期时间。

自定义幂等注解

/**
 * 幂等注解,防止消息队列消费者重复消费消息
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMQDuplicateConsume {

    /**
     * 设置防重令牌 Key 前缀
     */
    String keyPrefix() default "";

    /**
     * 通过 SpEL 表达式生成的唯一 Key
     */
    String key();

    /**
     * 设置防重令牌 Key 过期时间,单位秒,默认 1 小时
     */
    long keyTimeout() default 3600L;
}

幂等 MQ 消费状态枚举,对应消息消费中和已消费

/**
 * 幂等 MQ 消费状态枚举
 */
@RequiredArgsConstructor
public enum IdempotentMQConsumeStatusEnum {

    /**
     * 消费中
     */
    CONSUMING("0"),

    /**
     * 已消费
     */
    CONSUMED("1");

    @Getter
    private final String code;

    /**
     * 如果消费状态等于消费中,返回失败
     *
     * @param consumeStatus 消费状态
     * @return 是否消费失败
     */
    public static boolean isError(String consumeStatus) {
        return Objects.equals(CONSUMING.code, consumeStatus);
    }
}

SpEL解析工具类

/**
 * SpEL 表达式解析工具
 */
public final class SpELUtil {

    /**
     * 校验并返回实际使用的 spEL 表达式
     *
     * @param spEl spEL 表达式
     * @return 实际使用的 spEL 表达式
     */
    public static Object parseKey(String spEl, Method method, Object[] contextObj) {
        List<String> spELFlag = ListUtil.of("#", "T(");
        Optional<String> optional = spELFlag.stream().filter(spEl::contains).findFirst();
        if (optional.isPresent()) {
            return parse(spEl, method, contextObj);
        }
        return spEl;
    }

    /**
     * 转换参数为字符串
     *
     * @param spEl       spEl 表达式
     * @param contextObj 上下文对象
     * @return 解析的字符串值
     */
    public static Object parse(String spEl, Method method, Object[] contextObj) {
        DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
        ExpressionParser parser = new SpelExpressionParser();
        Expression exp = parser.parseExpression(spEl);
        String[] params = discoverer.getParameterNames(method);
        StandardEvaluationContext context = new StandardEvaluationContext();
        if (ArrayUtil.isNotEmpty(params)) {
            for (int len = 0; len < params.length; len++) {
                context.setVariable(params[len], contextObj[len]);
            }
        }
        return exp.getValue(context);
    }
}

防止消息重复提交切面类,并将其交由容器管理

/**
 * 防止消息队列消费者重复消费消息切面控制器
 */
@Slf4j
@Aspect
@RequiredArgsConstructor
public final class NoMQDuplicateConsumeAspect {

    private final StringRedisTemplate stringRedisTemplate;

    private static final String LUA_SCRIPT = """
            local key = KEYS[1]
            local value = ARGV[1]
            local expire_time_ms = ARGV[2]
            return redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms)
            """;

    /**
     * 增强方法标记 {@link NoMQDuplicateConsume} 注解逻辑
     */
    @Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoMQDuplicateConsume)")
    public Object noMQRepeatConsume(ProceedingJoinPoint joinPoint) throws Throwable {
        NoMQDuplicateConsume noMQDuplicateConsume = getNoMQDuplicateConsumeAnnotation(joinPoint);
        String uniqueKey = noMQDuplicateConsume.keyPrefix() + SpELUtil.parseKey(noMQDuplicateConsume.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());

        String absentAndGet = stringRedisTemplate.execute(
                RedisScript.of(LUA_SCRIPT, String.class),
                List.of(uniqueKey),
                IdempotentMQConsumeStatusEnum.CONSUMING.getCode(),
                String.valueOf(TimeUnit.SECONDS.toMillis(noMQDuplicateConsume.keyTimeout()))
        );

        // 如果不为空证明已经有
        if (Objects.nonNull(absentAndGet)) {
            boolean errorFlag = IdempotentMQConsumeStatusEnum.isError(absentAndGet);
            log.warn("[{}] MQ repeated consumption, {}.", uniqueKey, errorFlag ? "Wait for the client to delay consumption" : "Status is completed");
            if (errorFlag) {
                throw new ServiceException(String.format("消息消费者幂等异常,幂等标识:%s", uniqueKey));
            }
            return null;
        }

        Object result;
        try {
            // 执行标记了消息队列防重复消费注解的方法原逻辑
            result = joinPoint.proceed();

            // 设置防重令牌 Key 过期时间,单位秒
            stringRedisTemplate.opsForValue().set(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode(), noMQDuplicateConsume.keyTimeout(), TimeUnit.SECONDS);
        } catch (Throwable ex) {
            // 删除幂等 Key,让消息队列消费者重试逻辑进行重新消费
            stringRedisTemplate.delete(uniqueKey);
            throw ex;
        }
        return result;
    }

    /**
     * @return 返回自定义防重复消费注解
     */
    public static NoMQDuplicateConsume getNoMQDuplicateConsumeAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
        return targetMethod.getAnnotation(NoMQDuplicateConsume.class);
    }
}

消费者添加防止消息重复消费注解

@Component
@Slf4j
public class MyMessageListener {

	@NoMQDuplicateConsume(
            keyPrefix = "coupon_task_execute:idempotent:",
            key = "#messageWrapper.message.couponTaskId",
            keyTimeout = 120
    )
    // 修饰监听方法
    @RabbitListener(queues = STATUS_DELAYED_QUEUE_NAME)
    public void processMessage(String dataString, Message message, Channel channel) throws IOException {
        // 1、获取当前消息的 deliveryTag 值备用
        long deliveryTag = message.getMessageProperties().getDeliveryTag();

        try {
            // 2、正常业务操作
            log.info("消费端接收到消息内容:" + dataString);
            
            // 3、给 RabbitMQ 服务器返回 ACK 确认信息
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            // 4、获取信息,看当前消息是否曾经被投递过
            Boolean redelivered = message.getMessageProperties().getRedelivered();
            if (!redelivered) {
                // 5、如果没有被投递过,那就重新放回队列,重新投递,再试一次
                channel.basicNack(deliveryTag, false, true);
            } else {
                // 6、如果已经被投递过,且这一次仍然进入了 catch 块,那么返回拒绝且不再放回队列
                channel.basicReject(deliveryTag, false);
            }
        }
    }
}
  1. 为什么要设置 2 分钟幂等?10 分钟不行么?

    这个 2 分钟是个经验值,也就是说你这个消息消费的时间是否能够在 2 分钟内执行完成,如果不行需要设置更长的时间。

  2. 如果 2 分钟幂等结束后有新的一模一样的请求呢?、

    这是个伪命题,一般的幂等都是因为网络抖动同时到达,不太可能一个消息都执行完了挺长时间,然后又有一模一样的消息再来消费。如果面试官非要揪着这个点不放的话,可以把这个幂等标识存放到 MySQL 数据库,进行分表存储。这样存个 10 天半个月也不怕。但是要注意,MySQL 是没有到期删除机制的,还得配合定时任务删除之前的无效数据。

你可能感兴趣的:(Java杂谈,rabbitmq,mq)