跳到主要内容

消息幂等性

问题

为什么消息队列会出现重复消费?如何保证消费的幂等性?有哪些通用的幂等方案?

答案

为什么会重复消费

消息队列通常保证 at-least-once(至少一次投递),这意味着消息可能被重复投递。

常见重复消费场景

场景说明
ACK 丢失Consumer 处理完但 ACK 网络超时,Broker 重新投递
Consumer RebalanceKafka 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 幂等 ProducerKafka 场景自动去重仅限单分区单会话

常见面试问题

Q1: 如何保证消息消费的幂等性?

答案

根据业务操作类型选择方案:

  1. 插入操作:数据库唯一约束,重复插入时 catch 异常忽略
  2. 更新操作:乐观锁(版本号) 或 状态机(只允许特定状态转换)
  3. 通用方案:唯一消息 ID + Redis/DB 去重表,消费前检查是否已处理

核心思路:让业务逻辑本身具备幂等性,而不是依赖 MQ 保证。

Q2: Redis 去重和数据库去重哪个好?

答案

对比Redis 去重数据库去重
性能高(内存操作)较低(磁盘 IO)
一致性弱(Redis 宕机可能丢标记)强(事务保证)
适用场景高并发、可容忍小概率重复强一致性要求

最佳实践:Redis 快速去重 + 数据库唯一约束兜底,两层防护。

Q3: 为什么不让 MQ 保证 Exactly-Once?

答案

在分布式系统中,Exactly-Once 需要 Producer、Broker、Consumer 三端协同事务,代价极高:

  1. 性能开销:事务协调、两阶段提交大幅降低吞吐量
  2. 外部系统无法覆盖:即使 MQ 内部做到 Exactly-Once,消费端写数据库仍可能失败重试
  3. 复杂度:分布式事务本身就是难题

因此业界共识是:MQ 保证 at-least-once,消费端自行保证幂等

Q4: 消息去重的 ID 怎么生成?

答案

  • 业务唯一键(推荐):如订单号、支付流水号,天然唯一且有业务含义
  • 消息 ID:让 Producer 生成 UUID 或雪花 ID 作为 messageId
  • Kafka key:使用 Kafka 消息的 key 字段

不推荐使用 Broker 生成的 msgId(RocketMQ 的 MessageExt.getMsgId()),因为重试时 msgId 可能变化。

相关链接