这篇把"幂等"从概念拉回工程现实:重复消息不可避免,但结果可以可控。
先说结论
消费端幂等不是一个技巧,而是三件事组合:
- 唯一标识 :每条消息都有可追踪的
messageId - 去重存储:Redis 或数据库记录"是否处理过"
- 业务可重放:重复消费不会产生副作用
一、为什么会有重复消息
你只要开启了可靠投递和手动 ACK,就一定会遇到重复消息。常见场景:
- 消费成功,但 ACK 失败
- 消费失败后重试
- 生产者超时重发
这不是异常,是系统设计的正常行为。
二、3 种幂等实现方式(按成本从低到高)
1) Redis 去重(推荐起步)
优点:简单、高性能
缺点:Redis 不可用时要兜底
java
public void onMessage(OrderMessage msg) {
String dedupKey = "mq:dedup:" + msg.getMessageId();
// SETNX + TTL
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
// 重复消息,直接 ACK
return;
}
// 业务处理
process(msg);
}
2) 数据库唯一索引(可靠优先)
优点:强一致性
缺点:写入成本更高
sql
CREATE TABLE mq_consume_log (
message_id VARCHAR(64) PRIMARY KEY,
status TINYINT,
create_time DATETIME
);
java
public void onMessage(OrderMessage msg) {
try {
// 先插入去重表
consumeLogMapper.insert(msg.getMessageId());
} catch (DuplicateKeyException e) {
// 重复消息
return;
}
process(msg);
}
3) 业务幂等(最终形态)
比如:支付成功后只更新一次状态,重复消息也只是"幂等更新"。
java
public void process(OrderMessage msg) {
// 只更新一次
orderMapper.updateStatusIfNotPaid(msg.getOrderId());
}
三、常见业务场景怎么做
场景 1:扣库存
不要直接 stock - 1,而是做"去重后扣减":
java
public void reduceStock(String orderNo) {
if (dedup(orderNo)) {
stockMapper.decrease(orderNo);
}
}
场景 2:发优惠券
用"唯一索引"锁住发放记录:
sql
ALTER TABLE user_coupon
ADD UNIQUE KEY uk_user_coupon(user_id, coupon_id);
重复发放自然失败,不影响业务。
场景 3:更新订单状态
只允许状态单向流转:
sql
UPDATE orders
SET status = 'PAID'
WHERE id = ? AND status = 'UNPAID';
重复消费只会影响 0 行。
四、一个"够用"的组合方案
如果你不想纠结,直接用这个:
- messageId 必须有
- Redis 去重
- 数据库状态更新做幂等条件
这是我在实际项目里用的最平衡方案,成本低、效果稳。
五、常见坑
-
去重 key 没有 TTL
Redis 记录会无限增长,记得设置过期时间。
-
messageId 不唯一
不要用订单号当 messageId,最好是 UUID 或雪花 ID。
-
只靠 Redis 去重
Redis 挂了就会穿透,建议加业务幂等作为第二道保险。
最后总结
幂等不是让系统"完全不重复",而是让重复"变得无害"。
你只要把这三件事做好:
- 消息有唯一标识
- 去重有记录
- 业务可重放
重复消息就不再是线上事故,而是可控事件。