MQ 消息重复消费与消息丢失
一、业务背景
电商系统使用 Spring Boot、MySQL 和 MQ 实现订单后的异步业务处理。
用户下单成功后,系统需要:
- 扣减商品库存。
- 更新用户积分。
- 发送订单通知。
运行过程中出现以下问题:
- 部分订单库存被重复扣减。
- 部分订单已经创建成功,但积分一直没有更新。
- 消费者处理消息时宕机,重启后部分业务没有完成。
- MQ 显示消息发送成功,但业务数据仍然不一致。
- MQ 不可用时,订单已经提交,但积分事件没有发送出去。
- 积分已经更新,但消费记录未成功写入,导致重复投递后可能重复加分。
- 消费者逻辑存在 Bug,导致积分计算错误,但 MQ 认为消息已经成功处理。
核心目标:
确保消息可靠投递、业务处理可恢复、重复消息不会重复产生业务效果,并能够发现和修复最终的数据不一致。
二、问题一:为什么 MQ 会重复消费?
1. 典型场景
消费者收到消息 ↓ 执行库存扣减 ↓ 数据库扣减成功 ↓ 消费者还没 ACK 就宕机 ↓ MQ 重新投递消息 ↓ 库存再次扣减原因是业务处理成功与消息确认之间存在时间窗口。
MQ 无法仅凭消费者没有 ACK,就判断消费者之前是否已经成功执行了业务。
因此,重复投递是分布式消息系统需要处理的正常情况。
2. 解决方案:消费幂等
每条业务消息都应该有唯一的业务事件标识,例如:
event_id = EVT_10001消费端使用event_id判断该业务事件是否已经成功处理。
对于同一个事件,即使收到多次,也只能让业务效果生效一次。
需要注意:
不能只在应用层先查询记录是否存在,再决定是否执行,因为并发请求可能同时查询到记录不存在。
应使用数据库唯一约束等可靠机制处理并发竞争。
三、问题二:如何实现可靠的消费幂等?
假设积分服务收到一条增加 100 分的消息。
如果没有幂等机制,同一条消息被处理两次,用户就可能获得 200 分。
1. 建立消费记录表
CREATETABLEprocessed_events(event_idVARCHAR(64)PRIMARYKEY,processed_atDATETIMENOTNULL);event_id的主键约束用于防止同一事件重复登记。
2. 将消费记录与业务更新放进同一个数据库事务
推荐流程:
收到积分消息 ↓ 开启数据库事务 ↓ 登记 event_id ↓ 更新用户积分 ↓ 提交事务 ↓ ACK 消息如果数据库事务提交前发生异常:
- 消费记录回滚。
- 积分更新回滚。
- 消息可以重试。
如果数据库事务已经提交,但 ACK 失败:
- 积分已经更新。
- 消费记录已经存在。
- 消息再次投递时,可以识别重复事件,不再更新积分。
如果登记event_id时发生唯一键冲突,应查询已有记录并确认其状态,避免把其他数据库错误误判成重复消费。
前提:消费记录和积分更新处于同一个支持事务的数据库事务中。
如果业务涉及多个数据库或多个外部服务,就需要额外设计可靠的跨系统处理机制。
四、问题三:MQ 发送成功,为什么业务仍然可能失败?
MQ 发送成功不等于消费者已经成功执行业务。
典型流程:
订单创建成功 ↓ 积分事件发送成功 ↓ 消费者收到消息 ↓ 积分更新失败 ↓ 重试处理可能出现以下问题:
- 消费者未收到消息。
- 消费者处理失败。
- 消费者处理成功但 ACK 失败。
- 消费者已经确认消息,但业务逻辑执行错误。
- 消息进入死信队列,却没有后续处理。
因此,需要区分消息在不同阶段的状态。
| 阶段 | 需要关注的问题 |
|---|---|
| 业务提交 | 订单是否成功写入数据库 |
| 消息发送 | 消息是否可靠地发送并得到相应确认 |
| 消息存储 | 消息是否满足持久化要求 |
| 消息投递 | 消费者是否收到消息 |
| 业务执行 | 消费者是否成功完成业务 |
| 消息确认 | 是否在业务成功后正确 ACK |
| 最终一致性 | 相关业务数据是否最终达到预期状态 |
具体确认机制取决于所使用的 MQ 产品及其配置。
五、问题四:如何防止订单成功,但积分消息永久丢失?
1. 问题场景
订单写入 MySQL 成功 ↓ 准备发送积分消息 ↓ MQ 暂时不可用 ↓ 消息发送失败 ↓ 订单服务随后宕机如果系统没有持久化记录这条待发送的积分事件,服务恢复后就可能不知道哪些订单还需要处理积分。
单纯依赖内存重试无法解决这个问题,因为进程宕机后,内存中的任务可能消失。
2. 使用 Transactional Outbox
Transactional Outbox 是一种常见的可靠消息投递方案。
在同一个数据库事务中完成:
- 创建订单。
- 写入待发送的积分事件。
例如:
开启数据库事务 ↓ 写入订单 ↓ 写入 Outbox 事件 ↓ 提交事务这样,订单和待发送事件要么一起提交,要么一起回滚。
后台任务定期扫描尚未发送的事件:
扫描待发送事件 ↓ 发送到 MQ ↓ 收到发送成功确认 ↓ 标记事件已发送如果 MQ 暂时不可用,事件仍保存在数据库中,后续可以重试。
如果服务在发送成功、更新事件状态之前宕机,恢复后可能再次发送同一事件。
因此,Outbox 通常提供的是可靠的至少一次发送能力,而不是天然的恰好一次业务执行。
消费者仍然必须具备幂等能力。
六、问题五:MQ 不可用时,能否直接调用积分服务?
直接调用积分服务可以作为降级方案,但不能作为可靠消息投递的唯一保障。
1. 直接调用的风险
订单创建成功 ↓ MQ 发送失败 ↓ 直接调用积分服务 ↓ 积分服务也不可用此时,如果没有持久化的待处理任务,积分业务仍然可能永久丢失。
此外,即使积分服务已经成功更新积分,响应也可能在返回过程中丢失,导致订单服务重试并重复加分。
2. 如何定位直接调用?
| 方案 | 主要作用 |
|---|---|
| 直接调用积分服务 | 尝试立即完成业务 |
| 超时与重试 | 恢复暂时性调用失败 |
| 接口幂等 | 防止重试产生重复业务效果 |
| Outbox | 持久化待处理事件,保证后续可恢复 |
| 对账 | 发现并修复最终不一致 |
如果选择直接调用作为降级策略,也应该记录处理状态,并确保失败任务能够恢复。
降级策略不能替代可靠的任务持久化。
七、问题六:消费者宕机后,如何处理失败消息?
消费者宕机后,消息不一定直接进入死信队列。
具体行为取决于 MQ 产品、消息确认方式、重试策略和相关配置。
一般需要设计以下机制。
1. 消费者恢复与消息重投
如果消息没有被正确确认,MQ 可以根据其机制重新投递。
消费者恢复后,应能安全地重新执行业务。
2. 有限次数重试
对于网络超时、临时数据库故障等问题,可以进行有限次数重试。
需要注意:
- 避免无限快速重试。
- 设置合理的重试间隔。
- 对持续失败进行告警。
- 避免重试流量进一步压垮下游服务。
3. 死信队列
对于超过正常重试范围的失败消息,可以根据 MQ 产品能力,将其转入死信队列。
死信消息需要:
- 持续监控。
- 记录失败原因。
- 判断是否可以安全重新处理。
- 修复问题后进行受控重放。
- 对无法自动处理的情况进行人工介入。
死信队列负责保存失败消息,不等于系统已经自动恢复业务。
重新投递死信消息前,也必须确认消费者幂等能力和业务状态,防止重复执行已成功的操作。
八、问题七:如何设计积分流水和对账系统?
即使消息可靠发送、消费者具备幂等能力,也不能保证业务逻辑永远正确。
例如:
- 订单应增加 100 分。
- 代码 Bug 导致积分增加了 200 分。
- 消费记录成功提交。
- MQ 不再重投这条消息。
此时,单靠消息重试无法修复错误,因为消息已经被正常处理并确认。
1. 建立积分业务流水
每次积分变化都应有可追踪的业务记录。
例如:
| 字段 | 示例 |
|---|---|
| event_id | EVT_10001 |
| user_id | 1001 |
| business_type | ORDER_REWARD |
| business_id | ORDER_888 |
| expected_points | +100 |
| actual_points | +200 |
实际设计中,可以记录积分变动值、变动原因、关联业务标识和创建时间。
应尽量保留原始业务记录,不要为了修正错误直接删除或篡改历史流水。
2. 对账的基本流程
获取订单等业务事实 ↓ 重新计算应得积分 ↓ 核对积分业务流水 ↓ 发现异常 ↓ 生成修复任务 ↓ 执行幂等修复 ↓ 再次核验对账的核心不是简单地比较两个数字,而是依据业务事实重新计算预期结果,并定位具体错误。
3. 通过修正流水恢复正确状态
假设订单 A 应增加 100 分,但实际增加了 200 分。
对账确认差额为 100 分后,可以生成一条独立的修正流水:
原始积分流水:+200 修正积分流水:-100 最终净变动:+100这样既能修复余额,又能保留审计记录。
4. 修复操作也必须幂等
假设第一次修复已经成功,但对账服务在记录修复结果前宕机。
服务恢复后,如果再次执行同一修复任务,就可能重复扣除 100 分。
因此,修复任务也需要唯一的任务标识和可靠的执行状态。
推荐做法:
- 为每个修复任务生成唯一标识。
- 使用数据库唯一约束防止重复登记。
- 在事务中完成修复记录与积分更新。
- 对修复任务的状态进行持久化跟踪。
- 修复后再次对账,确认业务状态已经正确。
对账负责发现问题,业务流水负责定位问题,幂等修复负责安全纠正问题。
九、完整的可靠消息处理架构
综合上述机制,可以设计以下处理链路:
用户下单 ↓ 开启数据库事务 ↓ 创建订单 ↓ 写入 Outbox 事件 ↓ 提交事务 ↓ Outbox 后台发送任务 ↓ 发送到 MQ ↓ 积分消费者 ↓ 开启数据库事务 ↓ 登记唯一 event_id ↓ 更新用户积分 ↓ 提交事务 ↓ ACK 消息 ↓ 定时对账与异常修复如果中间出现异常:
- 发送失败:Outbox 任务重试。
- 消息重复:消费幂等阻止重复生效。
- 业务执行失败:根据失败类型重试或进入死信处理流程。
- 消费者宕机:根据消息确认机制重新投递。
- 业务逻辑错误:对账发现并生成修复任务。
- 修复任务重复执行:通过修复幂等保证不会再次产生错误。
注意:该架构是一个逻辑示意。真实系统还需要明确事件状态、并发扫描控制、重试上限、事务边界、消息确认机制和监控告警。
十、架构评审中的常见追问
1. MQ 能保证消息只被消费一次吗?
不能简单地依赖 MQ 保证业务只执行一次。
即使消息发送和投递机制可靠,业务执行成功与 ACK 之间仍可能出现故障,导致重复投递。
因此,需要消费端幂等。
2. ACK 成功是否代表业务一定正确?
不一定。
ACK 通常代表消费者按照约定确认了消息处理结果,但无法保证业务代码没有计算错误。
需要业务校验、流水和对账来发现此类问题。
3. 为什么 Outbox 仍然需要消费幂等?
因为消息发送成功后,Outbox 事件可能还没来得及更新状态,服务就宕机了。
恢复后再次发送会产生重复消息。
消费者必须能识别重复事件,避免重复执行业务。
4. 为什么不能只靠重试?
持续性故障、业务逻辑错误和永久性异常可能不会通过重试解决。
无限重试还可能造成资源耗尽或下游服务过载。
因此,需要重试上限、死信处理、告警和人工介入。
5. 为什么不能只靠对账?
对账属于发现和修复异常的机制,通常不能替代实时业务流程中的可靠性设计。
如果可以通过事务、唯一约束和幂等避免的问题,就应该优先在正常处理链路中解决。
6. 为什么消费记录和积分更新要放在同一个事务里?
如果两者分开提交,就可能出现积分已更新但消费记录不存在的情况。
消息重投后,系统可能再次增加积分。
同一个数据库事务可以保证这两项操作一起提交或一起回滚。
7. 如果订单已经提交,但 MQ 不可用,怎么办?
可以使用 Transactional Outbox,在创建订单的同一个数据库事务中保存待发送事件。
即使 MQ 暂时不可用,事件仍然持久化在数据库中,待 MQ 恢复后再发送。
8. 如果消费幂等已经做好,为什么还需要对账?
幂等只能防止同一个业务事件重复生效,不能防止所有业务逻辑错误。
例如,消费者只执行了一次,但积分计算错误,仍然需要通过对账发现和修复。