1. 重复消费的现场:一次库存事故的全过程复盘
有一类问题,平时看起来毫不起眼,一旦触发就是线上事故级别的麻烦——消息队列消费的幂等性。我之前经历过一次典型的重复消费事故,到现在想起来还觉得后背发凉。
当时订单系统通过消息队列投递创建订单的消息,库存服务负责消费并扣减库存。某天晚高峰,一个订单消息被正常处理,库存也扣成功了,但消费端在给消息中间件返回确认时网络抖动了一下,消息队列判定这次消费超时失败,触发了默认重试机制。同一笔订单的消息被再次投递,库存服务又执行了一次扣减逻辑。等监控告警响起来的时候,系统里已经出现了好几笔“同订单扣两次库存”的异常流水,仓库里对应的商品实际库存和数据库记录差了好几件。
这个事故说大不大,说小不小。导致事故的根本原因不是扣减逻辑写错了,也不是消息中间件出了问题,而是消费逻辑没有做到幂等——同一个消息被处理两次,产生了非预期的副作用。从那以后,我把“消费端幂等设计”当成所有消息队列业务的标配检查项,而不是可选项。
很多做后端开发的同事都有类似的困惑:明明队列里没有重复数据,为什么消费端必须处理幂等?要回答这个问题,得先搞明白消息为什么会重复。这不是“会不会发生”的问题,而是“什么时候发生”的问题。只要你在用消息队列,只要系统做到了一定规模,重复消费基本躲不掉。
所以这篇文章我会把和消息队列幂等性相关的方案讲透:重复消息从哪里来、业务层有哪些幂等手段、哪些操作天然幂等不用额外处理、消费端框架层面怎么兜底,以及我实际用下来踩过的坑和选型建议。适合正在做分布式系统、异步削峰、以及对“消息只处理一次”有强需求的读者参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么消息一定会重复:投递语义与重试机制
2.1 三种投递语义,大多数场景默认的是“至少一次”
消息队列的投递语义通常分成三种:
- 至多一次(At-Most-Once):消息可能丢失,但绝不会重复。发出去就不管了,丢了就丢了。
- 至少一次(At-Least-Once):消息不会丢失,但可能重复。消息发出去之后,如果没收到确认,就再发一次。
- 恰好一次(Exactly-Once):消息既不丢也不重复。这是最理想的状态,但实现成本极高,绝大多数业务场景不会做全局恰好一次。
主流的消息队列中间件,比如Kafka、RocketMQ、RabbitMQ,在生产者和消费者正常协作时,本质上实现的多是至少一次投递。Kafka通过消费者的offset提交机制保证消息不丢,但如果消费者处理完消息之后、提交offset之前崩溃了,重启后就会从旧offset重新拉取消息,这一批消息就被重复消费了。RocketMQ的消费重试、RabbitMQ的手动ack未确认重投,机制都是同一个道理:宁可重复,不能丢失。
这就是幂等性必须存在的最根本原因——消息队列本身不保证不重复,重复是分布式系统的常态,不是bug。
2.2 生产端重试与消费端重试,两条重复路径
消息重复的来源,总结下来有两大路径。
第一条路径是生产端重试。生产者发送消息时,由于网络超时、Broker短暂不可用等原因,生产者不知道消息是否真的发送成功,通常会配置重试机制把消息再发一次。如果第一次实际已经写入Broker了,只是响应超时,那Broker里就有两条一模一样的消息。
第二条路径是消费端重试。消费者拉取消息后开始处理业务逻辑,处理成功但在提交确认时失败(网络抖动、消费进程崩溃、重启),Broker会认为消息没被消费,重新投递给消费者。这就出现了同一条消息被同一个消费者处理了两次的情况。我遇到的那次库存事故,就是典型的消费端确认失败导致的重复。
除了这两条,还有一类场景容易被忽略:消费者集群部署时,消息重新负载均衡。比如某个消费者实例宕机,它正在处理的消息会被分配到其他实例,如果分配时机恰好发生在消息已经处理完但在等待确认的阶段,就会造成重复消费。这类重复非常隐蔽,因为从日志上看,不同的实例都在处理同一条业务消息,很难第一时间察觉。
2.3 用生活类比理解:快递送货的“回执确认”
用一个生活类比帮助理解:你网购了一件商品,快递员送货上门。快递员把包裹放在门口,回去后向系统提交“已签收”的回执,结果系统提示提交失败。快递员不确定到底有没有提交成功,于是第二天又把同样的包裹送了一次。你作为收件人,拿到了两个一模一样的包裹——消息队列的场景里,消费者就是收件人,快递员的重复送货就是重复消费。
如果你的业务逻辑是“把包裹搬进屋里放好”,搬两次也没问题——这就是天然幂等的操作。但如果业务逻辑是“拆开包裹清点里面的钱,每清点一次就往你账户里记一笔”,那搬两次就会导致账户里多了两笔钱。很多业务的复杂度恰恰在于,消费逻辑并不是天然幂等的。
明白这个源头之后,接下来可以看业务侧怎么做了。
3. 业务层的幂等方案:从唯一键到状态机的完整路径
3.1 数据库唯一键约束:最朴素也最可靠的首选方案
处理重复消费最直接的办法,是让数据库帮我们挡住重复。
具体做法是:在业务表里设计一个带唯一索引的字段,这个字段的值由业务唯一标识生成。消费者处理消息时,先尝试插入一条记录,如果插入时抛出了唯一键冲突异常,说明这条消息已经被处理过了,直接跳过。如果插入成功,再继续执行后续的业务逻辑。
以订单创建为例,假设我们要根据消息里的订单号创建一条订单记录:
java复制public void handleOrderCreateMessage(OrderMessage message) {
try {
OrderDO order = buildOrder(message);
orderMapper.insert(order);
// 插入成功,继续执行后续逻辑
doSomethingAfterOrderCreated(order);
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明订单已存在,幂等处理
log.warn("duplicate order message, orderId={}", message.getOrderId());
return;
}
}
这里的核心在于order_no字段必须建唯一索引,否则并发情况下两条相同消息同时进来,有可能都通过了select判断,导致重复插入。用唯一索引兜底,而不是先查再插入,是这道防线能够生效的关键。
唯一键约束的优点是实现简单、不依赖额外组件,数据库本身就能做到事务一致。缺点是适用范围有限:只有“插入新记录”这种语义才能用,如果业务是先更新记录再执行后续操作,就没有一个天然的“插入”动作可以依赖了。另外,一旦涉及分库分表,唯一索引的全局唯一性会变得麻烦,通常需要额外引入一个全局ID或者去重表。
3.2 Redis SETNX做去重标记:高吞吐场景的常用方案
如果业务场景不依赖数据库的唯一约束,或者消息量很大,用Redis的原子操作做去重标记是另一种常见思路。
原理很简单:以业务唯一标识为key,用SETNX命令尝试写入一个标记值。SETNX只在key不存在时才会设置成功,如果key已经存在,说明这条消息之前已经被处理过。利用这个原子性,可以把“是否已处理”的判断和“标记已处理”的动作合并成一步,避免并发窗口。
java复制public boolean tryMarkProcessed(String businessId) {
String key = "dedup:" + businessId;
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofHours(24));
return Boolean.TRUE.equals(result);
}
public void handleMessage(OrderMessage message) {
if (!tryMarkProcessed(message.getOrderId())) {
log.info("message already processed, orderId={}", message.getOrderId());
return;
}
// 继续处理业务
processBusiness(message);
}
这里有几个关键细节必须注意。
第一,SETNX的过期时间要和消息队列的最大重试周期对齐。比如消费者的重试间隔可能持续48小时,那这个key的过期时间不能短于48小时,否则一条消息第一次处理时写了标记,48小时后又来了重试消息,key已经过期,会被当成新消息再次处理。我之前就吃过这个亏,把过期时间设成30分钟,结果消费端重试策略最长能拖到1小时,导致部分库存扣减重复执行。
第二,先占位后执行,而不是先判断后占位。有些同学会写成先get判断key是否存在,不存在再去set,这样在并发场景下会漏判。必须直接用SETNX这种原子操作一条命令完成判断和写入。
第三,Redis方案带来的问题是它引入了额外的依赖和高可用风险。Redis挂了,整个消费链路可能直接停摆。所以实际生产环境里,我倾向于把Redis去重定位成“第一道快速拦截”,真正的强一致兜底还是交给数据库或者状态机。
3.3 状态机:让业务状态流转本身具备幂等性
很多业务消息消费的本质,是让一个业务实体发生状态变化。比如订单从“待支付”变成“已支付”,物流从“运输中”变成“已签收”。如果我们的状态变更SQL带了“前置状态条件”,那么它的执行天然就是幂等的。
拿支付结果通知来说,消费端要做的是把订单状态从PENDING_PAYMENT改成PAID:
sql复制UPDATE order_info
SET status = 'PAID', paid_time = now(), update_time = now()
WHERE id = #{orderId} AND status = 'PENDING_PAYMENT';
这条SQL第一次执行时,会命中1行,把订单状态改成PAID。同样的消息再重试一次,SQL执行时status = 'PENDING_PAYMENT'的条件已经不成立了,会更新0行。根据这个影响行数,消费端就能判断是不是重复消息:
java复制public void handlePaymentResultMessage(PaymentMessage message) {
int rows = orderMapper.markAsPaid(message.getOrderId());
if (rows == 1) {
// 本次更新真正生效,继续执行后续流程
doAfterPayment(message);
} else {
// 更新0行,说明状态不是预期的前置状态,可能已被处理,直接跳过
log.info("order not in PENDING_PAYMENT, skip, orderId={}", message.getOrderId());
}
}
这种方案的好处是几乎不引入额外的存储成本,只是多了一个前置状态条件,业务语义很清晰。但它对状态机的设计有要求:所有状态变更都必须沿着合法的流转方向走,不允许跳变。比如订单从PAID往REFUNDING流转,那么退款消息不能用“status = 'PAID' AND status = 'PENDING'”这种混乱的前置条件。状态机设计得越严谨,幂等处理就越容易。
3.4 乐观锁版本号:处理并发更新的幂等利器
业务场景中还有一种常见情况:消息里携带的是一笔金额更新操作,比如账户余额增加、库存数量扣减。这种场景既不能用唯一键挡,也不能用状态机表达状态,最常用的手段是乐观锁版本号。
思路是在业务表里加一个version字段,更新的时候带上期望的版本号,只有版本号匹配才更新成功,同时把version加1。因为重复消息携带的还是旧的version,第二次更新会失效。
sql复制UPDATE account_balance
SET balance = balance + #{amount},
version = version + 1
WHERE account_id = #{accountId}
AND version = #{expectedVersion};
第一次执行时,version匹配,更新成功,version从1变成2。重复消息再来时,消息里存的expectedVersion还是1,数据库里的version已经是2了,条件不匹配,更新0行,逻辑自然跳过。
这种方案的优点是实现简单,还能同时处理并发更新的问题。缺点是如果业务并发冲突很多,大部分更新会失败,消费端需要做好失败重试的补偿逻辑。另外要注意,版本号适合“单实体多操作”的幂等,如果是“一次消息要更新多个实体”,就需要把多个更新放进同一个事务,或者用事务消息最终一致性的思路来处理。
3.5 去重表:多业务共用一套幂等机制的通用做法
如果系统里有多个业务都在消费消息,每个业务都单独设计幂等方案会显得很杂乱。比较通用的做法是建一张消息去重表,记录所有已经成功处理过的消息。
sql复制CREATE TABLE msg_dedup (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
msg_key VARCHAR(128) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
created_time DATETIME NOT NULL,
UNIQUE KEY uk_biz_msg (biz_type, msg_key)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
消费消息时,先往这张表插入一条记录;插入成功说明是首次处理,插入失败说明是重复消息,直接跳过。
java复制public boolean tryProcess(String bizType, String msgKey) {
try {
dedupMapper.insert(bizType, msgKey);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
去重表方案本质上和“业务表的唯一键”是同一个思路,区别在于它和业务表解耦,多个业务可以复用同一个基础设施。它还能额外记录处理时间,方便排查问题。缺点是多了一次数据库写操作,在高吞吐场景下会成为性能瓶颈。如果消息量特别大,可以考虑定期清理去重表里超过保留周期的历史记录。
4. 哪些操作天然幂等:别把简单场景复杂化
4.1 天然幂等的三类操作特征
不是所有消费逻辑都需要专门做幂等设计。如果业务操作本身是幂等的,重复执行不会有额外副作用,那消费端可以直接返回成功,不用做任何处理。把操作是否能自然重复执行一次、结果是否一样,这是判断是否需要幂等方案的第一个标准。
天然幂等的操作主要有三类。
第一类是纯查询操作。比如消费消息后读取某个配置、刷新缓存,执行多少次结果都一样。
第二类是覆盖式写入。比如把用户的昵称设置为某个值,把这个值算好之后,无论执行多少次,最终存储在数据库里的都是同一个值。
第三类是删除操作。删除一个不存在的记录,MySQL的DELETE语句返回影响行数为0,但不会报错。所以“删除用户缓存”“删除过期的token”这种操作天然幂等。
java复制// 天然幂等:无论执行多少次,Redis里存的值都是一样的
redisTemplate.opsForValue().set("user:profile:" + userId, profileJson);
// 天然幂等:删除一个不存在的key也不会报错
redisTemplate.delete("temp:key:" + token);
4.2 容易被误判为幂等的非幂等操作
有些操作看起来像幂等,实际上根本不是。最典型的是累加累减操作。比如账户余额增加100元,执行一次是余额+100,执行两次是+200,结果完全不同。再比如追加操作,向列表尾部追加一条日志记录,执行两次日志记录会多一条。
另一种容易被忽略的情况是**“先查后写”的复合操作**。比如消费消息时先查询用户当前等级,再根据等级计算新积分然后更新。如果这个查询+计算的逻辑在两次重复消费中各自执行一遍,第一次计算出结果是A,更新了;第二次重新查询已经更新的结果,再算出B,更新了。最终结果就不是“只执行一次”应该有的结果——虽然最终存储的值可能一样,但中间依赖了查询结果,整体逻辑已经变成了非幂等。
所以判断幂等不能只看最终存储结果,还要看整个过程是否允许被重复执行。只要过程中有“读取当前值→基于当前值计算新值→回写”这种链路,就该严肃对待。
4.3 日常编码中一个很实用的判断法则
我平时判断一个消费逻辑是否需要额外做幂等,会问自己三个问题:
- 把同样的消息重放一遍,业务结果是否一致?
- 如果结果不一致,不一致的影响是否能在业务上接受?
- 如果影响不可接受,最薄的一层幂等拦截放在哪里最合适?
举个例子,消费一条“用户浏览商品”的埋点消息,我们把它写入统计表,重复写入会多一条浏览记录。但从业务上看,偶尔多记一次浏览对分析结果影响不大,这种场景就不需要投入高成本做幂等。但如果是“扣减库存”“增加余额”“发放优惠券”这类直接影响资金和货权的结果,绝不允许偏差,那就必须上幂等方案。
5. 消费端的兜底设计:框架层面的防护与治理
5.1 手动ACK才能掌握确认时机
业务层的幂等方案解决的是“重复执行时结果可接受”的问题,但消费端框架层面的设计同样重要,它能决定重复发生的概率有多大。
使用消息队列时,一定要明确ACK机制。自动ACK是幂等设计的大敌。很多基于Spring的消费框架默认会在消费者收到消息后立刻自动确认,而真正执行业务逻辑是在确认之后。如果业务逻辑执行失败,消息也不会重新投递,导致消息丢失;如果业务逻辑执行成功但进程在返回前崩溃,消息已经被确认,不会重复投递,但业务已经执行完了,看起来没问题。
手动ACK则把确认时机交给我们自己控制。正确姿势是:先执行业务逻辑,业务执行成功后再手动确认。这样如果执行业务期间进程崩溃,消息不会被确认,Broker会重新投递,消费逻辑会再次执行。此时幂等方案的意义就体现出来了——第二次执行虽然发生了,但结果和只执行一次保持一致。
java复制public void onMessage(Message message) {
try {
// 1. 先执行业务逻辑,内部必须包含幂等保护
boolean success = processBusiness(message);
if (!success) {
// 业务本身判断为重复,也算处理成功,直接确认
message.acknowledge();
return;
}
// 2. 业务执行成功,手动确认
message.acknowledge();
} catch (RetriableException e) {
// 可重试异常,不确认消息,触发重投
log.error("retriable exception, messageId={}", message.getMessageId(), e);
} catch (FatalException e) {
// 不可重试异常,确认消息并记录到死信队列
message.acknowledge();
sendToDlq(message, e);
}
}
5.2 消费端本地去重缓存
除了业务层的幂等标识,消费端进程内可以再维护一个去重缓存,拦截明显重复的消息。这个缓存适合拦截高频重复,比如同一个消息在几秒内被反复重投。
用Caffeine或者Guava Cache设置一个短过期时间的本地缓存,以消息ID为key,在执行业务前先查一遍本地缓存。这样做的好处是几乎零成本,性能极高;缺点是多实例部署时,每个实例的缓存是独立的,无法全局拦截。
所以本地去重缓存只是辅助手段,不能作为唯一防线。真正的全局幂等还是要靠Redis或者数据库。
5.3 重试队列、死信队列与告警
消费端框架的兜底设计还包括对重试和失败消息的治理。
一般建议给每个消费者配置合适的重试次数。重试次数太小,一个临时性故障(比如数据库连接池满)会导致消息处理失败,消息直接进入死信队列;重试次数太大,一条坏消息会反复阻塞消费进度。要根据业务容忍度,设置合理的重试次数和重试间隔。
消息重试达到上限仍然失败时,应该进入死信队列。死信队列的消息需要专门的消费者来处理,可能是人工介入修复数据,也可能是自动执行兜底逻辑。死信消息一定要有告警,否则它会安安静静躺在队列里,业务影响越来越严重。
我见过不少团队,线上消息队列的死信队列堆了几万条消息都没有人发现。等业务方反馈数据对不上,一查才发现是一条坏消息反复重试把所有下游任务都堵住了。给死信队列配上监控告警,是这个环节最重要的一件事。
5.4 消费记录表:可查询、可对账的兜底
在核心业务链路里,我还会在消费端维护一张消费记录表。这张表记录每条消息的消息ID、业务ID、消费状态、消费时间、耗时等关键信息。
消费记录表的好处有三个:一是配合唯一键机制,本身就是幂等方案的一种实现;二是方便排查问题,某条业务消息到底有没有被消费过、处理结果是什么,一条SQL就能查出来;三是可以对账,如果下游业务数据和消息发送量对不上,消费记录表是很好的追溯依据。
这张表会随着业务量增长变得很大,需要定期归档。通常按天创建分区,保留最近30天的数据就足够了。
6. 方案选型对照与实践总结
6.1 不同方案的对比表格与选型逻辑
把前面介绍的几种方案放在一张表里对比,方便按业务场景选型。
| 方案 | 核心原理 | 适用场景 | 优点 | 注意点 |
|---|---|---|---|---|
| 数据库唯一键 | 业务表唯一索引拦截重复插入 | 创建订单、生成流水等“插入新记录”场景 | 实现简单,强一致 | 分库分表后唯一性难保障 |
| Redis SETNX | 原子占位标记已处理 | 高吞吐去重场景 | 性能高,操作简单 | 需要合理设置过期时间,依赖Redis可用性 |
| 状态机前置条件 | 更新语句带原状态条件 | 订单支付、状态确认等强流程场景 | 业务语义清晰,不引入额外存储 | 状态设计必须严谨,不允许跳变 |
| 乐观锁版本号 | version字段条件更新 | 余额、库存等资金货权类更新场景 | 同时解决并发更新问题 | 高冲突场景需要重试补偿 |
| 去重表 | 独立表记录处理过的消息 | 多业务共用的通用幂等基础设施 | 通用性最强 | 增加一次数据库写操作 |
| 消费记录表 | 记录完整消费流水 | 核心链路可追溯对账 | 可查询,可对账 | 表数据量大,需要定期归档 |
选型时我一般按这样的顺序来思考:先判断业务操作是不是天然幂等,是就不用额外处理;如果不是,优先看能不能用唯一键或者状态机解决——这两种方案强一致、不依赖额外中间件;如果涉及资金类并发更新,加乐观锁版本号;如果跨服务、跨库,才考虑Redis SETNX或者去重表。
6.2 实践中踩过的坑和注意事项
做消费端幂等设计这几年,我总结出几个值得特别注意的坑。
第一个坑是**“先查后写”的竞态**。比如先去查订单是否存在,不存在再插入。两个相同消息并发到达时,两个消费者线程可能都查到“不存在”,然后都执行插入,如果表上没有唯一约束,就会插出两条订单。解决方式永远是用数据库或Redis的原子操作来做“判断+写入”,而不是拆成两步。
第二个坑是Redis key过期时间设置不合理。设短了,重试周期长的消息会漏判;设长了,浪费内存。需要根据消费端的重试策略来定:重试时间最长多久,key的TTL就至少要比它长。同时还要在业务低峰期做定期清理,避免无效key堆积。
第三个坑是状态回退。如果状态机的流转方向设计得不严谨,消费者收到一个旧的“待支付”状态消息,可能会把已经变成“已支付”的订单状态回退到“待支付”。解决方式是状态变更SQL里必须限制前置状态,同时最好加上更新时间判断,过期消息直接丢弃。
第四个坑是消息处理多个子业务时的整体幂等覆盖。一条消息里可能既有订单创建,又有库存扣减,还有积分发放。如果只给订单创建做了幂等,库存扣减和积分发放仍然是可重复执行的,整体链路依然不幂等。要么把所有子业务放进同一个事务并统一用唯一键控制,要么拆成多条消息分别做幂等。
第五个坑是Redis作消息队列时的幂等设计。Redis的BRPOP/LPOP配合业务处理,中间如果业务处理失败,消息已经POP出去了,就丢了;如果先处理业务再POP,又可能重复消费。所以用Redis做消息队列时,消费端的幂等设计比使用专业MQ时更加重要,通常要配合“处理成功后写入记录”的幂等标记来做。
6.3 一个可以落地参考的完整组合
根据这几个方案的特点和踩坑经验,我最后分享一套目前项目中实际在用的组合方式,可以作为参考。
消费端收到消息后,先走Redis SETNX做第一道快速去重拦截,拦截掉绝大多数短期重复消息;通过之后进入业务层核心方法,业务方法里根据场景落地唯一的强幂等方案——能走唯一键的走唯一键,能走状态机的走状态机,资金类更新一律带版本号;业务执行成功后手动ACK确认消息,同时往消费记录表写入一条完成记录;如果业务抛了可重试异常,不确认消息,让它根据配置重试;重试达到上限则进入死信队列,并触发告警。
这套组合在业务量中等偏上的系统里跑了大半年,库存扣减和订单创建相关的重复消费事故归零,消息积压和死信告警也能在第一时间被发现和处理。
消息队列的幂等性设计本质上是在认清“分布式环境中重复无法避免”这个大前提之后,通过合理的技术选型和流程兜底,把重复带来的影响降到可控范围。这个思路不受限于具体的消息队列产品,也不受限于编程语言,核心是把“重复发生后的结果”设计成和“只发生一次”一样,剩下的就交给时间验证了。
