【业务方案】消息重复消费的处理策略

34 阅读3分钟

1.背景

在代码中 我们常常使用消息队列作为解耦使用 但是下游系统可能因为种种原因收到多条消息 并且对消息进行重复消费

在我最近做的拼团项目中 在用户退单后 如果是未成团的情况下需要异步对Redis库存进行补偿(组团占位)
为了保证提高接口响应程度以及扩展性 在同步更新完组团订单,组团状态,写入退单消息后 就将退单消息交给线程池 让他去发送MQ消息(我们采用了本地任务表保证最终一致性) 进行Redis的库存恢复(把组团占位让出来)

那么下游服务接收到MQ消息之后 可能就会导致消息的重复消费 消息的重复消费问题有很多:

  • 消息生产者消息重发
  • 未收到ACK 导致消息重复发送
  • 消息处理完 但是宕机了 导致没有发送ACK
  • 在Kafka中 没有提交位移服务端就宕机了 下次就会获取同一条消息继续处理

除了以上问题 还有很多原因 ...

在极端情况下 如果消息重发 我们下游处理Redis库存恢复时 可能会出现库存重复恢复的问题

问题指出

消息重复消费可能会导致我们的库存重复恢复 因此: 1.尽量避免消息重复消费 2.下游做好幂等保障

不是类似基于数据库的修改操作 不能通过索引 状态变更+乐观锁校验来判断并且回滚 那么问题如何解决?

方案分析

为了解决这个问题 我们想出了以下方案

a. 通过分布式锁setnx 对订单ID/消息ID进行加分布式锁 保证只有一个线程能够成功处理库存恢复 从而处理了消息重复消费的方法 为业务提供幂等性保障

b. 如果消息消费失败了 需要释放锁 并且抛出异常 让消息重新入队 后续重新进行消息消费

c.消息重试达到最大次数仍然失败 将消息加入死信队列 人工处理

最终流程分析

消息发送:

77575aa83183d12f527ee86493f80ef3.png

消息处理:

image.png

代码片段参考:

@Override
public void refund2AddRecovery(String recoveryTeamStockKey, String orderId) {
    // 如果恢复库存key为空,直接返回
    if (StringUtils.isBlank(recoveryTeamStockKey) || StringUtils.isBlank(orderId)) {
        return;
    }

    // 使用orderId作为锁的key,避免同一订单重复恢复库存
    String lockKey = "refund_lock_" + orderId;
    
    // 尝试获取分布式锁,防止重复操作 具体日期可做调整
    Boolean lockAcquired = redisService.setNx(lockKey, 30 * 24 * 60 * 60 * 1000L, TimeUnit.MINUTES);
    
    if (!lockAcquired) {
        log.warn("订单 {} 恢复库存操作已在进行中,跳过重复操作", orderId);
        return;
    }

    try {
        // 在锁保护下执行库存恢复操作
        redisService.incr(recoveryTeamStockKey);
        log.info("订单 {} 恢复库存成功,恢复库存key: {}", orderId, recoveryTeamStockKey);
    } catch (Exception e) {
        log.error("订单 {} 恢复库存失败,恢复库存key: {}", orderId, recoveryTeamStockKey, e);
        // 如果抛异常则释放锁,允许MQ重新消费恢复库存
        redisService.remove(lockKey);
        throw e;
    }

}

实践指南

  • 合理配置消息队列参数 以RabbitMQ 合理设置重试次数 并且队列绑定死信队列 在消息消费多次失败后 加入死信队列 由人工介入处理

  • 消费者中应该避免长时间阻塞操作 以免影响消息吞吐和重试时效。异常处理应快速失败、释放资源、抛出异常触发重试

  • 上下游业务需要依赖于唯一ID实现幂等性 根据业务并发程度选择"一锁二判三索引"(高并发)和 直接乐观锁判断(中低并发)


如果觉得文章对你有用 麻烦点个赞 谢谢!!!