一、为什么要关注幂等性?
在分布式系统中,消息的重复投递是常态而非异常。导致消息重复的常见原因包括:
-
🔄 页面卡顿:用户频繁刷新导致表单重复提交
-
🔁 服务重试:服务间调用超时后的自动重试机制
-
📨 MQ重复投递:生产者未收到确认而重新发送消息
当业务被重复执行时,可能引发严重的数据错误。来看一个真实场景:
💥 重复消息引发的业务故障
时间线:
① 用户支付成功 → MQ投递消息 → 交易服务更新订单为【已支付】
② 网络故障,生产者未收到ACK → 间隔后重新投递同一条消息
③ 在重投消息消费前,用户申请退款 → 订单状态改为【已退款】
④ 重投消息被消费 → 订单状态又被改为【已支付】❌ 业务严重异常!
二、什么是幂等性?
幂等性:同一个操作执行一次或多次,对系统状态产生的影响完全相同。
数学表达:f(x) = f(f(x)),例如绝对值函数。
✅ 幂等操作示例
-
根据ID删除数据(重复删除无影响)
-
查询数据(只读不写)
-
新增数据(若业务上允许重复插入,或带有唯一约束)
❌ 非幂等操作示例
-
取消订单并恢复库存:重复执行会导致库存重复增加
-
退款业务:重复退款会造成直接经济损失
-
订单状态更新:不加判断的更新可能覆盖正确状态
三、方案一:唯一消息ID(去重表)
核心思路
每条消息生成唯一ID → 随消息投递 → 消费时查询去重表
已存在 → 丢弃 | 不存在 → 执行业务 + 记录ID
实现方式
SpringAMQP 的 MessageConverter 内置了 MessageID 功能:
java
@Bean
public MessageConverter messageConverter(){
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
// 开启自动创建消息ID
converter.setCreateMessageIds(true);
return converter;
}
消费者端处理逻辑:
java
@RabbitListener(queues = "order.pay.success.queue")
public void handleMessage(Message message) {
String messageId = message.getMessageProperties().getMessageId();
// 检查去重表
if (messageIdExists(messageId)) {
log.info("重复消息,已丢弃,messageId: {}", messageId);
return;
}
// 执行业务
orderService.markOrderPaySuccess(orderId);
// 记录消息ID
saveMessageId(messageId);
}
📊 方案评价
| 优点 | 缺点 |
|---|---|
| 通用性强,适用于任何场景 | 需要额外维护去重表 |
| 实现逻辑清晰简单 | 增加数据库存储和查询开销 |
| 可独立于业务逻辑设计 | 需要处理ID生成和传输的可靠性 |
四、方案二:业务状态判断(⭐推荐)
核心思路
利用业务本身的状态字段判断是否已处理,将判断和更新合并为一条原子操作,避免并发安全问题。
场景分析
当前业务:将订单从 未支付(status=1) 更新为 已支付(status=2)
我们只需在更新时增加状态条件即可:
java
@Override
public void markOrderPaySuccess(Long orderId) {
// 相当于 SQL:UPDATE `order` SET status=2, pay_time=NOW()
// WHERE id=? AND status=1
lambdaUpdate()
.set(Order::getStatus, 2)
.set(Order::getPayTime, LocalDateTime.now())
.eq(Order::getId, orderId)
.eq(Order::getStatus, 1) // 🔑 关键:只有未支付才更新
.update();
}
🔒 为什么更推荐?
-
无需额外存储:不引入去重表,减少维护成本
-
原子操作:WHERE 条件判断与 UPDATE 在同一事务中完成,无并发风险
-
零侵入:基于业务状态自然实现,代码改动小
-
性能优越:省去去重表的查询和写入开销
⚠️ 适用前提
业务本身需要有明确的状态流转约束,且状态机设计合理。
五、兜底方案:定时任务主动查询
即使我们做足了消息可靠性保障(生产者确认、消费者ACK、重试机制等),MQ 本身仍有极端故障可能(Broker宕机、网络长时间中断)。此时交易服务收不到支付通知,订单状态将永远停留在"未支付"。
解决方案
交易服务主动查询支付状态,作为 MQ 通知的兜底。
流程设计

代码实现示例
java
@Component
@Slf4j
public class PayStatusSyncTask {
@Autowired
private OrderService orderService;
@Autowired
private PayClient payClient;
/**
* 每20秒执行一次,扫描待支付订单并同步状态
*/
@Scheduled(fixedDelay = 20000)
public void syncPayStatus() {
// 1. 查询状态为"未支付"且创建时间超过一定阈值的订单(避免查询刚创建的订单)
List<Order> pendingOrders = orderService.findPendingOrdersNeedSync();
for (Order order : pendingOrders) {
try {
// 2. 调用支付服务查询真实支付状态
PayStatusResponse response = payClient.queryPayStatus(order.getId());
// 3. 如果已支付,更新订单状态(同样使用幂等更新)
if (response.getStatus() == PayStatus.SUCCESS) {
orderService.markOrderPaySuccess(order.getId());
log.info("定时任务同步订单支付状态成功,orderId: {}", order.getId());
}
} catch (Exception e) {
log.error("同步订单支付状态失败,orderId: {}", order.getId(), e);
}
}
}
}
🔍 关键设计要点
| 设计要点 | 说明 |
|---|---|
| 扫描频率 | 不宜过高(避免压垮支付服务),建议20~30秒 |
| 扫描范围 | 只扫描"未支付"状态订单,增加时间阈值过滤 |
| 分页处理 | 避免一次性加载大量数据,建议每次处理 100~200 条 |
| 幂等更新 | 定时任务中的更新同样使用 status=1 条件保证幂等 |
| 异常处理 | 单条失败不影响后续订单,记录日志便于排查 |
六、整体方案总结
方案对比
| 方案 | 作用 | 推荐度 |
|---|---|---|
| 唯一消息ID | 通用去重 | ⭐⭐⭐ |
| 业务状态判断 | 幂等更新(原子性保障) | ⭐⭐⭐⭐⭐ |
| 定时任务兜底 | 最终一致性保障 | ⭐⭐⭐⭐⭐ |
核心结论
消息可靠 + 消费幂等 + 定时兜底 = 最终一致性
这套方案在不引入分布式事务的前提下,通过合理的消息机制和业务设计,达到了高可用的最终一致性效果,是微服务场景下性价比极高的实践方案。