消息幂等性
问题
为什么消息队列会出现重复消费?如何保证消费的幂等性?有哪些通用的幂等方案?
答案
为什么会重复消费
消息队列通常保证 at-least-once(至少一次投递),这意味着消息可能被重复投递。
常见重复消费场景:
| 场景 | 说明 |
|---|---|
| ACK 丢失 | Consumer 处理完但 ACK 网络超时,Broker 重新投递 |
| Consumer Rebalance | Kafka Rebalance 期间 offset 未提交,新 Consumer 重新消费 |
| Producer 重试 | Producer 发送成功但未收到确认,重试导致 Broker 收到两条相同消息 |
| Broker 故障恢复 | 主从切换时可能导致消息重复投递 |
核心结论
MQ 不保证恰好一次消费(Exactly-Once),消费端必须自己实现幂等。
幂等性定义:同一操作执行一次和执行多次的效果一样。
幂等方案
方案一:唯一消息 ID + 去重表
最通用的方案,适用于所有 MQ。
唯一 ID 去重
// Producer 端:为每条消息生成唯一 ID
Message msg = new Message("topic", body);
msg.setKeys(UUID.randomUUID().toString()); // RocketMQ
// 或
producer.send(new ProducerRecord<>("topic", key, value)); // Kafka 用 key
// Consumer 端:消费前查重
public void consumeMessage(String msgId, String body) {
// 利用 Redis SETNX 判断是否已消费
Boolean isNew = redis.opsForValue()
.setIfAbsent("msg:consumed:" + msgId, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(isNew)) {
log.info("消息已消费,跳过: {}", msgId);
return; // 去重
}
try {
processMessage(body);
} catch (Exception e) {
redis.delete("msg:consumed:" + msgId); // 处理失败,删除标记允许重试
throw e;
}
}
方案二:数据库唯一约束
利用业务唯一键(如订单号)防止重复插入。
数据库唯一约束
// 订单表的 order_no 字段设为唯一索引
// CREATE UNIQUE INDEX uk_order_no ON orders(order_no);
public void createOrder(OrderMessage msg) {
try {
orderMapper.insert(msg.toOrder());
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明已处理过,直接忽略
log.info("订单已存在,跳过: {}", msg.getOrderNo());
}
}
方案三:乐观锁 / 版本号
适用于更新操作,通过版本号或条件判断防止重复更新。
乐观锁防重复
// 更新时带条件:只有当前状态为"待支付"才能更新为"已支付"
// UPDATE orders SET status = 'PAID', version = version + 1
// WHERE order_no = ? AND status = 'UNPAID' AND version = ?
public boolean payOrder(String orderNo, int version) {
int rows = orderMapper.updateStatus(orderNo, "PAID", version);
return rows > 0; // rows=0 说明已被更新过
}
方案四:Kafka 幂等 Producer
Kafka 0.11+ 支持 Producer 幂等,自动去重(基于 PID + Sequence Number):
Kafka 幂等 Producer
props.put("enable.idempotence", true);
// Broker 会自动丢弃同一 Producer 的重复消息
// 仅保证单分区内、单会话的幂等(Producer 重启后 PID 变化)
Kafka Exactly-Once 语义
Kafka 还支持事务(Transactional Producer + Consumer),实现跨分区的 Exactly-Once:
producer.initTransactions();
producer.beginTransaction();
producer.send(record1);
producer.send(record2);
producer.sendOffsetsToTransaction(offsets, consumerGroupId); // 提交消费 offset
producer.commitTransaction();
但这仅限于 Kafka 系统内部(Producer → Kafka → Consumer),不覆盖外部系统(如数据库)。
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 唯一 ID + Redis 去重 | 通用 | 简单高效 | Redis 可用性依赖、过期策略 |
| 数据库唯一约束 | 插入操作 | 强一致、无额外组件 | 仅适用于插入 |
| 乐观锁 / 版本号 | 更新操作 | 无锁、并发友好 | 需要版本号字段 |
| 状态机 | 有状态流转的业务 | 天然幂等 | 需要明确的状态定义 |
| Kafka 幂等 Producer | Kafka 场景 | 自动去重 | 仅限单分区单会话 |
常见面试问题
Q1: 如何保证消息消费的幂等性?
答案:
根据业务操作类型选择方案:
- 插入操作:数据库唯一约束,重复插入时 catch 异常忽略
- 更新操作:乐观锁(版本号) 或 状态机(只允许特定状态转换)
- 通用方案:唯一消息 ID + Redis/DB 去重表,消费前检查是否已处理
核心思路:让业务逻辑本身具备幂等性,而不是依赖 MQ 保证。
Q2: Redis 去重和数据库去重哪个好?
答案:
| 对比 | Redis 去重 | 数据库去重 |
|---|---|---|
| 性能 | 高(内存操作) | 较低(磁盘 IO) |
| 一致性 | 弱(Redis 宕机可能丢标记) | 强(事务保证) |
| 适用场景 | 高并发、可容忍小概率重复 | 强一致性要求 |
最佳实践:Redis 快速去重 + 数据库唯一约束兜底,两层防护。
Q3: 为什么不让 MQ 保证 Exactly-Once?
答案:
在分布式系统中,Exactly-Once 需要 Producer、Broker、Consumer 三端协同事务,代价极高:
- 性能开销:事务协调、两阶段提交大幅降低吞吐量
- 外部系统无法覆盖:即使 MQ 内部做到 Exactly-Once,消费端写数据库仍可能失败重试
- 复杂度:分布式事务本身就是难题
因此业界共识是:MQ 保证 at-least-once,消费端自行保证幂等。
Q4: 消息去重的 ID 怎么生成?
答案:
- 业务唯一键(推荐):如订单号、支付流水号,天然唯一且有业务含义
- 消息 ID:让 Producer 生成 UUID 或雪花 ID 作为 messageId
- Kafka key:使用 Kafka 消息的 key 字段
不推荐使用 Broker 生成的 msgId(RocketMQ 的 MessageExt.getMsgId()),因为重试时 msgId 可能变化。