让每次投递都有回音

1,410 阅读6分钟

mq 在现在的 Java 后端中是一项必须要直视的技术,谁都无法逃避,不然可能很难找到一份令自己满意的工作。众所周知,项目中引入 mq 也会带来一些问题,系统复杂性的提高、消息丢了肿么办等等。其中,消息丢失的问题可能出现在各个环节,比如:消息在到达 mq 的中途丢了,或者 mq 本身挂了,又或者消费者还没消费成功就嗝屁了,可别人以为你已经成了。说了这么多,但该用还得用啊。

笔者今天跟大家分享的是消息补偿机制。补偿可以用来解决上面提到的消息丢失问题,主要解决的是生产端和发送端的问题,mq 一侧的消息丢失还得要 mq 本身去解决,比如配置消息的持久化等等,这一点依赖于所使用的 mq 是啥。本篇以 rabbitmq 为例进行讲解,rabbitmq 中的所有消息、交换机、队列都进行了持久化。

img-16615121226780d452be4b093bde8d9bfc0c704e1e933.jpg

在后视镜里枕着橘红色的海胡思乱想

补偿机制分为三块,分别是生产者端、消费者端和定时任务补偿。

生产者端

生产者端要发送的消息最终是要落到 rabbitmq 中存储的,因此有没发送成功,有没持久化到磁盘,这些都需要 rabbitmq 告诉生产者。rabbitmq 提供了 confirm 机制用来告诉生产者是否收到了消息并是否完成了持久化。

        @FunctionalInterface
	public interface ConfirmCallback {
        // correlationData 可以理解成是附加的信息,可以用它来存储消息的唯一标识
        // ack 为 true 说明消息已经成功投递并进行了持久化,false 则表示投递失败
        // cause 表示的是投递失败的原因
	void confirm(@Nullable CorrelationData correlationData, boolean ack, String cause);

	}

既然要能够补偿,那么发出去的消息就要求能够在其他地方找回来。数据库喊了声,到!每条消息都对应了表中的一行数据,表应该设计哪些字段呢 ?笔者在这给出一种设计供大家参考:

CREATE TABLE `msg_compensation` (
  `id` bigint(20) NOT NULL COMMENT '消息唯一标识,通过雪花算法生成',
  `type` int(11) NOT NULL COMMENT '业务类型',
  `state` int(11) NOT NULL COMMENT '消息状态,有五种,待投递、投递失败、待消费、消费中、已完成',
  `retry_count` int(11) NOT NULL COMMENT '重试次数',
  `data` varchar(255) NOT NULL COMMENT '消息内容',
  `exchange` varchar(255) NOT NULL COMMENT '交换机',
  `routing_key` varchar(255) NOT NULL COMMENT '路由键',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  `update_time` datetime NOT NULL COMMENT '修改时间',
  `strategy` int(11) NOT NULL DEFAULT '1' COMMENT '失败后的投递策略,0 立即补偿,1 定时补偿',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

每条消息都会有一个唯一标识和五种不同的状态。“待投递” 对应的是消息的初始状态或重试阶段;如果投递成功,则状态为 “待消费”;如果超过重试次数都还未投递成功,则状态为 “投递失败”。状态的扭转放在 ConfirmCallback 接口的实现中:

@Component
public class RabbitMQConfirmCallback implements RabbitTemplate.ConfirmCallback {

    @Autowired
    private MsgCompensationMapper msgCompensationMapper;

    @Autowired
    private RabbitMQProducer rabbitMQProducer;

    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {

        String id = correlationData.getId();

        MsgCompensation msgCompensation = msgCompensationMapper.selectByPrimaryKey(Long.parseLong(id));

        if (msgCompensation == null){
            return;
        }

        // 重试次数达到上限都还未投递成功,具体次数根据实际情况指定
        if (!ack && msgCompensation.getRetryCount() + 1 > 3){
            // 只有当消息状态为 待投递才将状态修改为 "投递失败"
            msgCompensationMapper.updateState(Long.parseLong(id),"待投递","投递失败");
            return;
        }


        // 投递成功
        if (ack){
            // 只有当消息状态为 待投递 && 投递成功 才将状态修改为 "待消费"
            msgCompensationMapper.updateState(Long.parseLong(id),"待投递","待消费");
        }
        // 投递失败
        else {
            // 根据投递策略判断是否应该立即重发,如果是则重发,否则就等待定时任务进行重发
            if (msgCompensation.getStrategy() == 0){
                // 更新重试次数
                msgCompensation.setRetryCount(msgCompensation.getRetryCount() + 1);

                msgCompensationMapper.updateByPrimaryKey(msgCompensation);

                rabbitMQProducer.rawSend(msgCompensation);

            }else {
                // 执行其他操作,比如记录日志
            }
        }

    }

}

应用使用新的 api 进行消息的发送:

@Component
public class RabbitMQProducer {

    private final RabbitTemplate rabbitTemplate;

    private final MsgCompensationMapper msgCompensationMapper;

    // 应用调用 send 方法进行消息的发送
    public void send(MsgCompensation msgCompensation) {
        // 发送前将消息记录到表中
        this.msgCompensationMapper.insertSelective(msgCompensation);
        this.rawSend(msgCompensation);
    }

    public void rawSend(MsgCompensation msgCompensation) {
        // 发送消息时通过 CorrelationData 带上消息的唯一标识
        this.rabbitTemplate.convertAndSend(msgCompensation.getExchange(), msgCompensation.getRoutingKey(), msgCompensation, new CorrelationData(msgCompensation.getId()));
    }

    public RabbitMQProducer(final RabbitTemplate rabbitTemplate, final MsgCompensationMapper producerEventDao) {
        this.rabbitTemplate = rabbitTemplate;
        this.msgCompensationMapper = producerEventDao;
    }
}

消费者端

消费者端要保证消息不丢失,手动 ack 是必须要提上议程的。消费前,根据 id 修改消息的状态,当且仅当消息状态为 “待消费” 时,才将状态扭转为 “消费中”,如果修改成功,则执行具体的消费逻辑,否则直接 return。消费成功后,将状态从 “消费中” 扭转成 “已完成”;否则抛出异常,状态恢复到 “待消费”。

对标有 RabbitListener 注解的方法进行增强:

@Component
@Aspect
public class RabbitListenerAspect {


    @Autowired
    private RabbitMQConsumer rabbitMQConsumer;


    @Pointcut("@annotation(org.springframework.amqp.rabbit.annotation.RabbitListener)")
    public void rabbitListenerCut() {
    }

    @Around("rabbitListenerCut()")
    public void around(ProceedingJoinPoint point) {

        Map<Class<?>, Object> fields = this.getFields(point);
        
        Channel channel = (Channel)fields.get(Channel.class);
        Message message = (Message)fields.get(Message.class);
        MsgCompensation msgCompensation = (MsgCompensation)fields.get(MsgCompensation.class);
        
        if (msgCompensation != null) {
            this.rabbitMQConsumer.consume(msgCompensation, point);

            MessageProperties messageProperties = message.getMessageProperties();

            long deliveryTag = messageProperties.getDeliveryTag();

            
            try {
                // 手动 ack
                channel.basicAck(deliveryTag,false);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

    }

    public Map<Class<?>, Object> getFields(ProceedingJoinPoint joinPoint) {
        Map<Class<?>, Object> param = new HashMap();
        Object[] paramValues = joinPoint.getArgs();
        Class<?>[] parameterTypes = ((CodeSignature)joinPoint.getSignature()).getParameterTypes();

        for(int i = 0; i < parameterTypes.length; ++i) {
            param.put(parameterTypes[i], paramValues[i]);
        }

        return param;
    }
}

RabbitMQConsumer :

@Component
public class RabbitMQConsumer {


    @Autowired
    private MsgCompensationMapper msgCompensationMapper;


    @Transactional
    public void consume(MsgCompensation msgCompensation, ProceedingJoinPoint point){

        if (msgCompensation.getId() == null){
            return;
        }

        Long id = msgCompensation.getId();

        Integer res = msgCompensationMapper.updateState(id, "待消费", "消费中");

        if (res != 1){
            // 如果 res 的值不为 1 说明该条消息已经被消费,用于避免重复消费的问题
            return;
        }

        try {
            point.proceed();

            msgCompensationMapper.updateState(id, "消费中", "已完成");


        }catch (Throwable throwable){
            // 消费逻辑出现异常,可以抛出自定义异常或运行时异常,让 spring 管理的事务能够进行回滚操作
            throw new RuntimeException();
        }

    }


}

img-1661653451183b915df278a45c8f383c7aab965113834.jpg

人都爱做梦,因为梦里连动物都会说话

定时任务补偿

这一部分就是利用定时任务去补偿那些发送失败的消息。定时任务的实现有多种,比如 spring 提供的 @Scheduled 注解,或是 xxl-job、powerjob 等定时任务调度框架。如果是多机器部署的话,可以用现成的调度框架,只让一台机器去做补偿。

补偿的逻辑就是查出 state 为 “待投递” && strategy 为 1 的消息,遍历消息列表,对每条消息的重试次数 + 1,然后发送即可。需要注意的是,如果某条消息刚刚插入到数据库中(刚刚被创建出来),同时定时任务也开始运行,此时就会出现一条消息被投递两次的情况。为了避免这种情况,定时任务补偿时可以先判断消息的创建时间和当前时间的差是否小于某个值,如果小于,则本次定时任务先不处理;否则进行重试。通过这种方式也可以避免说,某条消息是 confirm 因为网络问题延迟到达就进行重试,但实际该消息可能已经投递成功。

@Component
public class MsgCompensationScheduler {



    @Autowired
    private MsgCompensationMapper msgCompensationMapper;

    @Autowired
    private RabbitMQProducer rabbitMQProducer;

    @Scheduled(
            cron = "0/10 * * * * ?"
    )
    public void scheduler(){

        MsgCompensation msgCompensation = new MsgCompensation();

        msgCompensation.setState("待投递");

        msgCompensation.setStrategy(1);

        List<MsgCompensation> msgCompensations = msgCompensationMapper.selectByMsgCompensation(msgCompensation);

        if (CollUtil.isEmpty(msgCompensations)){
            return;
        }


        Date now = new Date();

        for (MsgCompensation compensation : msgCompensations) {

            if (DateUtil.between(compensation.getCreateTime(),now, DateUnit.SECOND) < 30){
                continue;
            }

            if (msgCompensation.getRetryCount() + 1 > 3){
                msgCompensationMapper.updateState(compensation.getId(),"待投递","投递失败");
                continue;
            }

            msgCompensation.setRetryCount(msgCompensation.getRetryCount() + 1);

            msgCompensationMapper.updateByPrimaryKey(msgCompensation);

            rabbitMQProducer.rawSend(msgCompensation);

        }


    }
}

最后笔者整理下一条消息的状态流转过程:

状态流转.png

以上就是要分享的全部内容了,读者有不同看法的,可以在评论区中提出,大家共同进步。笔者自己建了个群,如果有乐意分享或写作的朋友可以进来,大家互相交流。

二维码.png