RabbitMQ-保证消息可靠性

1. RabbitMQ 消息发送机制

RabbitMQ 中的消息发送引入了 Exchange(交换机)的概念,消息的发送首先到达交换机上,然后再根据既定的路由规则,由交换机将消息路由到不同的 Queue(队列)中,再由不同的消费者去消费。

image.png 这个过程中可能会出现的问题:

  • 生产者消息没到交换机,相当于生产者弄丢消息
  • 交换机没有把消息路由到队列,相当于生产者弄丢消息
  • RabbitMQ 宕机导致队列、队列中的消息丢失,相当于 RabbitMQ 弄丢消息
  • 消费者消费出现异常,业务没执行,相当于消费者弄丢消息

2. RabbitMQ方案一通过事务

Spring Boot 中开启 RabbitMQ 事务机制的方式如下:

首先需要先提供一个事务管理器,如下:

@Bean
RabbitTransactionManager transactionManager(ConnectionFactory connectionFactory) {
    return new RabbitTransactionManager(connectionFactory);

接下来,在消息生产者上面做两件事:添加事务注解并设置通信信道为事务模式:

@Service
public class MsgService {
    @Autowired
    RabbitTemplate rabbitTemplate;

    @Transactional
    public void send() {
        rabbitTemplate.setChannelTransacted(true);
        rabbitTemplate.convertAndSend(RabbitConfig.JAVABOY_EXCHANGE_NAME,RabbitConfig.JAVABOY_QUEUE_NAME,"hello rabbitmq!".getBytes());
        int i = 1 / 0;
    }
}

这里注意两点:

  1. 发送消息的方法上添加 @Transactional 注解标记事务。
  2. 调用 setChannelTransacted 方法设置为 true 开启事务模式。

这就 OK 了。

在上面的案例中,我们在结尾来了个 1/0 ,这在运行时必然抛出异常,我们可以尝试运行该方法,发现消息并未发送成功。

当我们开启事务模式之后,RabbitMQ 生产者发送消息会多出四个步骤:

  1. 客户端发出请求,将信道设置为事务模式。
  2. 服务端给出回复,同意将信道设置为事务模式。
  3. 客户端发送消息。
  4. 客户端提交事务。
  5. 服务端给出响应,确认事务提交。

上面的步骤,除了第三步是本来就有的,其他几个步骤都是平白无故多出来的。所以大家看到,事务模式其实效率有点低,这并非一个最佳解决方案。我们可以想想,什么项目会用到消息中间件?一般来说都是一些高并发的项目,这个时候并发性能尤为重要。

所以,RabbitMQ 还提供了发送方确认机制(publisher confirm)来确保消息发送成功,这种方式,性能要远远高于事务模式,一起来看下。

3. RabbitMQ方案二通过确认退回机制

在使用 RabbitMQ 的时候,作为消息发送方希望杜绝任何消息丢失或者投递失败场景。RabbitMQ 为我 们提供了两种方式用来控制消息的投递可靠性模式。

  • confirm 确认模式
  • return 退回模式

rabbitmq 整个消息投递的路径为:
producer--->rabbitmq broker--->exchange--->queue--->consumer

  • 消息从 producer 到 exchange 则会返回一个 confirmCallback 。
  • 消息从 exchange-->queue 投递失败则会返回一个 returnCallback 。 我们将利用这两个 callback 控制消息的可靠性投递

3.1 消息发送提供成功率

3.1.1 准备一个employee表和处理mq的表

employee表 image.png 处理mq的表 image.png

3.1.2 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

3.1.3 mq确认模式、退回模式开启配置

rabbitmq:
  username: guest
  password: guest
  host: xxx.xxx.xxx.xxx
  publisher-confirms: true
  publisher-returns: true

3.1.4 配置mq配置类

配置确认模式、退回模式以及配置Exchange、Queue并且绑定
如果发送成功将修改表中status为1也就是消息投递成功的状态

RabbitConfig.java

@Configuration
public class RabbitConfig {
    public final static Logger logger = LoggerFactory.getLogger(RabbitConfig.class);
    @Autowired
    CachingConnectionFactory cachingConnectionFactory;
    @Autowired
    MailSendLogService mailSendLogService;

    @Bean
    RabbitTemplate rabbitTemplate() {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(cachingConnectionFactory);
        rabbitTemplate.setConfirmCallback((data, ack, cause) -> {
            String msgId = data.getId();
            if (ack) {
                logger.info(msgId + ":消息发送成功");
                mailSendLogService.updateMailSendLogStatus(msgId, 1);//修改数据库中的记录,消息投递成功
            } else {
                logger.info(msgId + ":消息发送失败");
            }
        });
        rabbitTemplate.setReturnCallback((msg, repCode, repText, exchange, routingkey) -> {
            logger.info("消息发送失败");
        });
        return rabbitTemplate;
    }

    @Bean
    Queue mailQueue() {
        return new Queue(MailConstants.MAIL_QUEUE_NAME, true);
    }

    @Bean
    DirectExchange mailExchange() {
        return new DirectExchange(MailConstants.MAIL_EXCHANGE_NAME, true, false);
    }

    @Bean
    Binding mailBinding() {
        return BindingBuilder.bind(mailQueue()).to(mailExchange()).with(MailConstants.MAIL_ROUTING_KEY_NAME);
    }

}

3.1.5 相关Service、ServiceImpl方法

  • Service

EmployeeService.java

public interface EmployeeService extends IService<Employee> {

    /**
     * 新增员工
     * @param employee
     * @return
     */
    boolean InsertEmployee(Employee employee);

}

MailSendLogService.java

public interface MailSendLogService extends IService<MailSendLog> {

    /**
     * 发送消息成功的话修改status为1也就是发送成功
     * @param msgId
     * @param status
     * @return
     */
    public boolean updateMailSendLogStatus(@Param("msgId") String msgId, @Param("status") Integer status);

    /**
     * 定时任务中查询所有status为0也就是发送中,进行后续重试
     * @return
     */
    List<MailSendLog> getMailSendLogsByStatus();

    /**
     * 定时重试count次数+1
     * @param msgId
     * @param date
     */
    void updateCount(String msgId, Date date);
}
  • ServiceImpl
@Service
public class MailSendLogServiceImpl extends ServiceImpl<MailSendLogMapper, MailSendLog> implements MailSendLogService {

    @Override
    public boolean updateMailSendLogStatus(String msgId, Integer status) {
        LambdaUpdateWrapper<MailSendLog> updateWrapper = new LambdaUpdateWrapper<>();

        updateWrapper.eq(MailSendLog::getMsgId,msgId)
                .set(MailSendLog::getStatus,status);

        return this.update(updateWrapper);
    }

    @Override
    public List<MailSendLog> getMailSendLogsByStatus() {
        LambdaQueryWrapper<MailSendLog> queryWrapper= new LambdaQueryWrapper<>();

        queryWrapper.eq(MailSendLog::getStatus,0)
                .le(MailSendLog::getTryTime,System.currentTimeMillis());

        List<MailSendLog> list = this.list(queryWrapper);
        return list;
    }

    @Override
    public void updateCount(String msgId, Date date) {
        this.baseMapper.updateCount(msgId,date);
    }
    
}

3.1.6 新增employee并处理mq

先第一步将employee新增,接着将mq处理表的相关信息插入mq表,teyTime重试时间设置为一分钟,最后通过 rabbitTemplate.convertAndSend()发送消息

@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {

    @Autowired
    private EmployeeMapper employeeMapper;

    @Autowired
    private MailSendLogMapper mailSendLogMapper;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Override
    public boolean InsertEmployee(Employee employee) {
        boolean save = this.save(employee);
        if(save) {
            Employee emp = employeeMapper.selectById(employee.getId());
            //生成消息的唯一id
            String msgId = UUID.randomUUID().toString();
            MailSendLog mailSendLog = new MailSendLog();
            mailSendLog.setMsgId(msgId);
            mailSendLog.setCreateTime(new Date());
            mailSendLog.setExchange(MailConstants.MAIL_EXCHANGE_NAME);
            mailSendLog.setRouteKey(MailConstants.MAIL_ROUTING_KEY_NAME);
            mailSendLog.setEmpId(emp.getId());
            mailSendLog.setCount(0);
            mailSendLog.setTryTime(new Date(System.currentTimeMillis() + 1000 * 60 * MailConstants.MSG_TIMEOUT));
            mailSendLogMapper.insert(mailSendLog);
            rabbitTemplate.convertAndSend(MailConstants.MAIL_EXCHANGE_NAME, MailConstants.MAIL_ROUTING_KEY_NAME, emp, new CorrelationData(msgId));
        }
        return save;
    }
}

3.1.7 开启定时重试任务

开启定时任务,在Application.java中添加@EnableScheduling注解,调用getMailSendLogsByStatus()查询satus为0,一开始发送失败的消息,进行@Scheduled(cron = "0/10 * * * * ?")每10s重试一次,并且通过updateCount()方法每重试一次count+1,超过3次记为失败不再重试。

@Component
public class MailSendTask {
    @Autowired
    MailSendLogService mailSendLogService;

    @Autowired
    RabbitTemplate rabbitTemplate;

    @Autowired
    EmployeeService employeeService;

    @Scheduled(cron = "0/10 * * * * ?")
    public void mailResendTask() {
        List<MailSendLog> logs = mailSendLogService.getMailSendLogsByStatus();
        if (logs == null || logs.size() == 0) {
            return;
        }

        logs.forEach(mailSendLog->{
            if (mailSendLog.getCount() >= 3) {
                mailSendLogService.updateMailSendLogStatus(mailSendLog.getMsgId(), 2);//直接设置该条消息发送失败
            }else{
                mailSendLogService.updateCount(mailSendLog.getMsgId(), new Date());
                Employee emp = employeeService.getById(mailSendLog.getEmpId());
                rabbitTemplate.convertAndSend(MailConstants.MAIL_EXCHANGE_NAME, MailConstants.MAIL_ROUTING_KEY_NAME, emp, new CorrelationData(mailSendLog.getMsgId()));
            }
        });
    }
}

3.1.8 测试

  • apipost测试发送添加请求 image.png
  • 修改了exchange名将会报错

image.png

  • 定时任务中的exchange名正确的话,重试第一次将会成功,count+1,status为1,发送成功

image.png

  • 定时任务中exchange也错误的话,重试3次后,count加为3后,不再进行重试,status为2,发送失败

一开始

image.png

重试前面2次

image.png

重试3次,status为2,不再重试

image.png

4. 手动确认消息并用Redis保证幂等性

4.1 Consumer Ack

ack指Acknowledge,确认。 表示消费端收到消息后的确认方式。
有三种确认方式:

  • 自动确认:acknowledge="none"
  • 手动确认:acknowledge="manual"
  • 根据异常情况确认:acknowledge="auto", 其中自动确认是指,当消息一旦被Consumer接收到,则自动确认收到,并将相应 message 从 RabbitMQ 的消息缓存中移除。但是在实际业务处理中,很可能消息接收到,业务处理出现异常,那么该消息就会丢失。如果设置了手动确认方式,则需要在业务处理成功后,调用channel.basicAck(),手动签收,如果出现异常,则调用channel.basicNack()方法,让其自动重新发送消息。

4.2 消费者弄丢消息

消费者消费消息时有可能会弄丢消息
所谓消费端弄丢消息就是消费端执行业务代码报错了,那么该做的业务其实没有做。比如创建订单成功了,优惠券结算报错了,默认情况下 RabbitMQ 只要把消息推送到消费者就会认为消息已经被消费,就从队列中删除了,但是优惠券还没有结算,这样就相当于消息变相丢失了。这种情况还是很常见的,毕竟我们开发人员不能保证自己的代码不报错,这种问题一定得解决。 否则用户下了订单,优惠券没有扣减,这样就是有问题的。因此我们可以用手动ack确认。

配置文件开启手动确认

spring:
  rabbitmq:
    host: xxx.xxx.xxx.xxx
    port: 5672
    virtual-host: /
    username: guest
    password: guest
    listener:
      simple:
        prefetch: 100
        acknowledge-mode: manual
@RabbitListener(queues = MailConstants.MAIL_QUEUE_NAME)
public void handler(Message message, Channel channel) throws IOException {
    MessageHeaders headers = message.getHeaders();
    Long tag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
        logger.info(msgId + ":消息已经被消费");
    try {
       /**
        * 执行相关业务
        * */
        channel.basicAck(tag, false);
        logger.info(msgId + ":邮件发送成功");
    } catch (MessagingException e) {
        channel.basicNack(tag, false, true);
        e.printStackTrace();
        logger.error("邮件发送失败:" + e.getMessage());
    }
}

踩坑

如果用了手动确认消息,使用了basicNack()方法,它的第三个参数是是否回到队列,如果false的话就会直接丢弃,这样就没有了消息可靠性,但是true的话当出现异常的时候,这个消息重回队列后到达顶端,又被消费者消费,接着出现异常;就会进入一个死循环,出现下面的报错

Execution of Rabbit message listener failed.

org.springframework.amqp.rabbit.listener.exception.ListenerExecutionFailedException: Listener method 'public void   com.xxxxx(org.springframework.messaging.Message,com.rabbitmq.client.Channel) throws java.io.IOException' threw exception

这里就有几个选择:

  1. 使用springboot的retry重试机制
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true
          max-attempts: 3 #重试次数
          initial-interval: 5000ms # 重新投递的时间间隔
        default-requeue-rejected: false # 超过上面重试次数设置后是否丢弃
  1. 直接填 false ,不重回队列,记录日志、发送邮件等待开发手动处理
  2. 当消费失败后将此消息存到 Redis,记录消费次数,如果消费了三次还是失败,就丢弃掉消息,记录日志落库保存

4.3 消息重复消费(幂等性)

这个是我们在业务场景中可能会经常遇到的问题,有的消息可能会被重复消费。

确保消费端只执行一次

一般来说消息重复消费都是在短暂的一瞬间消费多次,我们可以使用 redis 将消费过的消息唯一标识存储起来,然后在消费端业务执行之前判断 redis 中是否已经存在这个标识。举个例子,订单使用优惠券后,要通知优惠券系统,增加使用流水。这里可以用订单号 + 优惠券 id 做唯一标识。业务开始先判断 redis 是否已经存在这个标识,如果已经存在代表处理过了。不存在就放进 redis 设置过期时间,执行业务。

// 先检查
if (redisTemplate.opsForHash().entries("mail_log").containsKey(msgId)) {
    //redis 中包含该 key,说明该消息已经被消费过
    logger.info(msgId + ":消息已经被消费");
    channel.basicAck(tag, false);//确认消息已消费
    return;
}
try {
     /**
        * 执行相关业务
     * */
    // 消费过的标识存储到 Redis
    redisTemplate.opsForHash().put("mail_log", msgId, "javaboy");
    channel.basicAck(tag, false);
    logger.info(msgId + ":邮件发送成功");
}
// 

案例

  1. 配置文件

application.yml

spring:
  rabbitmq:
    host: xxx.xxx.xxx.xxx
    port: 5672
    virtual-host: /
    username: guest
    password: guest
    listener:
      simple:
        prefetch: 100
        acknowledge-mode: manual
  redis:
    host: xxx.xxx.xxx.xxx
    port: 6379
  mail:
    host: smtp.163.com
    protocol: smtp
    default-encoding: UTF-8
    password: 
    username: 
    properties:
      mail:
        smtp:
          socketFactoryClass: javax.net.ssl.SSLSocketFactory
          auth: true
          starttls:
            enable: true
            required: true
        debug: true
  1. 消费者消费消息,并且使用redis存储消费过的消息,保证消费端只执行一次

MailReceiver.java

@Component
public class MailReceiver {

    public static final Logger logger = LoggerFactory.getLogger(MailReceiver.class);

    @Autowired
    JavaMailSender javaMailSender;
    @Autowired
    MailProperties mailProperties;
    @Autowired
    TemplateEngine templateEngine;
    @Autowired
    StringRedisTemplate redisTemplate;

    @RabbitListener(queues = MailConstants.MAIL_QUEUE_NAME)
    public void handler(Message message, Channel channel) throws IOException {
        Employee employee = (Employee) message.getPayload();
        MessageHeaders headers = message.getHeaders();
        Long tag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
        String msgId = Convert.toStr("spring_returned_message_correlation");
        if (redisTemplate.opsForHash().entries("mail_log").containsKey(msgId)) {
            //redis 中包含该 key,说明该消息已经被消费过
            logger.info(msgId + ":消息已经被消费");
            channel.basicAck(tag, false);//确认消息已消费
            return;
        }
        //收到消息,发送邮件
        MimeMessage msg = javaMailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(msg);
        try {
            helper.setTo(employee.getName());
            helper.setFrom(mailProperties.getUsername());
            helper.setSubject("恭喜employee加入");
            helper.setSentDate(new Date());
            Context context = new Context();
            context.setVariable("name", employee.getName());
            //......其他employee字段
            String mail = templateEngine.process("mail", context);
            helper.setText(mail, true);
            javaMailSender.send(msg);
            redisTemplate.opsForHash().put("mail_log", msgId, "javaboy");
            channel.basicAck(tag, false);
            logger.info(msgId + ":邮件发送成功");
        } catch (MessagingException e) {
            channel.basicNack(tag, false, true);
            e.printStackTrace();
            logger.error("邮件发送失败:" + e.getMessage());
        }
    }
}
  1. 测试

生产者发送多条消息

4834a1d68a6d17450521d08fb130fb4.png

redis中存储的已标识的消息

Snipaste_2022-07-10_23-33-29.png

允许消费端执行多次,保证数据不受影响

  • 数据库唯一键约束

如果消费端业务是新增操作,我们可以利用数据库的唯一键约束,比如优惠券流水表的优惠券编号,如果重复消费将会插入两条相同的优惠券编号记录,数据库会给我们报错,可以保证数据库数据不会插入两条。

  • 数据库乐观锁思想

如果消费端业务是更新操作,可以给业务表加一个 version 字段,每次更新把 version 作为条件,更新之后 version + 1。由于 MySQL 的 innoDB 是行锁,当其中一个请求成功更新之后,另一个请求才能进来,由于版本号 version 已经变成 2,必定更新的 SQL 语句影响行数为 0,不会影响数据库数据。

5. 消息积压

所谓消息积压一般是由于消费端消费的速度远小于生产者发消息的速度,导致大量消息在 RabbitMQ 的队列中无法消费。

5.1 增加实例、扩大消费能力、紧急扩容、消息重导

  1. 增加消费端实例。说白了就是增加机器。如果出现线上事故,能申请多少机器就申请多少机器,争取在最短的时间内消费掉积压在MQ中的消息。
  2. 如果申请机器行不通,毕竟公司的机器是有限的,此时可以增加消费端的消费能力。在MQ的配置中配置"最大消费者数量"与"每次从队列中获取的消息数量"
spring:
  rabbitmq:
    host: xxx.xxx.xxx.xxx
    port: 5672
    virtual-host: /
    username: guest
    password: guest
    listener:
      simple:
        # 消费者数量
        concurrency: 20
        # 最大消费者数量
        max-concurrency: 30
        # 限流 每次从队列获取的消息数量
        prefetch: 100
        acknowledge-mode: manual
  1. 如果是积压百万到上千万的数据几小时,线上的comsumer又故障了,修复后并不能解决快速消费完大量数据的需求,因此需要紧急扩容 1)先修复consumer的问题,确保其恢复消费速度,然后将现有cnosumer都停掉
    2)新建一个topic,partition是原来的10倍,临时建立好原先10倍或者20倍的queue数量
    3)然后写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的10倍数量的queue
    4)接着临时征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的数据
    5)这种做法相当于是临时将queue资源和consumer资源扩大10倍,以正常的10倍速度来消费数据
    6)等快速消费完积压数据之后,得恢复原先部署架构,重新用原先的consumer机器来消费消息

image.png 4. 如果是设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在mq里,而是大量的数据会直接搞丢

  • 这种情况就是采取批量重导,过了用户使用高峰后,写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把高峰时期的数据补回来。也只能是这样了。

5.2 拆分MQ

  • 拆分MQ,生产者一个MQ,消费者一个MQ,写一个程序监听生产者的MQ模拟消费速度(譬如线程休眠),然后发送到消费者的MQ,如果消息积压则只需要处理生产者的MQ的积压消息,不影响消费者MQ

image.png

  • 拆分MQ,生产者一个MQ,消费者一个MQ,写一个程序监听生产者的MQ,定义一个全局静态变量记录上一次消费的时间,如果上一次时间和当前时间只差小于消费者的处理时间,则发送到一个延迟队列(可以使用死信队列实现)发送到消费者的MQ,如果消息积压则只需要处理生产者的MQ的积压消息,不影响消费者MQ。

image.png

5.3 使用Redis缓存

使用Redis的List或ZSET做接收消息缓存,写一个程序按照消费者处理时间定时从Redis取消息发送到MQ。

image.png

5.4 死信实现延迟队列

设置消息过期时间,过期后转入死信队列,写一个程序处理死信消息(重新如队列或者即使处理或记录到数据库延后处理)

image.png

猜你喜欢

转载自juejin.im/post/7118983680088866853