在 MQ(RocketMQ/RabbitMQ/Kafka 等)中,"至少一次 "投递语义决定了 重复消费必然存在 ,只能 业务侧幂等 来"防"。
把散落在各博客里的方案提炼后,企业级落地 就 4 套组合拳,按 "轻→重" 顺序
一、4 套通用方案(附适用场景)
方案 | 实现要点 | 性能 | 一致性 | 场景 |
---|---|---|---|---|
1. 唯一 ID + Redis setNX + TTL | 消息头带唯一 key;SETNX 原子判重,成功才处理 | 高 | 最终一致 | 高并发、非核心 |
2. 数据库去重表(唯一索引) | 本地事务内先插去重表,主键冲突即跳过 | 中 | 强一致 | 订单、支付核心 |
3. 分布式锁(Redis/ZK) | 以 msgId 为锁 key,确保集群同时仅一个节点处理 | 中高 | 并发安全 | 长耗时、多节点竞争 |
4. 死信队列 + 重试上限 | 消费失败次数 > N 移到 DLQ,人工兜底 | - | 防无限重试 | 所有方案配套 |
二、Redis 方案 1 行代码
java
// 返回 true 表示未处理过,可以继续业务
Boolean first = stringRedisTemplate.opsForValue()
.setIfAbsent(msgId, "1", Duration.ofMinutes(10));
if (Boolean.TRUE.equals(first)) {
// 业务逻辑
} else {
log.warn("重复消费 msgId={}", msgId);
}
10 min TTL 避免 key 无限膨胀;也可
SET msgId 1 NX PX 600000
原生命令。
三、Spring AOP 注解式(零侵入)
- 注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMQDuplicate {
String keyPrefix() default "mq:dup:";
long timeout() default 600; // 秒
}
- 切面(Lua 脚本保证 get+set 原子)
java
@Aspect
@Component
@RequiredArgsConstructor
public class NoMQDuplicateAspect {
private final StringRedisTemplate redis;
private static final DefaultRedisScript<String> LUA = new DefaultRedisScript<>(
"local key=KEYS[1] local val=ARGV[1] local ttl=ARGV[2] " +
"return redis.call('SET',key,val,'NX','PX',ttl)", String.class);
@Around("@annotation(noMQDuplicate)")
public Object around(ProceedingJoinPoint pjp, NoMQDuplicate noMQDuplicate) throws Throwable {
String msgId = SpELUtil.parseKey(noMQDuplicate.key(), pjp); // 解析 SpEL 拿 msgId
String key = noMQDuplicate.keyPrefix() + msgId;
String flag = redis.execute(LUA, List.of(key), "1", String.valueOf(noMQDuplicate.timeout() * 1000));
if (flag == null) { // 已存在
log.warn("重复消费 key={}", key);
return null; // 直接返回,不抛异常
}
try {
return pjp.proceed(); // 执行业务
} catch (Throwable t) {
redis.delete(key); // 业务异常→删除 key,允许重试
throw t;
}
}
}
- 使用
java
@RabbitListener(queues = "order.queue")
@NoMQDuplicate(key = "#message.headers['msgId']", timeout = 600)
public void handle(OrderMessage message) {
// 正常写业务,无需再关心幂等
}
同理可用于 RocketMQ、Kafka 的
@StreamListener
或@KafkaListener
。
四、数据库去重表(核心场景)
表结构
sql
CREATE TABLE mq_dedup (
msg_id VARCHAR(64) PRIMARY KEY,
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
消费逻辑(本地事务)
java
@Transactional
public void consume(String msgId, Runnable business) {
try {
jdbcTemplate.update("INSERT INTO mq_dedup(msg_id) VALUES(?)", msgId);
} catch (DuplicateKeyException e) {
log.warn("重复消息 {}", msgId);
return;
}
business.run(); // 执行业务
}
插入与业务在同一线程事务,保证 "记录存在 ⇒ 业务已做完" 原子性。
五、小结
-
MQ 自身无法杜绝重复,只能业务幂等。
-
90% 场景 用「唯一 ID + Redis SETNX + TTL」足够;核心/金钱流程叠加「去重表 + 事务」。
-
长耗时/多节点竞争再补「分布式锁」。
-
失败重试 设上限并配 DLQ,避免无限死循环