从生产者和消费者两方面解决消息队列重复消费问题

270 阅读3分钟

重复消费产生原因

排除人为配置还有代码原因的问题..

  • 生产者产生原因:

    1. 网络延迟,导致消息发送后,生产者收不到 ack 确认,认为消息丢失,重新发送,导致同一个请求的消息发送了两份。
    2. 服务故障,导致消息队列发送接收确认 ack 的时候,系统宕机,导致消息重新发送。
    3. 生产者端,没有对用户操作的不当行为,比如多次点击,发送多次请求进行处理。导致消息重复发送,还可能会造成 MQ 宕机(恶意请求)。
  • 消费端产生原因 :

    1. 网络问题
    2. 消息队列一般对消息消费失败都有重试机制(RocketMQ 有,kafka 没有),假如有一个消息消费执行了某些逻辑后在删除标识时(在实现了幂等消费的情况下)失败了 (服务宕机,断电等问题导致中断),那消息队列就会进行重试,而造成重复消费。

待补充。

怎么解决生产者方面的幂等问题

  1. 幂等处理:即通过设置唯一标识,对于同一请求的消息只会发送一次。

Kakfa 可以通过配置开启幂等发送的配置,更方便。

  1. 消息去重判定:可以在消息发送之前,在存储系统中,比如 redis 中查询是否已经发送了该消息,如果已经发送过,则不会再发送。
  2. 事务配置:利用消息队列本身的事务,如果发送失败则进行回滚,消息不会被发送,这样再次发送消息之后就能正常消费。

怎么解决消费者方面的问题

  1. 消息标识幂等处理:redis 标识判定消息消费状态(采用的)。

通过 Redis 设置幂等标识,当需要进行消费时,判断以当前消息 ID 为后缀的幂等 Key 是否存在于 Redis 中(存在则说明以及被消息),如果其已经被消费,则直接返回,(值为 0 说明没被消费完成)否则放行进行消费并设置完成标识(把值设置为 1)

完成标识和消息消费的标识是同一个 Key,因为消息消费是判断是否存在这条消息消费记录,而完成标识是通过设置这个 key 的 value 为 1 表示完成。

(双重判定保证消费不会因为异常丢失)这一点也可以写上。

消费过程如下:

 @Override
    public void onMessage(MapRecord<String, String, String> message) {
        String stream = message.getStream();
        RecordId id = message.getId();
        // 判断当前的这个消息是否已经被消费
        if (!messageQueueIdempotentHandler.isMessageProcessed(id.toString())) {
            //消费失败,但是此时已经设置了消费标识,可能会导致消息未被真实消费,所以设置一个完成标识
            if (messageQueueIdempotentHandler.isAccomplish(id.toString())) {
                return;
            }
            throw new ServiceException("消息未完成流程,需要消息队列重试");
        }
        try {
            Map<String, String> producerMap = message.getValue();
            String fullShortUrl = producerMap.get("fullShortUrl");
            if (StrUtil.isNotBlank(fullShortUrl)) {
                String gid = producerMap.get("gid");
                ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
                //核心:调用原本的短链接统计方法
                actualSaveShortLinkStats(fullShortUrl, gid, statsRecord);
            }
            //由于Redis自身没有像MQ那种智能的把消费过的信息,待一段时间后自动删除,而且内存珍贵,所以手动删除这个消息(也能避免重复消费)
            stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue());
        } catch (Throwable ex) {
            // 消息队列宕机处理
            messageQueueIdempotentHandler.delMessageProcessed(id.toString());
            log.error("记录短链接监控消费异常", ex);
        }
        //当完整执行完消费流程,才会设置完成标识。
        messageQueueIdempotentHandler.setAccomplish(id.toString());
    }
  1. 数据库的唯一索引限制兜底 (不适用于监控消息处理)。

虽然我这个监控请求的消息就是靠唯一索引进行增加 uv,pv 以及访问次数等信息的。但是对于一些其他业务场景,比如插入订单的场景,对于订单号进行唯一索引限制,这样插入多条同订单时就会抛出异常。实现唯一入库。

  1. 幂等处理消息 id

维护消费消息 id 集合,在进行消费之前判断是否消费过该消息。