RabbitMQ保证消息的可靠性

本文详细探讨了RabbitMQ中可能出现的消息丢失情况,包括生产者、MQ自身和消费者三方面,并提供了相应的解决办法。针对生产者丢失消息,建议使用confirm模式或事务机制。对于MQ自身丢失消息,可以通过持久化队列和消息来解决。消费者丢失消息则可通过关闭自动ACK,改用手动ACK来防止。此外,文章还讨论了如何确保消息的幂等性,提出利用Redis进行消费状态跟踪的方案。

RabbitMQ

一、三种可能出现消息丢失的情况及解决办法

1、生产者弄丢了消息
生产者在将数据发送到MQ的时候,可能由于网络等原因造成消息投递失败

2、MQ自身弄丢了消息
未开启RabbitMQ的持久化,数据存储于内存,服务挂掉后队列数据丢失
开启了RabbitMQ持久化,消息写入后会持久化到磁盘,但是在落盘的时候挂掉了,不过这种概率很小

3、消费者弄丢了消息
消费者刚接收到消息还没处理完成,结果消费者挂掉了

解决办法:

1、生产者弄丢消息

生产者在发送数据之前开启RabbitMQ的事务,采用该种方法由于事务机制,会导致吞吐量下降,太消耗性能

1.1、开启confirm模式,实现ConfirmCallback回调接口重写confirm方法
事务机制和 confirm机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是 confirm机制是异步的,你发送个消息之后就可以发送下一个消息,RabbitMQ 接收了之后会异步回调confirm接口通知你这个消息接收到了。一般在生产者这块避免数据丢失,建议使用用 confirm 机制

1.2、交换机无法将消息进行路由时,会将该消息返回给生产者 实现ReturnCallback回调接口重写returnedMessage方法

spring:
  rabbitmq:
      publisher-confirm-type: correlated # 开启消息发送确认机制
      publisher-returns: true # 开启消息无法从交换机到达队列时 交换机退回消息给生产者
/**
 * 生产者确认回调对象
 * spring.rabbitmq.publisher-confirm-type=correlated 高版本
 * 每个RabbitTemplate只支持一个ConfirmCallback
 * @author Cc
 **/
@Slf4j
@Component
public class ProducerAckConfirmCallback implements RabbitTemplate.ConfirmCallback,RabbitTemplate.ReturnCallback{

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Autowired
    private RedisTemplate redisTemplate;

    @PostConstruct
    private void init() {
        //设置生产者确认回调对象 this表示调用当前重写的confirm方法
        rabbitTemplate.setConfirmCallback(this);


        /* 
         *true:
         *交换机无法将消息进行路由时,会将该消息返回给生产者
         *false:
         *如果发现消息无法进行路由,则直接丢弃
         */
        rabbitTemplate.setMandatory(true);
        // 设置回退消息交给谁处理
        rabbitTemplate.setReturnCallback(this);
    }


    /**
     * 发送ack确认回调
     * @param correlationData 这里获取唯一id
     * @param ack             是否确认收到(true已确认收到,false未确认收到)
     * @param cause           失败原因
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        // 有些没有设置发送应答ack的,不需要走后续的逻辑 CorrelationData对象需要生产者传递
        if (correlationData == null) {
            return;
        }

        // 确认方法
        log.info("是否确认发送成功ack = {}  失败原因cause={}", ack, cause);
        // 如果为true,代表mq已成功接收消息
        if (ack) {
            //  如果发送交换机成功,但是没有匹配路由到指定的队列, 这个时候ack返回是true(这是一个坑)
            //  解决办法实现ReturnCallback 函数式接口 当消息到达了交换机但没有匹配路由到指定的队列时触发回调接口
            log.info("消息确认发送成功:correlationDataId = {}", correlationData.getId());

        } else {
            // 如果为false,代表mq没有接收到消息(消息生产失败)
            // 业务处理(采用定时器进行轮询发送) 不能调用rabbitTemplate发送,会导致线程死锁
            // 解决办法 从Redis中起出对象缓存. 让定时任务轮询发送
            // 调用定时任务服务轮询发送
            // 将错误记录进缓存 后续起出通过Aop记录进日志
            /* Map errorMap = new HashMap();
            errorMap.put("status", "-2");// ack失败
            errorMap.put("errorMsg", cause);
            errorMap.put("correlationData", correlationData);
            redisTemplate.boundHashOps("correlationData").put(correlationData.getId(), errorMap);
           */
            log.error("消息确认发送失败:correlationDataId = {}", correlationData.getId());
        }
    }

    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        log.info("消息:{}被服务器退回,退回原因:{}, 交换机是:{}, 路由 key:{}",
                new String(message.getBody()),replyText, exchange, routingKey);
    }
}

2、MQ自身弄丢消息

2.1、创建queue时设置为持久化队列(durable = “true”),这样可以保证RabbitMQ持久化queue的元数据,此时还是不会持久化queue里的数据。

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "${topicAck.ack.queue}", durable = "true", arguments = {
                @Argument(name = "x-max-length", value = "3", type = "java.lang.Long"), // 队列的最大存储界限,这里示例设为3
                @Argument(name = "x-message-ttl", value = "5000000", type = "java.lang.Long"), // 消息过期时间,多久没有被消费
                @Argument(name = "x-dead-letter-exchange", value = "${dead.exchange}"), // 死信队列交换机-dead-exchange
                @Argument(name = "x-dead-letter-routing-key", value = "xxx")}), // 死信队列路由key
        exchange = @Exchange(name = "${topicAck.exchange}", type = ExchangeTypes.TOPIC),// 交换机
        key = {"#"}// 路由key,通配符
))

2.2、发送消息时将消息的deliveryMode设置为持久化,此时queue中的消息才会持久化到磁盘。
同时设置queue和message持久化以后,RabbitMQ 挂了再次重启,也会从磁盘上重启恢复 queue,恢复这个 queue 里的数据,保证数据不会丢失。

Message message = MessageBuilder.withBody((JSONObject.toJSONString(map)).getBytes())//设置发送消息
                    .setCorrelationId(uuid)// 设置全局ID
                    .setMessageId("MessageId" + i)
                    .setDeliveryMode(MessageDeliveryMode.PERSISTENT)// 消息持久化
                    .setContentType("application/json")// 设置格式application/json
                    .build();

但是就算开启持久化机制,也有可能出现上面说的的消息落盘时服务挂掉的情况。这时可以考虑结合生产者的confirm机制来处理,持久化机制开启后消息只有成功落盘时才会通过confirm回调通知生产者,所以可以考虑生产者在生产消息时维护一个正在等待消息发送确认的队列,如果超过一定时间还没从confirm中收到对应消息的反馈,自动进行重发处理。

3、消费者自身弄丢消息

关闭自动ACK,使用手动ACK。RabbitMQ中有一个ACK机制,默认情况下消费者接收到到消息,RabbitMQ会自动提交ACK,之后这条消息就不会再发送给消费者了。我们可以更改为手动ACK模式,每次处理完消息之后,再手动ack一下。不过这样可能会出现刚处理完还没手动ack确认,消费者挂了,导致消息重复消费,不过我们只需要保证幂等性就好了。

 # 配置rabbitmq
spring:
  rabbitmq:
    listener:
      simple:
        # 并发消费:每个侦听器线程的最小数量,具体数值根据系统性能配置(一般为系统cpu核数)
        concurrency: 2
        # 并发消费:每个侦听器线程的最大数量,具体数值根据系统性能配置(一般为系统cpu核数*2)
        max-concurrency: 4
        # 每次只能获取一条消息,处理完成才能获取下一个消息,避免照成消息堆积在一个消费线程上
        prefetch: 1
        acknowledge-mode: manual         # 消费者开启手动ack消息确认,需要测试请看示例请AckConsumer,所有队列都会生效
        default-requeue-rejected: false  # 设置为false,会重发消息到死信队列(防止手动ack确认失败的消息堆积),需要测试请示例AckConsumer,所有队列都会生效
        retry:
          enabled: true                   # 解决消息死循环问题-启用重试
          max-attempts: 3                 # 最大重试3次(默认),超过就丢失(或放到死信队列中,防止消息堆积)
          multiplier: 2                   # 乘子
          initial-interval: 3000          # 第一次和第二次之间的重试间隔,后面的用乘子计算 3s 6s 12s
          max-interval: 16000             # 最大重试时间间隔16s
/**
 * 消费端-手动确认ack,
 * 需要配置 spring.rabbitmq.listener.acknowledge-mode=manual  消费者开启手动ack消息确认
 * 需要配置 spring.rabbitmq.listener.default-requeue-rejected=false  设置为false,会重发消息到死信队列
 * @author Cc
 **/
@Slf4j
@Component
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "${topicAck.ack.queue}", durable = "true", arguments = {
                @Argument(name = "x-max-length", value = "3", type = "java.lang.Long"), // 队列的最大存储界限,这里示例设为3
                @Argument(name = "x-message-ttl", value = "5000000", type = "java.lang.Long"), // 消息过期时间,多久没有被消费
                @Argument(name = "x-dead-letter-exchange", value = "${dead.exchange}"), // 死信队列交换机-dead-exchange
                @Argument(name = "x-dead-letter-routing-key", value = "xxx")}), // 死信队列路由key
        exchange = @Exchange(name = "${topicAck.exchange}", type = ExchangeTypes.TOPIC),// 交换机
        key = {"#"}// 路由key,通配符
))
public class AckConsumer {


    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 消息监听方法
     * bindings: 完成队列与交换机的绑定
     * Queue: 队列属性,超过最大值,超时未被消费,消费失败超过重试次数,都会被仍到信息队列中
     * exchange:交换机属性
     * key:路由key,通配符
     * @RabbitListener 标注在类上面表示当有收到消息的时候
     * 就交给 @RabbitHandler 的方法处理,根据接受的参数类型进入具体的方法中
     */
    @RabbitHandler
    public void handlerMessage(String msg, Channel channel, Message message) throws IOException {
        try {

            System.out.printf(">>>>>>>>>>>>>AckConsumer接受到消息,准备消费 msg=%s,CorrelationId=%s", msg,message.getMessageProperties().getCorrelationId());
            System.out.println();
/*            try {
                Thread.sleep(5000);
            } catch (Exception e) {
                e.printStackTrace();
            }*/

            // 创建json消息转换器
/*            Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
            Map map = (Map) jackson2JsonMessageConverter.fromMessage(message);*/
            // 解决幂等性问题
            Object Message = redisTemplate.boundHashOps(message.getMessageProperties().getMessageId()).get(message.getMessageProperties().getCorrelationId());
            if (Message == null) {
                log.info("已经消费了, 不在重复消费");
                System.out.println("已经消费了, 不在重复消费");
                channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
                return;
            }

            System.out.println("AckConsumer--->接受到的消息是:" + msg);


            // 业务处理
            // int i = 10 / 0;

            // 清空redis缓存数据(如果业务有写入数据库的操作建议采用延迟双删)
            /* 1、先删除缓存
            2、再写数据库
            3、休眠500ms(依据统计线程读取数据和写缓存的工夫)
            (休眠的作用是以后线程等其余线程读完了数据后写入缓存后,删除缓存)
            4、进行判断如果缓存 存在则再次删除缓存*/

            // 手动ack确认
            //参数1:deliveryTag:消息唯一传输ID
            //参数2:multiple:true: 手动批量处理,false: 手动单条处理
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            System.out.printf("================AckConsumer消费成功,msg=%s,================", msg);
            System.out.println();


        } catch (Exception ex) {
            System.out.println(">>>>>>>>>>>>>>>>>>>>>>>AckConsumer消费错误" + ex.getMessage());
            // 如果真得出现了异常,我们采用消息重投,获取redelivered,判断是否为重投: false没有重投,true重投
            Boolean redelivered = message.getMessageProperties().getRedelivered();
            System.out.println("redelivered = " + redelivered);

            try {
                // (已重投)拒绝确认
                if (redelivered) {
                    /**
                     * 拒绝确认,从队列中删除该消息,防止队列阻塞(消息堆积)
                     * boolean requeue: false不重新入队列(丢弃消息 这里会将消息投入死信队列)
                     */
                    channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
                    System.out.printf("================AckConsumer消费消息已投入死信队列,msg=%s,================", msg);
                    System.out.println();
                } else { // (没有重投) 消息重投
                    /**
                     * 消息重投,重新把消息放回队列中
                     * boolean multiple: 单条或批量
                     * boolean requeue: true重回队列
                     */
                    channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
                    System.out.println("=========消息重投了=======");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

二、如何保障消息的幂等性

生产者发送消息时将消息存入Redis 消费者 消费消息时先检查Redis缓存 没有说明已经消费过啦 发送ACK 有缓存时进行消费 消费完删除缓存(暂时使用延迟双删)

// 生产者发送消息时将消息存入Redis
redisTemplate.boundHashOps(message.getMessageProperties().getMessageId()).put(message.getMessageProperties().getCorrelationId(), map);
// 消费者 消费消息前检查缓存
Object Message = redisTemplate.boundHashOps(message.getMessageProperties().getMessageId()).get(message.getMessageProperties().getCorrelationId());
if (Message == null) {
    log.info("已经消费了, 不再重复消费");
    channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    return;
}
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值