1.背景
在代码中 我们常常使用消息队列作为解耦使用 但是下游系统可能因为种种原因收到多条消息 并且对消息进行重复消费
在我最近做的拼团项目中 在用户退单后 如果是未成团的情况下需要异步对Redis库存进行补偿(组团占位)
为了保证提高接口响应程度以及扩展性 在同步更新完组团订单,组团状态,写入退单消息后
就将退单消息交给线程池 让他去发送MQ消息(我们采用了本地任务表保证最终一致性) 进行Redis的库存恢复(把组团占位让出来)
那么下游服务接收到MQ消息之后 可能就会导致消息的重复消费 消息的重复消费问题有很多:
- 消息生产者消息重发
- 未收到ACK 导致消息重复发送
- 消息处理完 但是宕机了 导致没有发送ACK
- 在Kafka中 没有提交位移服务端就宕机了 下次就会获取同一条消息继续处理
除了以上问题 还有很多原因 ...
在极端情况下 如果消息重发 我们下游处理Redis库存恢复时 可能会出现库存重复恢复的问题
问题指出
消息重复消费可能会导致我们的库存重复恢复 因此: 1.尽量避免消息重复消费 2.下游做好幂等保障
不是类似基于数据库的修改操作 不能通过索引 状态变更+乐观锁校验来判断并且回滚 那么问题如何解决?
方案分析
为了解决这个问题 我们想出了以下方案
a. 通过分布式锁setnx 对订单ID/消息ID进行加分布式锁 保证只有一个线程能够成功处理库存恢复 从而处理了消息重复消费的方法 为业务提供幂等性保障
b. 如果消息消费失败了 需要释放锁 并且抛出异常 让消息重新入队 后续重新进行消息消费
c.消息重试达到最大次数仍然失败 将消息加入死信队列 人工处理
最终流程分析
消息发送:
消息处理:
代码片段参考:
@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实现幂等性 根据业务并发程度选择"一锁二判三索引"(高并发)和 直接乐观锁判断(中低并发)
如果觉得文章对你有用 麻烦点个赞 谢谢!!!