MQ消息队列幂等性设计与踩坑实战
前言
这篇是消息队列系列的学习笔记,这次整理的是 MQ 幂等性设计。
说实话,写这篇文章是因为最近实习的时候踩了个大坑。当时在写一个业务,用到了 MQ 消费消息,我心想幂等性嘛,简单,消费前先往 Redis 里塞个标识不就完了?结果呢,业务执行到一半失败了,抛了个异常,消息重新投递回来准备重试------诶,发现 Redis 里已经有幂等标识了,直接跳过,不处理了。得,这条消息就这么"丢"了,数据也没入库成功。
当时我就懵了:明明是为了防重复消费加的幂等,怎么反过来把正常的重试给拦住了?
后来仔细研究了一下,发现 MQ 幂等性这事儿,还真没那么简单。今天就来聊聊这个话题,顺便分享一下我踩坑后总结出来的最佳实践。
🏠个人主页:山沐与山
文章目录
- [一、什么是幂等性?为什么 MQ 要保证幂等?](#一、什么是幂等性?为什么 MQ 要保证幂等?)
- [二、MQ 重复消费的场景分析](#二、MQ 重复消费的场景分析)
- 三、幂等性方案设计:三态模型
- 四、核心代码实现
- 五、消费者模板代码详解
- [六、RocketMQ 幂等性实战](#六、RocketMQ 幂等性实战)
- 七、踩坑实录与解决方案
- 八、常见问题
- 九、总结
一、什么是幂等性?为什么 MQ 要保证幂等?
1.1 先搞懂幂等性
什么是幂等性?说白了就是:同一个操作,执行一次和执行一百次,效果是一样的。
数学上有个公式:f(f(x)) = f(x),翻译成人话就是------你重复做这件事,结果不会变。
举几个例子你就明白了:
| 操作 | 是否幂等 | 为什么 |
|---|---|---|
| 查询订单详情 | 幂等 | 查一百次,订单还是那个订单 |
| 根据 ID 删除记录 | 幂等 | 删一次是删,删十次还是删了 |
| 把用户状态设成"已激活" | 幂等 | 设一次是激活,设一百次还是激活 |
| 创建订单 | 不幂等 | 创建一次是一个订单,重复创建就有多个订单了 |
| 库存扣减 | 不幂等 | 扣一次是扣 1,重复扣就扣多了 |
| PV 统计 +1 | 不幂等 | 加一次是 +1,重复加就多加了 |
看到没?关键区别在于:幂等操作不会因为重复执行而产生副作用,而非幂等操作会。
1.2 MQ 为啥会重复消费?
你可能会问:消息不是消费完就没了吗,哪来的重复消费?
别天真了,分布式系统里,重复消费简直是家常便饭。来看看常见的几种情况:
场景一:生产者发送重试
生产者发消息 ──────→ MQ
↑ │
│ 网络超时 │ 其实已经收到了
└──────────────┘
生产者以为失败了,又发了一遍,MQ 就收到两条一样的消息
场景二:消费者处理完但 ACK 失败
MQ ──────→ 消费者处理完了
←────── 准备发 ACK
网络抖动,ACK 丢了
MQ 以为你没处理完,又投递了一遍
场景三:消费者 Rebalance
这个在 Kafka、RocketMQ 里很常见。消费者组重新分配分区的时候,可能导致同一条消息被不同消费者拿到。
场景四:消费者宕机
消费者处理到一半,服务器挂了。MQ 没收到 ACK,消息重新投递给其他消费者。
你说说,这些情况谁能保证不发生?所以 MQ 消费端必须要有幂等性保障。
二、MQ 重复消费的场景分析
2.1 重复消费会带来什么问题?
以电商场景为例:
java
// 假设这是你的消费逻辑
public void onMessage(OrderMessage message) {
// 扣减库存
inventoryService.decrease(message.getProductId(), message.getQuantity());
// 更新订单状态
orderService.updateStatus(message.getOrderId(), "PAID");
// 发送短信通知
smsService.sendPaymentSuccess(message.getUserPhone());
}
如果这条消息被消费两次会怎样?
- 库存被扣了两次(本来买 1 件,扣了 2 件的库存)
- 用户收到两条"支付成功"短信(虽然不致命,但用户体验差)
再比如我实习时做的短链接统计:
java
public void onMessage(StatsMessage message) {
// PV +1
linkStatService.increasePV(message.getShortUrl());
// UV 统计
linkStatService.recordUV(message.getShortUrl(), message.getUserId());
}
重复消费的话,PV 数据就不准了。本来访问了 100 次,统计出来 200 次,这数据还怎么看?
2.2 幂等性方案对比
市面上常见的幂等性方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 数据库唯一索引 | 利用数据库唯一约束 | 强一致性 | 性能差,有数据库瓶颈 |
| Redis SETNX | 利用 SETNX 原子操作 |
性能高 | 需要处理好过期时间 |
| 业务状态机 | 判断业务状态是否允许操作 | 贴合业务 | 实现复杂 |
| Token 机制 | 先获取 Token,操作时校验并删除 | 通用性强 | 多一次交互 |
今天重点讲 Redis SETNX 方案,因为在 MQ 场景下它是性能和实现复杂度的最佳平衡点。
三、幂等性方案设计:三态模型
3.1 为什么简单的"存在即消费过"行不通?
很多人第一反应是这样做:
java
// 错误示范!
public void onMessage(Message message) {
String messageId = message.getId();
// 判断是否已消费
if (redis.exists(messageId)) {
return; // 存在就跳过
}
// 标记为已消费
redis.set(messageId, "1", 2, TimeUnit.MINUTES);
// 处理业务
doBusinessLogic(message);
}
看起来没毛病?问题大了!
问题一:消费到一半挂了
T+0 标记 messageId 到 Redis
T+1 开始处理业务
T+5 服务器宕机了!业务没处理完
T+6 MQ 重新投递消息
T+7 发现 Redis 里有标识,直接跳过
结果:消息永远不会被正确处理了!
这就是我实习时踩的坑------先标记后处理,一旦处理失败,标识还在,后续重试全被拦住。
问题二:业务失败了怎么办
java
try {
doBusinessLogic(message);
} catch (Exception e) {
// 抛异常了,业务没成功
// 但是 Redis 里已经有标识了
// 后续重试还是会被跳过
}
同样的问题,业务失败了,幂等标识却留着,重试机制形同虚设。
3.2 三态模型:未消费 / 消费中 / 已完成
解决方案是引入三个状态:
| 状态 | Redis 表现 | 含义 |
|---|---|---|
| 未消费 | Key 不存在 | 从未处理过这条消息 |
| 消费中 | Key 存在,Value = "0" |
正在处理,或者处理中断了 |
| 已完成 | Key 存在,Value = "1" |
已经成功处理完 |
状态流转图:
┌─────────────┐
│ 未消费 │
│ (Key不存在) │
└──────┬──────┘
│
│ SETNX 成功,Value="0"
▼
┌─────────────┐
┌──────────│ 消费中 │──────────┐
│ │ (Value="0") │ │
│ └──────┬──────┘ │
│ │ │
消费失败 消费成功 宕机了
删除 Key Value 改成 "1" (什么都没做)
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 未消费 │ │ 已完成 │ │ 消费中 │
│ (可重试) │ │ (Value="1") │ │ (等TTL过期) │
└─────────────┘ └─────────────┘ └─────────────┘
关键点:
- 消费成功才标记为完成
- 消费失败要删除标识,允许重试
- 宕机了等 TTL 过期后可以重新消费
四、核心代码实现
4.1 幂等处理器
来看看核心的幂等处理器怎么写:
java
@Component
@RequiredArgsConstructor
public class MessageQueueIdempotentHandler {
private final StringRedisTemplate stringRedisTemplate;
private static final String IDEMPOTENT_KEY_PREFIX = "mq:idempotent:";
/**
* 尝试获取消费权
* @return true-首次消费,false-重复消息
*/
public boolean isMessageProcessed(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
// SETNX + 过期时间,原子操作
return Boolean.TRUE.equals(
stringRedisTemplate.opsForValue()
.setIfAbsent(key, "0", 2, TimeUnit.MINUTES)
);
}
/**
* 检查消息是否已完成
*/
public boolean isAccomplish(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
return Objects.equals(
stringRedisTemplate.opsForValue().get(key),
"1"
);
}
/**
* 标记消费完成
*/
public void setAccomplish(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
}
/**
* 删除幂等标识(消费失败时调用)
*/
public void delMessageProcessed(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
stringRedisTemplate.delete(key);
}
}
4.2 四个方法的作用
| 方法 | 作用 | 调用时机 |
|---|---|---|
isMessageProcessed |
尝试获取消费权 | 消费开始前 |
isAccomplish |
检查是否已完成 | 发现是重复消息时 |
setAccomplish |
标记消费完成 | 业务处理成功后 |
delMessageProcessed |
删除标识 | 业务处理失败时 |
4.3 为什么 TTL 设置 2 分钟?
这是个权衡的艺术:
太短(比如 30 秒)有什么问题?
T+0s 消费者 A 开始处理
T+25s 业务还在处理中(比如调了个慢接口)
T+30s Key 过期了!
T+31s 消费者 B 也开始处理这条消息
T+35s 消费者 A 处理完了
T+40s 消费者 B 也处理完了
→ 重复处理了!
太长(比如 30 分钟)有什么问题?
T+0s 消费者 A 开始处理
T+1s 服务器宕机了
T+2s 消息被重新投递
T+2s 发现 Key 还在,抛异常等重试
... 一直等,一直重试失败
T+30m Key 终于过期了,才能正常消费
→ 消息延迟 30 分钟才被处理!
2 分钟是个平衡点:
- 绝大多数业务处理 10 秒内完成,2 分钟足够覆盖
- 即使宕机了,最多等 2 分钟就能恢复
五、消费者模板代码详解
5.1 标准消费模板
记住这个模板,以后写消费者就照着来:
java
public void onMessage(Message message) {
String messageId = message.getId();
// ========== Step 1: 幂等性判断 ==========
if (!idempotentHandler.isMessageProcessed(messageId)) {
// 获取消费权失败,说明是重复消息
if (idempotentHandler.isAccomplish(messageId)) {
// 已经处理完成了,直接返回
log.info("[幂等] 消息已处理完成,忽略: {}", messageId);
return;
}
// 还没处理完(可能是消费中断),抛异常让 MQ 重试
log.warn("[幂等] 消息消费中断,等待重试: {}", messageId);
throw new RuntimeException("消息未完成流程,需要消息队列重试");
}
// ========== Step 2: 业务处理 ==========
try {
doBusinessLogic(message);
} catch (Throwable ex) {
// ========== Step 3: 异常处理 ==========
// 关键!删除幂等标识,允许重试
idempotentHandler.delMessageProcessed(messageId);
log.error("[消费异常] messageId={}", messageId, ex);
throw ex; // 重新抛出,让 MQ 知道消费失败
}
// ========== Step 4: 标记完成 ==========
idempotentHandler.setAccomplish(messageId);
log.info("[消费成功] messageId={}", messageId);
}
5.2 为什么要双重检查?
你可能会问:isMessageProcessed 返回 false 了,为什么还要再调 isAccomplish?
因为返回 false 有两种可能:
- 消息已经处理完了(Value =
"1") - 消息正在处理中,或者处理到一半中断了(Value =
"0")
这两种情况的处理方式完全不同:
- 第一种:直接忽略,不需要做任何事
- 第二种:抛异常,等 MQ 重试或者等 TTL 过期
java
if (!handler.isMessageProcessed(messageId)) {
if (handler.isAccomplish(messageId)) {
return; // 情况1:已完成,安全忽略
}
throw new RuntimeException("需要重试"); // 情况2:中断了,等重试
}
5.3 为什么要捕获 Throwable 而不是 Exception?
java
} catch (Throwable ex) { // 注意是 Throwable
idempotentHandler.delMessageProcessed(messageId);
throw ex;
}
Java 的异常体系:
Throwable
│
┌───────────┴───────────┐
│ │
Error Exception
(JVM 错误) (程序异常)
│ │
OutOfMemoryError RuntimeException
StackOverflowError ...
Exception 只能捕获程序异常,而 Error 是 JVM 级别的错误(比如内存溢出)。
在消息消费场景下,即使发生了 OutOfMemoryError,我们也希望能删除幂等标识,给下次重试一个机会。所以要用 Throwable。
六、RocketMQ 幂等性实战
原文档里 RocketMQ 的例子被注释掉了,这里补充一下完整的 RocketMQ 幂等性实现。
6.1 生产者发送消息
java
@Component
@RequiredArgsConstructor
public class OrderMessageProducer {
private final RocketMQTemplate rocketMQTemplate;
public void sendOrderMessage(OrderDTO order) {
// 生成消息唯一标识
String messageId = UUID.randomUUID().toString();
Message<OrderDTO> message = MessageBuilder
.withPayload(order)
.setHeader(RocketMQHeaders.KEYS, messageId) // 设置到 Keys
.build();
rocketMQTemplate.send("order-topic", message);
log.info("[发送消息] messageId={}, orderId={}", messageId, order.getId());
}
}
RocketMQ 里通常用消息的 Keys 属性来存放唯一标识,这个属性在消费端可以通过 MessageExt.getKeys() 获取。
6.2 消费者处理消息
java
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "order-consumer-group"
)
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {
private final MessageQueueIdempotentHandler idempotentHandler;
private final OrderService orderService;
@Override
public void onMessage(MessageExt message) {
// 获取消息唯一标识
String keys = message.getKeys();
// 幂等性判断
if (!idempotentHandler.isMessageProcessed(keys)) {
if (idempotentHandler.isAccomplish(keys)) {
log.info("[幂等] 消息已处理,忽略: {}", keys);
return;
}
throw new RuntimeException("消息未完成流程,需要消息队列重试");
}
try {
// 解析消息体
OrderDTO order = JSON.parseObject(message.getBody(), OrderDTO.class);
// 执行业务
orderService.processOrder(order);
} catch (Throwable ex) {
idempotentHandler.delMessageProcessed(keys);
log.error("[消费失败] keys={}", keys, ex);
throw ex;
}
idempotentHandler.setAccomplish(keys);
log.info("[消费成功] keys={}", keys);
}
}
6.3 RocketMQ 消息唯一标识的选择
RocketMQ 提供了几种消息标识:
| 标识 | 获取方式 | 特点 |
|---|---|---|
msgId |
message.getMsgId() |
Broker 生成,全局唯一 |
offsetMsgId |
message.getOffsetMsgId() |
Broker 存储位置 |
keys |
message.getKeys() |
业务自定义 |
uniqKey |
message.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MSG_ID_KEYIDX) |
客户端生成 |
推荐使用 keys,因为:
- 业务可控,自己生成的 ID 自己清楚
- 支持通过
keys查询消息(运维友好) - 生产者和消费者使用同一个标识,语义清晰
6.4 RocketMQ 重试机制与幂等配合
RocketMQ 消费失败会自动重试,重试策略:
第 1 次重试:10s 后
第 2 次重试:30s 后
第 3 次重试:1min 后
第 4 次重试:2min 后
...
第 16 次重试:2h 后
超过 16 次:进入死信队列
这就是为什么我们设置 2 分钟 TTL 的另一个原因------正好能覆盖前几次重试。如果第一次消费中断(没删标识的情况),2 分钟后 Key 过期,正好第 4 次重试来了,就能正常消费。
七、踩坑实录与解决方案
7.1 坑一:先标记后处理
这是我实习时踩的坑,前面说过了,再强调一遍:
java
// 错误写法!
public void onMessage(Message message) {
String id = message.getId();
redis.set(id, "1"); // 先标记
doBusinessLogic(message); // 后处理
}
问题:业务失败了,标识还在,重试被拦住。
正确做法:三态模型,成功才标记完成,失败删标识。
7.2 坑二:忘记删除标识
java
// 错误写法!
try {
doBusinessLogic(message);
} catch (Exception e) {
log.error("消费失败", e);
throw e; // 没删标识!
}
idempotentHandler.setAccomplish(messageId);
问题 :消费失败了,标识还是 "0",下次重试会被当作"消费中断",抛异常等重试。虽然最终 TTL 过期后能恢复,但白白等了 2 分钟。
正确做法 :catch 块里一定要 delMessageProcessed。
7.3 坑三:只有 Exception 没有 Throwable
java
// 不够好的写法
try {
doBusinessLogic(message);
} catch (Exception e) { // 只捕获 Exception
idempotentHandler.delMessageProcessed(messageId);
throw e;
}
问题 :如果发生了 OutOfMemoryError 等 Error,catch 块不会执行,标识不会被删除。
更稳妥的做法 :用 Throwable。
7.4 坑四:TTL 太短或太长
前面分析过了:
- 太短(30秒):业务没处理完,Key 就过期了,可能重复消费
- 太长(30分钟):宕机后等太久才能恢复
建议:根据业务处理时间设置,一般 2-5 分钟比较合适。
7.5 坑五:消息 ID 不唯一
java
// 错误示范!
public void sendMessage(Order order) {
Message message = new Message();
message.setId(order.getId()); // 用订单 ID 作为消息 ID
mqTemplate.send(message);
}
问题:如果同一个订单发了多条不同的消息(比如创建、支付、发货),它们的消息 ID 都一样,后面的消息会被当作重复消息跳过。
正确做法:消息 ID 要唯一,用 UUID 或者雪花算法。
八、常见问题
8.1 消息一直被跳过怎么办?
问题描述:消息每次来都被判定为"已消费",但业务其实没成功。
排查步骤:
- 检查是不是
setAccomplish调用太早了(应该在业务成功后调用) - 检查 catch 块里有没有
delMessageProcessed - 检查消息 ID 是不是唯一的
8.2 宕机后消息延迟处理怎么办?
问题描述:服务宕机后,消息要等 2 分钟 TTL 过期才能消费。
这是正常的,因为我们无法区分"真的在消费中"和"消费中断了"。2 分钟是可接受的延迟。
如果实在接受不了,可以:
- 缩短 TTL(但要确保业务能在 TTL 内完成)
- 服务重启时主动清理"消费中"状态的标识(比较复杂)
8.3 高并发下性能怎么样?
每次消费要访问 3-4 次 Redis:
isMessageProcessed- SETNXisAccomplish(可能)- GETsetAccomplish- SETdelMessageProcessed(失败时)- DEL
单机 Redis 10w+ QPS 没问题。如果还是瓶颈,可以考虑:
- 用 Redis Cluster 分散压力
- 用 Lua 脚本合并操作
- 加本地缓存(已完成的消息 ID 缓存在本地)
8.4 和分布式事务什么关系?
这是两个不同的问题:
- 幂等性:解决重复消费的问题
- 分布式事务:解决多个操作要么全成功要么全失败的问题
比如"扣库存 + 更新订单状态"这个场景:
- 幂等性保证:这个操作不会因为重复消费而多执行
- 分布式事务保证:扣库存和更新订单状态要么都成功,要么都失败
两者可以配合使用,但不能互相替代。
九、总结
MQ 幂等性设计,核心就是 三态模型 + 正确的状态流转:
┌───────────────────────────────────────────────────────────────┐
│ MQ 幂等性核心要点 │
├───────────────────────────────────────────────────────────────┤
│ │
│ 1. 三态设计 │
│ • 未消费 → 消费中 → 已完成 │
│ • 用 Redis Value 区分:不存在 / "0" / "1" │
│ │
│ 2. 状态流转 │
│ • 消费前:SETNX 设置为 "0" │
│ • 消费成功:改成 "1" │
│ • 消费失败:删除 Key,允许重试 │
│ • 宕机:等 TTL 过期后自动恢复 │
│ │
│ 3. 双重检查 │
│ • 重复消息分两种:已完成(忽略)/ 消费中断(等重试) │
│ │
│ 4. TTL 权衡 │
│ • 2 分钟左右,覆盖业务处理时间 + 兼顾故障恢复 │
│ │
└───────────────────────────────────────────────────────────────┘
最后再唠叨一句:幂等性不是银弹,它只解决重复消费的问题。其他问题比如消息丢失、顺序消费、分布式事务,还得用其他方案。
| 问题 | 解决方案 |
|---|---|
| 重复消费 | 幂等性(本文) |
| 消息丢失 | ACK 机制 + 持久化 |
| 顺序消费 | 同一分区 + 单消费者 |
| 分布式事务 | 事务消息 / TCC / Saga |
好了,今天就聊到这。希望这篇文章能帮你避开我踩过的坑。
热门专栏推荐
- Agent小册
- 服务器部署
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- 消息队列合集
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟