幂等消费:为什么你明明做了去重,钱还是扣了两次?

凌晨三点,报警电话响起。一笔支付回调,重复消费了10次。用户账户扣了10笔。运维同学骂骂咧咧地翻代码,看到消息队列里那行‘至少一次投递’的注释,叹了口气。这就是幂等消费没做好的代价——代价大到让人想砸电脑。

别急着说“我用了唯一的message_id啊”。用是用了,但为什么还出事?因为你踩的坑远不止一个ID那么简单。

一、幂等,不是说你重复执行结果一样就行

很多人看过那个经典例子:set a = 10 是幂等的,set a++ 不是。但这跟消费幂等完全是两码事。消费幂等的核心,是把“操作”封装成一个“状态流转”。啥意思?比如支付回调,处理逻辑不是“收到消息就扣款”,而是“如果订单状态为待支付,则扣款并改为已支付;如果已是已支付,直接返回成功”。

换句话说,你得把业务行为抽象成一个有限状态机。消息来了,不是直接动手,而是先查当前状态——像去电影院,检票口先看你票根是不是已撕过,而不是凭你脸熟再放你进去一次。可问题是,这票根得是全局唯一的,而且必须“一撕定终身”,不能同时有两个检票员撕同一张票。

消息幂等消费状态机流转示意图
消息幂等消费状态机流转示意图

技术实现上,无非两条路:强行在数据库里砸一个唯一约束(比如订单号+操作场景建个联合唯一索引),然后insert一条去重记录;或者用Redis的SET NX原子性锁住一个key。但这两条路,走起来都是坑。

我做过一个电商订单的异步改价需求——用户下单后,后台允许手动改价,改了要同步到订单中心。消息是走RocketMQ,为了高可用,MQ会重试。我一开始就用了订单号+操作时间戳当唯一键,心想这总不会重复吧?结果,有个倒霉催的测试用例,连续两次请求的时间戳一模一样,精确到毫秒都一样……因为网络抖动,生产者把同样的消息塞了两遍。去重?去个寂寞。最后是靠雪花算法生成一个全局递增的biz_id,塞进消息体,再加上订单号+操作类型建联合唯一索引,才算真正防住。这是第一个教训:别用业务数据拼唯一键,拼出来的都是泪

二、压测,数据不说谎

光靠嘴说方案可靠不可靠,太虚了。我们直接压测对比三种常见幂等方案。

场景:一个支付回调服务,接收RabbitMQ消息,需保证幂等。消息量模拟为每秒5000的峰值,每条消息处理耗时约50ms(含查库、写库)。

  • 方案A:MySQL去重表。消费端先insert ignore一条记录(唯一键:msg_id),然后执行业务。压测结果:当并发超过3000时,数据库CPU飙升到90%以上,插入平均延迟从5ms飙到120ms,大量消息被拒绝,TPS直接腰斩。原因?insert ignore在唯一键冲突时虽然不报错,但依然会加间隙锁,高并发下锁竞争惨烈。
  • 方案B:Redis SET NX。用msg_id做key,SET成功才继续。实测单节点Redis轻松扛下5000 TPS,P99延迟5ms以内。但好景不长,Redis主从切换时,因为异步复制,有一瞬间key丢失,导致几百条消息重复消费。这就是大名鼎鼎的Redis分布式锁在故障时的“幽灵锁”问题
  • 方案C:业务状态机 + 数据库乐观锁。不依赖额外去重,而是依赖业务单据本身的状态。比如订单表有个status字段,更新时用version号(乐观锁)确保只更新一次。压测显示,TPS稳定在4000左右,无额外存储开销,但要求所有消息处理必须严格走这个状态机,改造量大,而且遇到无序消息(后发的消息先到)有时需要特殊处理。
幂等消费压测性能对比柱状图
幂等消费压测性能对比柱状图

最终,我们折中选择了方案B+C的混合体:核心业务走状态机,非核心或无法改老代码的场景,用Redis做轻量去重,但为了克服Redis丢key,把msg_id也持久化到一张小的去重日志表(异步写,不阻塞消费),一旦发现Redis没了,还能回查日志兜底。这个架构在双11大促中扛下了10万TPS的峰值,消息重复率从之前的0.1%降到了0.0003%

三、落地,三个把你坑出血的地方

三、落地,三个把你坑出血的地方
三、落地,三个把你坑出血的地方

理论讲完,说点真金白银的实操。我经历过不下五次幂等消费的血案,总结出三个最狠的坑。

坑一:唯一ID生成器的时钟回拨。你用雪花算法或其他分布式ID生成器,一旦服务器时钟回拨(NTP同步导致,或者虚拟机暂停),生成的ID就可能重复。重复ID意味着什么?去重彻底失效。解决办法:要么用Leaf-segment双buffer模式(美团开源的),要么在应用层缓存最近生成的序号,检测到回拨就拒绝对外服务一段极小时间。当然,现在用Redis原子自增+持久化也能避免,但还是要考虑Redis宕掉时的降级。

坑二:去重记录表无限膨胀。消息量一上来,去重表很快就会变成庞然大物,MySQL扛不住。有人用分表,按msg_id哈希,但业务冷热不均。更好的做法是TTL定时清理,只保留最近N天的去重记录。但记得,清理操作会导致主库IO突增,务必在低峰期并且用低优先级线程慢慢删。我们用的是RocksDB嵌入式存储做去重,顺序写、倒排清理,性能比MySQL高两个数量级,而且没额外网络开销。

坑三:下游服务非幂等。你消息消费幂等了,但调用下游HTTP接口却是非幂等的。比如你扣款成功了,调用营销中心发优惠券,但营销中心没做幂等,网络超时你重试了,它就发了两张券。这不是你的锅?用户可不这么看。解决办法:强迫下游提供幂等接口,或者你每次调用带上一个客户端生成的unique_token,下游用token做去重。如果下游无法改造,那你只好自己实现一个补偿表,记录调用成功与否,并定时对账。有人说这叫“上游替下游擦屁股”,没错,但总比线上赔钱强。

最后说一句,幂等消费从来不是一个消息队列特性,而是一个端到端的业务属性。别指望MQ的X-Deduplication-Header能帮你解决所有问题,那玩意儿只在某些队列里有效,而且大多只保证一段时间内的去重。真正的保障,是你代码里那行select for update,以及你那台Redis服务器不要因为断电而丢数据。

至于有人说可以靠Raft协议强一致的元数据服务来管理去重?理论很美,现实是每次去重都要跑一轮共识,延迟抵得上一次跨机房RTT,除非你是Google那种体量,否则还是老老实实啃业务状态机吧。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:幂等消费:为什么你明明做了去重,钱还是扣了两次?
文章链接:https://www.lfdjt.com/info_23_7770.html