05 - 消息幂等性与重复消费
面试高频度:⭐⭐⭐⭐ 考查方式:方案设计题、追问生产端/消费端可靠性时的延伸题
一、为什么会重复消费?——这是机制问题,不是 Bug
1.1 核心原因:RabbitMQ 只保证 At-Least-Once
RabbitMQ 的 ACK 机制有一个根本特性:消息在被 ACK 之前,始终保留在 Queue 中。这保证了消息不会丢(消费者崩溃后可以重新投递),但代价是同一条消息可能被投递多次。
这就是 At-Least-Once(至少一次投递)语义——宁可重复投递,也不丢消息。
1.2 三种重复消费的场景
场景一:消费者 ACK 前崩溃(最常见)
Consumer A ──▶ 收到消息 ──▶ 处理业务(成功)──▶ 准备 ACK ──▶ 💥 崩溃
│
Broker 未收到 ACK ──▶ 消息仍在 Queue 中 ──▶ 重新投递给 Consumer B ──▶ 重复消费这条消息被 Consumer A 成功处理了一次(比如已经扣了库存),但 Consumer A 在 ACK 前崩溃了。Broker 不知道 Consumer A 已经处理过了,所以把消息重新投递给了 Consumer B,导致重复消费。
场景二:生产端重试导致重复发送
Producer ──▶ 发送消息 ──▶ Broker 收到消息并存入 Queue
Broker 返回 ACK ──▶ 网络延迟/丢失 ──▶ Producer 没收到 ACK
Producer 没收到 ACK ──▶ 认为发送失败 ──▶ 重发同一条消息 ──▶ Broker 收到两条相同消息这里 Broker 和 Producer 对"是否发送成功"的认知不一致。Producer 认为失败了所以重发,Broker 认为成功了所以两条都存了。
场景三:消费者处理慢,触发 Broker 重新投递
某些场景下(如 Consumer 的 prefetch 消息长时间未 ACK),Broker 可能认为 Consumer 已死,将消息重新投递给其他 Consumer。但原 Consumer 可能还在处理——结果两个 Consumer 都处理了同一条消息。
1.3 为什么 RabbitMQ 不在协议层解决重复问题?
因为"是否重复"是一个业务语义,不是技术语义。同样的消息发两次,在日志场景中可能无所谓(重复写一条日志),但在扣款场景中就是灾难。RabbitMQ 无法判断哪些重复是可接受的,所以把这个决策权交给了业务层。
二、什么是幂等性?
2.1 定义
幂等性:同一个操作执行一次和执行多次的效果完全相同。
f(x) = f(f(x)) = f(f(f(x)))
通俗理解:做一次和做一百次,结果一样。
2.2 哪些操作天然幂等?哪些不幂等?
| 操作 | 天然幂等? | 原因 |
|---|---|---|
SELECT | ✅ | 查多少次结果都一样 |
UPDATE SET age=18 | ✅ | 设置固定值,多次执行结果相同 |
UPDATE SET age=age+1 | ❌ | 每次执行 age 都 +1,执行两次就多加了一次 |
INSERT(无唯一约束) | ❌ | 多次插入产生多条记录 |
DELETE | ✅ | 删除一次和多次效果一样(删第二次时记录已不存在) |
核心判断标准:操作是否有副作用随执行次数变化。如果有(如累加、插入),就不幂等;如果没有(如设置固定值、删除),就天然幂等。
三、幂等方案详解
3.1 方案一:数据库唯一约束(最简单、最可靠)
原理:给消息 ID 建立唯一索引,利用数据库的唯一约束来保证同一条消息只被插入一次。第二次插入时数据库会抛出 DuplicateKeyException,捕获后跳过即可。
优势:
- 实现最简单——只需要一张去重表 + 一个唯一索引
- 最可靠——数据库的唯一约束是最强的一致性保障,即使 Redis 宕机也不会重复
劣势:
- 每次消费都要写数据库,高并发下数据库可能成为瓶颈
- 去重表会持续增长,需要定期清理或设置 TTL
适用场景:数据量不大、并发不高的场景,或者作为兜底层配合其他方案使用。
3.2 方案二:Redis 去重(高并发推荐)
原理:利用 Redis 的 SETNX(Set If Not eXist)命令——只有 Key 不存在时才能设置成功。第一次消费时设置成功(返回 true),第二次消费时 Key 已存在,设置失败(返回 false),说明是重复消息。
为什么 Redis 比数据库快? 因为 SETNX 是一个 O(1) 的内存操作,不涉及磁盘 I/O。在高并发场景下,Redis 的吞吐量是数据库的 10-100 倍。
关键注意事项:
| 问题 | 原因 | 解决 |
|---|---|---|
| 过期时间怎么设? | 消息去重 Key 必须在消息的"生命周期"内一直存在,否则过期后重复消息会被当成新消息 | 过期时间 > 消息的最大重试周期(如消息最多重试 3 天,Key 过期设 7 天) |
| 消费失败时要不要删 Key? | 如果消费失败后保留去重 Key,重试时会被认为是"重复消息"而跳过——永远无法重试成功 | 消费失败时删除去重 Key,允许下次重试 |
| Redis 宕机怎么办? | Redis 主从同步有延迟,主节点写入的 Key 可能没同步到从节点。主节点宕机后从节点晋升为新主,去重 Key 丢失 | 需要结合数据库唯一约束做兜底 |
3.3 方案三:状态机(订单类业务首选)
原理:业务状态只能按照预定义的合法路径转移。重复消费时,由于状态已经变更,条件不再满足,操作会被跳过。
核心 SQL:
UPDATE orders SET status = 'PAID' WHERE order_id = ? AND status = 'CREATED';第一次执行:status='CREATED' 匹配,更新成功,affected rows = 1 第二次执行:status='PAID'(已更新),WHERE 条件不匹配,affected rows = 0
为什么状态机适合订单场景? 因为订单本身就有天然的状态流转(CREATED → PAID → SHIPPED → COMPLETED),每一步都是一个"只能前进不能后退"的状态机。幂等性是状态机的自然属性,不需要额外的去重机制。
3.4 方案四:乐观锁(版本号)
原理:给每条记录维护一个 version 字段,更新时检查版本号是否匹配。如果版本号不匹配(说明已被其他消息更新过),则跳过。
UPDATE orders SET data = ?, version = version + 1
WHERE order_id = ? AND version = ?;乐观锁和状态机的本质区别:状态机用业务状态做条件(适合状态流转场景),乐观锁用版本号做条件(适合任何更新场景)。当业务没有明显的"状态"概念时,乐观锁更通用。
四、方案对比与组合使用
4.1 单一方案的局限性
| 方案 | 局限 |
|---|---|
| 数据库唯一约束 | 高并发下性能差(每次消费都要写 DB) |
| Redis 去重 | 有主从同步风险,不能做 100% 的保障 |
| 状态机 | 只适用于有状态流转的业务 |
| 乐观锁 | 只适用于更新操作,不适用于插入操作 |
4.2 生产推荐:三层防护
┌─────────────────────────────────────────────────────┐
│ 消费端幂等三层防护 │
│ │
│ 第一层:Redis 快速去重(挡掉 99.9% 的重复消息) │
│ ↓ 非重复 │
│ 第二层:状态机校验(业务合法性检查) │
│ ↓ 合法状态转移 │
│ 第三层:数据库唯一约束(终极兜底) │
│ │
│ 任意一层判断为重复 → 直接 ACK,不做业务处理 │
└─────────────────────────────────────────────────────┘为什么要三层而不是一层?
- Redis 做第一层:速度最快,能挡掉绝大部分重复消息,保护数据库不被打爆
- 状态机做第二层:即使 Redis 漏过了(主从同步延迟),状态机可以拦截——因为重复消费时状态已经不是初始状态了
- DB 唯一约束做第三层:即使前两层都漏过了(极端情况),数据库的唯一约束是最强的保障
五、消息 ID 怎么设计?
去重的前提是每条消息有唯一标识。消息 ID 的设计直接影响去重的效果:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 业务唯一键 | 如 orderId + "_pay" | 推荐——天然唯一,与业务绑定,即使消息被重复发送 ID 也相同 |
| 雪花算法 | 分布式 ID 生成器 | 需要全局唯一但无业务键时 |
| UUID | 随机生成 | 简单但无意义,调试困难 |
为什么推荐业务唯一键? 因为它不仅能去重,还能和业务逻辑关联——比如 order_12345_pay 明确表示"订单 12345 的支付消息"。而 UUID 只是一串无意义的字符,出问题时难以排查。
六、面试常见追问
Q1:RabbitMQ 能保证消息只消费一次吗?
不能。 RabbitMQ 保证 At-Least-Once(至少一次投递),不保证 Exactly-Once。Exactly-Once 需要在业务层实现幂等来"模拟"——虽然消息可能被投递多次,但幂等保证了多次执行和一次执行效果相同。
Q2:消息被 ACK 后消费者处理失败怎么办?
ACK 后消息已从 Queue 中移除,无法重新投递。所以正确顺序是:先处理业务,后 ACK。如果业务处理失败,NACK + requeue 重新入队(或发到死信队列)。
Q3:如何设计一个通用的幂等框架?
核心思路:自定义注解(如 @Idempotent)+ AOP 切面拦截消费方法。切面中实现去重逻辑(Redis / 数据库可插拔),业务代码不需要关心幂等细节。
七、关联笔记
- **消息可靠性保障** - ACK 机制(重复消费的根源)
- **死信队列与延迟队列** - 重试 N 次后的兜底方案
- **消息积压与消费能力** - 消费端性能优化