MQ丢消息情况以及如何避免

236 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第15天,点击查看活动详情

丢消息原因

一条消息从生产到被消费,经过MQ,生产者、消费者这几个环节。

MQ丢消息情况.jpeg

生产者丢失

生产者往MQ中写数据,可能出现网络故障,消息没到MQ内部,生产者自己不知道,没有捕获异常,那么这条消息就丢失了。

或者当消息刚到MQ,MQ坏掉了,但是生产者自己也不知道,导致消息丢失

MQ丢失

消息到MQ内部后,消息会存在内存中,然后再持久化到磁盘。如果在消息处于内存没到磁盘这个阶段,MQ所在服务器寄了,那消息就丢失了。

当然,消息存到磁盘,但是磁盘没有备份,损坏了也会导致消息丢失。

消费者丢失

消费者的 ack 没设置或者设置了 auto 选项,那么消费者获取到消息后会第一时间直接 offset 到MQ,MQ就认为整个流程结束了。如果在这个时候,消费者没有消费完成消息,即处理消息对应的业务逻辑,机器寄了,那消息丢失了。

保证不丢失

MQ不丢消息.jpeg

生产者环节

有两种方式可以解决:

  • 通过事物机制解决
  • 通过发送方确认机制实现

事物机制

channel.txSelect 开启事务,使用 channel.txCommitchannel.txRollback 分别用来提交事务和回滚事务。

与数据库的事务有稍许不同,数据库每次都需要打开事务,且最后与之对应的有commit或者rollback,而RabbitMQ中channel中的事务只需要开启一次,可以多次commit或者rollback

代码示例

config

@Component
public class RabbitConfig {
​
    @Autowired
    private ConfigurableApplicationContext applicationContext;
    
    public static final String TEST_ACK = "test-ack";
​
    public static final String TEST_CONFIRM = "confirm.queue";
​
    public static final String CONFIRM_EXCHANGE = "confirm.exchange";
​
    // 获取RabbitMQ服务器连接
    public  Connection getConnection() {
        Connection connection = null;
        try {
            ConnectionFactory factory = new ConnectionFactory();
            factory.setHost(applicationContext.getEnvironment().getProperty("spring.rabbitmq.addresses"));
       factory.setPort(Integer.parseInt(applicationContext.getEnvironment().getProperty("spring.rabbitmq.port")));
            factory.setUsername(applicationContext.getEnvironment().getProperty("spring.rabbitmq.username"));
            factory.setPassword(applicationContext.getEnvironment().getProperty("spring.rabbitmq.password"));
            connection = factory.newConnection();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return connection;
    }
}

生产者

public void testChannel(){
        Connection connection = rabbitConfig.getConnection();
        try {
            Channel channel = connection.createChannel();
            //channel开启事务
            channel.txSelect();
            //创建队列
            channel.queueDeclare(RabbitConfig.TEST_ACK, true, false, false, null);
            //发送3条消息
            String msgTemplate = "测试事务消息内容[%d]";
            channel.basicPublish("", RabbitMQConfig.TEST_ACK, new AMQP.BasicProperties(), String.format(msgTemplate,1).getBytes(StandardCharsets.UTF_8));
            channel.basicPublish("", RabbitMQConfig.TEST_ACK, new AMQP.BasicProperties(), String.format(msgTemplate,2).getBytes(StandardCharsets.UTF_8));
            channel.basicPublish("", RabbitMQConfig.TEST_ACK, new AMQP.BasicProperties(), String.format(msgTemplate,3).getBytes(StandardCharsets.UTF_8));
            //消息回滚
            channel.txRollback();
            //成功提交
            channel.basicPublish("", RabbitMQConfig.TEST_ACK, new AMQP.BasicProperties(), String.format(msgTemplate,4).getBytes(StandardCharsets.UTF_8));
            channel.txCommit();
            // 5、释放资源
            channel.close();
            connection.close();
        } catch (IOException | TimeoutException e) {
            e.printStackTrace();
        }
    }

消费者代码就不再展示,普通的接收并打印参数

上述生产者只发送了第四条消息,前三条都回滚了。

虽然事务可以保证消息一定被提交到服务器,而且在客户端编码方面足够简单。但是它也不是那么完美,在性能方面事务会带来较大的性能影响。如果对性能要求不是特别高的采用事务方式也是可以的,如果有性能方面的要求,可以使用Channel的确认机制。

confirm机制

通过 channel.confirmSelect 开启confirm 模式,confirm与事务机制不能共存。开启confirm 之后,每次发送消息后都会产生唯一的Id,如果消息投递成功 消费者 就会给 MQ客户端发送一个 ACK 确认,通过唯一ID我们就知道哪个消息发送成功。事务需要每次发送完成之后commit 或者 rollback ,导致不能连续发送,必须等到 MQ 响应。

confirm的发送和ack不冲突,相对异步,比事务的效率高

代码示例

生产者

public void testConfirm(){
        Connection connection = rabbitConfig.getConnection();
        try {
            Channel channel = connection.createChannel();
            //创建Exchange
            channel.exchangeDeclare(RabbitConfig.CONFIRM_EXCHANGE, BuiltinExchangeType.DIRECT, true, false, new HashMap<>());
            //创建Queue
            channel.queueDeclare(RabbitConfig.TEST_CONFIRM , true, false, false, new HashMap<>());
            //绑定路由
            channel.queueBind(RabbitConfig.TEST_CONFIRM , RabbitConfig.CONFIRM_EXCHANGE, "confirm");
            channel.confirmSelect();
            channel.addConfirmListener(new ConfirmListener() {
                @Override
                public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                    log.info("ack : deliveryTag = {},multiple = {}", deliveryTag, multiple);
                }
                @Override
                public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                    log.error("nack : deliveryTag = {},multiple = {}", deliveryTag, multiple);
                }
            });
            String msgTemplate = "测试消息[%d]";
            for (int i = 0; i < 5; i++) {
                channel.basicPublish(RabbitConfig.CONFIRM_EXCHANGE, "confirm", new AMQP.BasicProperties(), String.format(msgTemplate, i).getBytes(StandardCharsets.UTF_8));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

MQ环节

对于MQ服务器来说,要保证两个方面不出问题:1.消息成功持久化到磁盘;2.消息有多个副本

1.基于 Dledger 的 broker 主从架构,每个主 broker 需要挂至少 2 个 slave broker。

2.采用同步刷盘策略。

同步刷盘指 消息存到cache 后,将cache中的消息数据保存到磁盘,整个流程完成,返回success 给生产者。

异步刷盘指 消息存到cache 后,直接返回success 给生产者,同时进行将cache 数据保存到磁盘。

消费者

关闭ack 的 auto,手动处理完业务逻辑提交 offset。(详情看 RabbitMQ - Consumer Ack 这一篇)

不使用异步线程池处理消息。

总结

大多数保证消息不丢失就是取消异步操作,牺牲系统的性能:复杂生产者的逻辑,MQ服务器的吞吐量降低,消费者不能异步。具体结合实际业务来决定消息能不能丢失,比如:订单、交易等涉及钱的业务。