[中间件~大厂必问面试题] 神秘解决异步消息黑洞问题!再也不用担心重复和丢失了!

解决异步消息不重复不丢失的问题

对于异步消息系统,消息的准确可靠性是非常重要的,其中不重复和不丢失更是最基本的要求之一。在这里,我们将结合 RabbitMQ、Spring Boot、Redis 和数据库等技术,从三个方面来解决这个问题。

在这里插入图片描述



是什么: 异步消息不重复不丢失

异步消息系统通常由消息生产者、消息队列和消息消费者等组成。生产者将消息发送到消息队列,消费者从队列中获取消息并处理。这种方式可以达到解耦、异步和可扩展的目的。

在我们的解决方案中,我们使用 RabbitMQ 作为消息队列,Spring Boot 作为框架,Redis 作为缓存,数据库作为持久化。RabbitMQ 是一个开源的消息代理,它可以实现 AMQP(Advanced Message Queuing Protocol)协议,支持持久化、可靠性、扩展性和灵活性等特性。Spring Boot 可以快速搭建 Web 应用,并且对 RabbitMQ 有良好的支持。Redis 是一个内存数据库,可以作为缓存和分布式锁等用途。数据库则提供消息的持久化。

为什么: 异步消息的重复和丢失的原因

  • 异步消息的重复通常是由于消费者在消费消息后,没有正确地向消息队列确认消费成功,导致消息队列认为该消息未被消费,从而再次将该消息发送给消费者。

  • 异步消息的丢失则可能是由于生产者在发送消息后,消息队列没有正确地接收到该消息,或者消费者在消费消息前,该消息已经从消息队列中被删除。

为了保证消息的不重复和不丢失,我们需要考虑以下几个方面:

1. 消息的幂等性

幂等性指多次调用同一接口,对于相同的输入参数,结果不会发生变化。在消息系统中,保证消息的幂等性可以避免消息的重复处理。为了实现消息的幂等性,我们需要为每个消息生成唯一的 ID,并将消息的处理结果和 ID 存储到缓存或数据库中。在处理消息前,先判断是否已经存在相同的 ID,如果存在则不处理该消息。

2. 消息的持久化

消息的持久化可以避免消息在处理中丢失。在 RabbitMQ 中,我们可以设置消息队列的持久化属性,使得队列和消息可以存储到磁盘中,在服务器重启后仍能恢复。同时,我们还可以将消息的 ID 和处理结果存储到数据库中,以保证数据的持久化。

3. 消息的重试

在消息处理失败时,我们需要重新处理该消息。为了避免重复处理已经成功的消息,我们需要记录每个消息的处理次数,并设置最大重试次数。如果消息处理失败,则将消息重新加入队列中,并将消息的处理次数加一。

4. 消息的消费确认

在消息处理完成后,我们需要向 RabbitMQ 发送消费确认,告知 RabbitMQ 该消息已经被成功处理。这样可以避免消息被重复消费。同时,我们还可以将消费确认和消息的处理结果一起存储到数据库中,以备后续查询和分析。

怎么办: 异步消息的重复与丢失的解决方案

在实现上述功能时,我们可以采用以下技术和方案:

1. 生成消息 ID

我们可以使用 UUID(Universally Unique Identifier)作为消息的 ID。UUID 是一种 128 位的数字,可以保证全球唯一。在 Spring Boot 中,我们可以使用 java.util.UUID 类来生成 UUID。

2. 消息的持久化

在 RabbitMQ 中,我们可以设置消息队列的持久化属性,使得队列和消息可以存储到磁盘中。同时,在发送消息时,我们可以设置消息的持久化属性,使得消息可以在队列中持久化存储。在消费消息时,我们可以使用 channel.basicAck() 方法来发送消费确认。

3. 消息的重试

在处理消息失败时,我们可以将消息重新加入队列中,并设置消息的 TTL(Time To Live)。TTL 是消息在队列中可以存活的时间,到期后将被自动删除。如果消息处理失败,则等待一段时间后重新加入队列中,再次尝试处理。在 RabbitMQ 中,我们可以使用 channel.basicReject() 方法来拒绝消息,并将消息重新加入队列。

4. 消息的消费确认

在消费消息并处理完成后,我们可以使用 channel.basicAck() 方法来发送消费确认。同时,我们可以将消费确认和消息的处理结果一起存储到数据库中。在查询和分析时,可以根据消息的 ID 来查询消息的处理结果。为了保证消息的可靠性,我们还可以使用 Redis 来提供分布式锁,避免消息的重复处理。

纸上的来终觉浅

1. 消息幂等的简单demo

以下是基于 RabbitMQ、Redis、Spring Boot、MySQL 的消息幂等的 Demo。

首先,定义一个 Order 消息类,用于发送和接收消息:

public class Order implements Serializable {
    private static final long serialVersionUID = 1L;

    private String orderId;
    private String userId;
    private String productId;
    private Integer amount;

    // 省略 getter 和 setter 方法
}

接着,定义一个消息处理类,用于处理接收到的消息:

@Component
public class OrderConsumer {
    private static final Logger logger = LoggerFactory.getLogger(OrderConsumer.class);

    @Autowired
    private OrderService orderService;

    /**
     * 消费消息
     */
    @RabbitListener(queues = "order.queue")
    public void consume(Order order) {
        logger.info("接收到订单消息:{}", order);
        try {
            // 幂等性校验
            if (orderService.isProcessed(order.getOrderId())) {
                logger.info("订单已处理,忽略处理请求");
                return;
            }

            // 处理订单
            orderService.processOrder(order);
            logger.info("订单处理成功");
        } catch (Exception e) {
            logger.error("订单处理失败:{}", e.getMessage());
        }
    }
}

在消息处理类中,首先进行幂等性校验,判断该订单是否已经被处理过了。如果已经被处理,就忽略该消息。如果没有被处理,就处理该订单并将处理结果保存到数据库中。

接着,定义一个 OrderService 类,用于处理订单:

@Service
public class OrderService {
    private static final Logger logger = LoggerFactory.getLogger(OrderService.class);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 处理订单
     */
    public void processOrder(Order order) {
        logger.info("开始处理订单:{}", order);

        // 执行业务逻辑...

        // 订单处理完成,将订单 ID 加入 Redis 缓存
        redisTemplate.opsForValue().setIfAbsent(order.getOrderId(), "");
        logger.info("订单处理完成,订单 ID:{}", order.getOrderId());
    }

    /**
     * 判断订单是否已被处理过
     */
    public boolean isProcessed(String orderId) {
        return redisTemplate.hasKey(orderId) || jdbcTemplate.queryForObject(
                "select count(*) from order_info where order_id = ?", Integer.class, orderId) > 0;
    }
}

在 OrderService 类中,定义了处理订单和判断订单是否已被处理过的方法。处理订单时,先执行具体业务逻辑,然后将订单 ID 放入 Redis 缓存中表示该订单已经被处理过了。判断订单是否已被处理过时,先从 Redis 缓存中查找,如果不存在再从 MySQL 数据库中查找,如果都没有查到则表示订单未被处理过。

最后,定义一个 OrderController 类,用于发送订单消息:

@RestController
@RequestMapping("/orders")
public class OrderController {
    private static final Logger logger = LoggerFactory.getLogger(OrderController.class);

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 发送订单消息
     */
    @PostMapping("")
    public String sendOrder(@RequestBody Order order) {
        try {
            // 发送订单消息
            rabbitTemplate.convertAndSend("order.exchange", "order.routingKey", order);
            logger.info("订单发送成功:{}", order);
            return "订单发送成功";
        } catch (Exception e) {
            logger.error("订单发送失败:{}", e.getMessage());
            return "订单发送失败";
        }
    }
}

在 OrderController 类中,定义了发送订单消息的方法。该方法通过 RabbitTemplate 对象发送订单消息。

至此,一个基于 RabbitMQ、Redis、Spring Boot、MySQL 的消息幂等的 Demo 就完成了。

2. 消息重试以及消息确认的简单demo

以下是基于 RabbitMQ、Redis、Spring Boot、MySQL 的消息重试以及消息确认的 Demo。

首先,定义一个 Order 消息类,用于发送和接收消息:

public class Order implements Serializable {
    private static final long serialVersionUID = 1L;

    private String orderId;
    private String userId;
    private String productId;
    private Integer amount;

    // 省略 getter 和 setter 方法
}

接着,定义一个消息处理类,用于处理接收到的消息:

@Component
public class OrderConsumer {
    private static final Logger logger = LoggerFactory.getLogger(OrderConsumer.class);

    @Autowired
    private OrderService orderService;

    /**
     * 消费消息
     */
    @RabbitListener(queues = "order.queue")
    public void consume(Order order, @Headers Map<String, Object> headers, Channel channel) throws IOException {
        logger.info("接收到订单消息:{}", order);
        try {
            // 处理订单
            orderService.processOrder(order);
            logger.info("订单处理成功");

            // 手动确认消息已消费
            Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            logger.error("订单处理失败:{}", e.getMessage());

            // 消息重试
            if (orderService.isRetryable(order.getOrderId())) {
                logger.info("订单可重试,开始重试");

                // 手动确认消息未消费,退回队列并开启重试
                Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
                channel.basicNack(deliveryTag, false, true);

                // 将订单 ID 加入 Redis 缓存,用于记录重试次数
                orderService.addRetryTask(order.getOrderId());

                logger.info("订单重试完成");
            } else {
                logger.info("订单不可重试,拒绝该消息");
                Long deliveryTag = (Long) headers.get(AmqpHeaders.DELIVERY_TAG);
                channel.basicReject(deliveryTag, false);
            }
        }
    }
}

在消息处理类中,先处理订单,如果处理成功就手动确认消息已消费,如果处理失败就进行消息重试。如果订单可重试,就退回消息队列并开启重试任务,将订单 ID 加入 Redis 缓存中用于记录重试次数。如果订单不可重试,就拒绝该消息。需要注意的是,在使用 basicNack 方法退回消息队列时,第三个参数 requeue 表示该消息是否重新放回队列,在使用 basicReject 方法时,第二个参数 requeue 表示该消息是否重新放回队列。

接着,定义一个 OrderService 类,用于处理订单:

@Service
public class OrderService {
    private static final Logger logger = LoggerFactory.getLogger(OrderService.class);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 处理订单
     */
    @Transactional
    public void processOrder(Order order) {
        logger.info("开始处理订单:{}", order);

        // 执行业务逻辑...

        // 订单处理完成,将订单 ID 从 Redis 缓存中删除
        redisTemplate.delete(order.getOrderId());
        logger.info("订单处理完成,订单 ID:{}", order.getOrderId());
    }

    /**
     * 判断订单是否可重试
     */
    public boolean isRetryable(String orderId) {
        // 从 Redis 缓存中读取重试次数,如果超过三次则不再重试
        Integer retries = (Integer) redisTemplate.opsForValue().get(orderId);
        return retries == null || retries < 3;
    }

    /**
     * 添加重试任务
     */
    public void addRetryTask(String orderId) {
        redisTemplate.opsForValue().increment(orderId, 1L);
    }
}

在 OrderService 类中,定义了处理订单、判断订单是否可重试、添加重试任务的方法。处理订单时,先执行具体业务逻辑,然后将订单 ID 从 Redis 缓存中删除。判断订单是否可重试时,从 Redis 缓存中读取重试次数,如果超过三次则不再重试。添加重试任务时,将订单 ID 加入 Redis 缓存中,并将重试次数加一。

最后,定义一个 OrderController 类,用于发送订单消息:

@RestController
@RequestMapping("/orders")
public class OrderController {
    private static final Logger logger = LoggerFactory.getLogger(OrderController.class);

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 发送订单消息
     */
    @PostMapping("")
    public String sendOrder(@RequestBody Order order) {
        try {
            // 发送订单消息
            rabbitTemplate.convertAndSend("order.exchange", "order.routingKey", order, new CorrelationData(order.getOrderId()));
            logger.info("订单发送成功:{}", order);
            return "订单发送成功";
        } catch (Exception e) {
            logger.error("订单发送失败:{}", e.getMessage());
            return "订单发送失败";
        }
    }
}

在 OrderController 类中,定义了发送订单消息的方法。该方法通过 RabbitTemplate 对象发送订单消息,并通过 CorrelationData 类型的参数设置消息的唯一标识。

至此,一个基于 RabbitMQ、Redis、Spring Boot、MySQL 的消息重试以及消息确认的 Demo 就完成了。

注意

已上确认幂等的处理,如果在特别高并发的时候,仍然会造成消息的重复落库,如果遇到可以考虑用分布式锁或者唯一索引互斥落库。

使用分布式锁可以保证同一时刻只有一个线程能够处理消息,避免重复落库。常见的分布式锁实现方式有 Redis 分布式锁、Zookeeper 分布式锁等。

使用唯一索引可以保证相同的消息只会被落库一次,避免重复落库。在 MySQL 中,可以通过在表上创建唯一索引来实现。

需要注意的是,使用分布式锁或唯一索引可能会影响系统的性能和可用性,因此需要根据具体情况进行权衡和优化。

总结

文章主要介绍了如何解决异步消息不重复不丢失的问题。通过使用 RabbitMQ 作为消息队列,Spring Boot 作为框架,Redis 作为缓存,数据库作为持久化,从生成消息 ID、消息的持久化、消息的重试、消息的消费确认等几个方面提出了解决方案。文章详细介绍了如何实现每个方面的解决方案,从而保证了消息的准确可靠性。

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值