
消息重复是RocketMQ使用中最容易遇到的"隐形炸弹"。比如支付场景中,一条"扣减库存"的消息被重复消费,可能导致库存多扣;订单场景里,重复的"确认收货"消息可能引发多次退款。更麻烦的是,RocketMQ无法100%避免消息重复,但我们可以通过"幂等设计"让重复消息"无害"。
本文拆解3种实战幂等方案,从原理到代码一步到位,帮你彻底解决重复消费问题。
本文是《RocketMQ核心问题全攻略》系列第2篇,
完整系列可查看主文:RocketMQ核心问题全攻略:5大痛点+原理+实操方案+避坑指南,
上一篇:RocketMQ怎么保证消息顺序?实操方案+代码示例
一、先看痛点:重复消费有多致命?
某电商平台曾因消息重复踩过的典型坑:
- 库存超卖:用户下单后,"扣减库存"消息因网络超时被生产者重试发送,消费者收到2条相同消息,导致库存多扣10件,最终无法发货;
- 重复退款:订单完成后,"通知退款"消息因消费者未及时提交Offset(消费进度),被Broker重新投递,财务系统重复退款,造成资金损失;
- 状态错乱:同一订单的"支付"和"取消"消息重复,导致订单状态在"已支付"和"已取消"之间反复切换,用户投诉不断。
核心问题:重复消息会直接导致业务数据不一致,而RocketMQ的设计逻辑中,"可靠性"优先于"去重"------为了避免消息丢失,允许一定程度的重复(比如重试机制、故障恢复时的消息重投)。
二、为什么会出现消息重复?
消息重复的根源是"网络不确定性"和"故障恢复机制",主要有3类场景,先定位原因才能对症下药:
1. 生产者重试导致重复
生产者发送消息后,未收到Broker的确认响应(可能是网络抖动、Broker短暂超时),会触发内置重试机制(默认同步发送重试3次)。例如:
- 生产者第一次发送消息成功,但响应在网络中丢失;
- 生产者认为发送失败,触发重试,导致同一消息被多次发送到Broker。
2. 消费者未提交Offset导致重复
消费者处理完消息后,需要向Broker提交"消费进度(Offset)",告知"这条消息我已经处理完了"。若提交前消费者宕机(如内存溢出、被kill),Broker会认为消息未被消费,待消费者重启后,会重新投递该消息。
3. Broker主从切换导致重复
Broker主节点宕机后,从节点会升级为主节点。若主节点未将所有消息同步给从节点,从节点可能会重复投递那些"主节点已处理但未同步"的消息(目的是保证消息不丢失,宁可重复也不丢失)。
三、3种幂等方案:从原理到代码实操
幂等的核心是"多次执行同一次操作,结果完全一致"。针对RocketMQ消息重复,推荐3种方案,按优先级排序如下:
方案1:业务唯一键(推荐,最可靠)
原理:利用业务天然的唯一标识(如订单ID、支付流水号、用户ID+操作时间),通过数据库或缓存判断消息是否已处理。适合所有场景,不依赖消息中间件,可靠性最高。
实现步骤:
- 生产者发送消息时,在消息中携带业务唯一键(如订单ID);
- 消费者处理前,先查询该业务键的状态(如"订单是否已支付");
- 若已处理则跳过,未处理则执行逻辑并标记状态。
代码示例(订单支付场景)
java
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
@RocketMQMessageListener(
topic = "pay_topic", // 监听的Topic
consumerGroup = "pay_consumer_group" // 消费组
)
public class PayMessageConsumer implements RocketMQListener<MessageExt> {
@Autowired
private OrderMapper orderMapper; // 订单数据库操作类
@Override
public void onMessage(MessageExt message) {
// 1. 从消息中获取业务唯一键(订单ID,生产者发送时存入消息属性)
String orderId = message.getUserProperty("orderId");
if (orderId == null) {
log.error("消息缺少订单ID,跳过处理");
return;
}
// 2. 查询订单当前状态(判断是否已处理)
Order order = orderMapper.selectById(orderId);
if (order == null) {
log.error("订单{}不存在,跳过处理", orderId);
return;
}
// 3. 若已支付,则忽略重复消息
if ("PAID".equals(order.getStatus())) {
log.info("订单{}已支付,忽略重复消息", orderId);
return;
}
// 4. 未支付则执行支付逻辑(扣减库存、更新状态等)
processPayment(order); // 核心业务逻辑:调用支付接口、扣减库存等
orderMapper.updateStatus(orderId, "PAID"); // 标记为已处理
log.info("订单{}支付处理完成", orderId);
}
// 支付核心逻辑(示例)
private void processPayment(Order order) {
// 1. 调用支付网关确认支付结果
// 2. 扣减商品库存
// 3. 生成支付记录
}
}
优点:
- 与业务强绑定,不依赖任何中间件(如Redis、消息ID),稳定性最高;
- 即使消息丢失或重复,都能通过业务状态判断,符合实际业务逻辑。
缺点:
- 需要在业务表中维护状态字段(如
status),略增表结构复杂度; - 高并发下需注意"判断-处理"的原子性(见避坑指南)。
方案2:唯一消息ID(次选,依赖消息标识)
原理 :利用RocketMQ消息自带的唯一ID(msgId)或生产者自定义的唯一键(keys),通过Redis或数据库记录"已处理的消息ID",实现去重。适合无业务唯一键的场景(如日志同步、数据备份)。
实现步骤:
- 生产者发送消息时,设置自定义
keys(推荐,比默认msgId更可控); - 消费者接收消息后,先检查Redis中是否存在该
keys; - 若存在则跳过,不存在则处理消息并将
keys存入Redis(设置过期时间,避免内存溢出)。
代码示例(Redis去重)
java
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@RocketMQMessageListener(
topic = "log_topic", // 日志同步Topic
consumerGroup = "log_consumer_group"
)
public class LogMessageConsumer implements RocketMQListener<MessageExt> {
@Autowired
private StringRedisTemplate redisTemplate; // Redis操作类
@Autowired
private LogService logService; // 日志处理服务
@Override
public void onMessage(MessageExt message) {
// 1. 获取消息唯一标识(优先用自定义keys,其次用msgId)
String messageKey = message.getKeys();
if (messageKey == null) {
messageKey = message.getMsgId(); // RocketMQ自动生成的唯一ID
}
// 2. 检查是否已处理(Redis的setIfAbsent是原子操作,避免并发问题)
String redisKey = "processed:msg:" + messageKey;
Boolean isNewMessage = redisTemplate.opsForValue().setIfAbsent(
redisKey,
"1", // 占位值,无实际意义
24, // 过期时间:根据业务保留24小时(足够处理重复消息)
TimeUnit.HOURS
);
// 3. 已处理则跳过
if (isNewMessage == null || !isNewMessage) {
log.info("消息{}已处理,忽略重复", messageKey);
return;
}
// 4. 未处理则执行业务逻辑(如同步日志到数据库)
String logContent = new String(message.getBody()); // 消息体内容
logService.syncLog(logContent); // 同步日志
log.info("消息{}处理完成", messageKey);
}
}
优点:
- 实现简单,不侵入业务表,适合日志、监控等无业务ID的场景;
- Redis查询速度快,对性能影响小。
缺点:
- 依赖消息ID的唯一性(RocketMQ的
msgId在极端情况下可能重复,建议自定义keys); - 需要维护Redis等中间件,增加系统复杂度。
方案3:状态机(适合流程固定的场景)
原理:将业务流程设计为"状态机"(如订单状态:待支付→支付中→已支付→已取消),重复消息触发状态转换时,若"当前状态→目标状态"不合法,则直接忽略。适合有明确状态流转的业务(如订单、支付、物流)。
示例(订单状态机判断)
订单状态枚举:
java
// 订单状态:明确的流转关系
public enum OrderStatus {
PENDING_PAY("待支付"), // 初始状态
PAYING("支付中"), // 中间状态
PAID("已支付"), // 终态1
CANCELLED("已取消"); // 终态2
private String desc;
// 构造器、getter省略
}
消费者处理逻辑:
java
@Override
public void onMessage(MessageExt message) {
String orderId = message.getUserProperty("orderId");
String action = message.getUserProperty("action"); // 消息动作:pay(支付)/cancel(取消)
Order order = orderMapper.selectById(orderId);
if ("pay".equals(action)) {
// 只有"待支付"状态可处理支付消息(状态机约束)
if (OrderStatus.PENDING_PAY.equals(order.getStatus())) {
processPayment(order); // 处理支付
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
} else {
log.info("订单{}当前状态{},忽略重复支付消息", orderId, order.getStatus());
}
} else if ("cancel".equals(action)) {
// 只有"待支付"或"支付中"状态可处理取消消息
if (OrderStatus.PENDING_PAY.equals(order.getStatus())
|| OrderStatus.PAYING.equals(order.getStatus())) {
processCancel(order); // 处理取消
order.setStatus(OrderStatus.CANCELLED);
orderMapper.updateById(order);
} else {
log.info("订单{}当前状态{},忽略重复取消消息", orderId, order.getStatus());
}
}
}
优点:
- 通过状态约束天然实现幂等,无需额外存储(如Redis、状态字段);
- 符合业务流程设计,逻辑清晰。
缺点:
- 仅适合流程固定、状态明确的业务,通用性较弱(如日志、统计类消息不适用);
- 状态流转规则需严格设计,避免遗漏异常状态。
四、避坑指南:3个关键注意事项
⚠️ 避坑1:分布式场景必须保证"判断-处理"的原子性
高并发下,两个相同的消息可能同时通过"已处理判断",导致重复处理。例如:
- 消息A和消息A'同时查询订单状态,都发现"未支付",然后同时执行支付逻辑。
解决办法:加锁保证原子性
- 数据库行锁:
select * from order where id = ? for update(查询时加锁,阻止其他事务修改); - Redis分布式锁:通过
setIfAbsent获取锁,处理完释放(适合非数据库场景)。
⚠️ 避坑2:自定义消息keys比依赖msgId更可靠
RocketMQ的msgId是Broker生成的唯一ID,但在"生产者重试+Broker主从切换"的极端场景下可能重复。建议生产者发送消息时主动设置keys(如UUID+业务ID),确保唯一性:
java
// 生产者设置自定义keys示例
Message<String> message = MessageBuilder.withPayload("支付消息")
.setHeader(RocketMQHeaders.KEYS, "PAY_" + orderId + "_" + System.currentTimeMillis())
.build();
⚠️ 避坑3:非核心消息可容忍"至少一次",但核心消息必须幂等
日志、监控等非核心消息,即使重复消费也不会造成业务损失,可简化处理(甚至不做幂等);但支付、库存、订单等核心场景,必须严格实现幂等,宁可多写代码,也不能因重复消费导致资损。
五、总结
RocketMQ消息重复不可避免,但通过幂等设计可让重复"无害":
- 最佳实践:优先用"业务唯一键"判断(如订单ID),简单可靠且通用性强,适配90%以上场景;
- 次选方案:无业务ID时用"唯一消息ID+Redis",需注意ID唯一性和过期时间;
- 补充方案:状态明确的业务(如订单)可结合"状态机",双重保障幂等性。
记住:幂等设计的核心不是"阻止重复",而是"让重复产生的影响为零"。
互动话题:你在项目中用哪种方式解决消息重复?有没有遇到过特殊场景的幂等难题?评论区分享你的经验~
下一篇将拆解《RocketMQ消息积压紧急处理!3个应急方案+长期优化》,关注我获取完整系列内容