穿透 MQ 专栏 (三):【幂等防御】“网卡了一下,用户被扣了两次钱?”:如何防住防不胜防的重复消费
在上一篇,为了防住“薛定谔的消息”,我们在生产者、MQ 服务端和消费者身上绑上了“生死契约”(手动 ACK、副本落盘、无限重试)。看着坚不可摧的消息防线,你终于睡了个安稳觉。
但就在第二天凌晨,客服主管再次一脚踹开你的房门:“有个暴躁老哥在投诉!他明明只买了一双鞋,但网卡了一下,系统竟然连续扣了他两次钱!”
你赶紧爬起来查日志,瞬间冷汗直冒:扣款系统确实只发起了一次请求,但 MQ 里竟然躺着两条一模一样的扣款消息!消费者(订单系统)傻乎乎地拿了一条,扣了 100 块;紧接着又拿了下一条,又扣了 100 块。
“消息是没丢,但它踏马的发重了啊!”
今天,我们就来直面这个把无数初级程序员坑得体无完肤的物理常态——消息重复。并手撕大厂高频面试题:什么是幂等性?高阶开发是如何在代码里穿上“防弹衣”的?
一、 MQ 的摆烂哲学:为什么重复消费防不胜防?
很多新手对 MQ 有一种不切实际的幻想,认为 MQ 内部应该有一种机制,能帮我把重复的消息自动过滤掉。
放弃幻想吧!在分布式的物理世界里,只要有网络,只要你还想要“高可靠性”,重复发消息就是不可避免的死结。
我们在上一篇埋下的那个致命场景,就是重灾区:
扣款系统(生产者)成功把消息发给了 MQ。
MQ 稳稳地把消息存进了磁盘,并高高兴兴地给扣款系统发回执(ACK)。
就在这 0.1 秒的瞬间,光缆被挖掘机挖断了,或者网卡抖动了一下。
扣款系统迟迟等不到回执,它心想:“完了,消息肯定在半路丢了!”
尽职尽责的扣款系统触发了重试机制,又把这条消息发了一遍!
面对这两条一模一样的消息,MQ 是怎么想的? Kafka 和 RocketMQ 的底层哲学非常直接,叫做At Least Once(至少一次):
“兄弟,我只能向你保证消息绝对送达(至少一次),但我绝不保证我不会送两遍。至于怎么过滤重复,那是你业务系统该干的脏活累活,别赖我!”
所以,面对重复消息,不要试图在 MQ 层面堵,必须在你的消费者代码里防。
二、 说人话的“幂等性(Idempotency)”
为了解决重复消费,计算机界发明了一个极其拗口、听起来像某种法术的数学名词——幂等性。
别去背百科上的定义,咱们直接上“泥土气息”的业务类比:
什么是【不幂等】?(极其危险)就像老式的电灯拉线开关。你拉一次,灯亮了;再拉一次,灯灭了。 在代码里,就是
UPDATE account SET balance = balance - 100。 这条 SQL 执行 1 次和执行 2 次,用户的余额是完全不一样的!如果消息发重了,用户直接破产。什么是【幂等】?(绝对安全)就像带刻度的电灯旋钮。你把旋钮拧到“开”的位置,灯亮了;如果你的手抽筋了,又连续往“开”的位置拧了 10 次,灯依然只是亮着,不会爆炸。 在代码里,就是
UPDATE account SET balance = 500 WHERE balance = 600。不管这条相同的消息被 MQ 投递了 1 次还是 100 次,最终业务数据库里的状态,和被投递 1 次一模一样。这就叫幂等。
三、 高阶研发的“防重装甲”:实战三大解法
明白了概念,接下来才是重头戏。如何在消费者代码里,把所有操作都改造成“幂等”的? 大厂架构师通常有三种防重手段,从青铜到王者,层层递进。
1. 青铜解法:数据库唯一索引(Unique Key)硬抗
这是最简单粗暴、也是见效最快的兜底防线。
核心逻辑:建立一张独立的日志表
msg_idempotent_log。给每一条消息生成一个唯一的业务 ID(比如order_id + 动作=10086_PAY),把这个字段设为唯一索引(Unique Key)。业务流程:消费者拿到消息后,先去
INSERT这张表。如果
INSERT成功,说明是第一次来,继续执行扣款业务。如果
INSERT报错(触发DuplicateKeyException唯一冲突),直接捕获异常,打印一句log.warn("重复消息,直接丢弃"),然后向 MQ 返回成功 ACK,放行!
缺点:所有的并发压力全部砸在了数据库的
INSERT上,高并发场景下,数据库的锁竞争会成为性能瓶颈。
2. 白银解法:RedisSETNX(极速拦截机)
既然数据库扛不住并发,我们就把防重前线推到内存里,用 Redis 做分布式锁拦截。
核心逻辑:消费者拿到消息,先去 Redis 执行一句
SETNX msg_id 1(只有当 key 不存在时才设置成功)。业务流程:
如果 Redis 返回 1(成功),说明是新消息,去执行扣款入库。
如果 Redis 返回 0(失败),说明这是重复消息,直接把消息扔掉,返回 ACK。
致命的隐患(锁的释放问题):这种方案看似完美,其实暗藏深坑! 如果 Redis 返回了 1,但在执行后续的扣款数据库操作时,数据库宕机报错了!此时业务没做完,但 Redis 里已经留下了防重的标记。当 MQ 几秒后发起重试把消息再次投递过来时,Redis 会直接把它当成重复消息无情抛弃。结果:消息永远无法被正确消费,业务卡死。(解决这个坑需要引入极其复杂的分布式锁过期时间、状态反查机制,代码复杂度陡增。)
3. 王者解法:业务状态机(Optimistic Locking 终极优雅)
真正的顶级架构师,从来不喜欢引入多余的组件(既不建新表,也不用 Redis)。最优雅的幂等,一定是融合在业务逻辑本身之中的。
在电商系统中,任何一笔订单都一定有“状态(Status)”。比如:1=待支付,2=已支付,3=已发货。 这就构成了天然的状态机防重装甲。
核心逻辑:消费者拿到扣款成功的消息(想要把订单状态改为已支付),不需要查 Redis,不需要查防重表,直接去 MySQL 执行一条带前置状态条件的 UPDATE 语句:
SQLUPDATE orders SET status = 2, update_time = NOW() WHERE order_id = '10086' AND status = 1; -- 【关键防御:只能从“待支付”状态流转】奇迹时刻推演:
第一条消息到来:数据库里
status确实是 1。SQL 匹配成功,affected_rows = 1。订单状态变成了 2。网卡了一下,重复的第二条消息到来:同样执行这句一模一样的 SQL。但是!此时数据库里的
status已经是 2 了。WHERE status = 1这个条件直接落空。MySQL 会悄无声息地返回affected_rows = 0。代码处理:你的 Java 代码判断一下,如果影响行数为 0,说明状态已经被改过了,这就是重复消息!直接
return,大摇大摆地向 MQ 发送 ACK。
不需要任何额外的中间件,仅仅利用了业务数据自带的状态转换单向性,配合 MySQL InnoDB 行锁的天然原子性,就完美斩断了重复消费的魔爪。
💡 灵魂拷问:为下一篇埋下天坑
经过这三篇的浴血奋战,我们用 MQ 挡住了洪峰(削峰),用确认机制防住了丢失(可靠性),用状态机防住了扣两次钱(幂等性)。
你的秒杀系统现在简直像是一个刀枪不入的铁桶。
但是,老板又来找麻烦了。 这次的案发现场更加诡异:“有个用户下了单,然后立刻申请了退款。结果我们的系统不仅没给他退款,反而把货给他发出去了!”
你调出日志,看到了让人三观崩塌的一幕: 扣款系统明明是先发了“订单创建”的消息,后发了“订单取消”的消息。 但在消费者(订单处理系统)那边,竟然是先收到了“订单取消”,后收到了“订单创建”! 系统一看,订单还没创建怎么取消?直接把“取消”消息报错扔了。紧接着“创建”消息到了,系统高高兴兴地把货给发了。
“这消息是怎么在网络里超车的?说好的先来后到呢?”
如果在高并发场景下,消息乱序了,所有的业务状态机都会瞬间崩溃。 要想让消息绝对有序,MQ 只能退化成单线程运行,那吞吐量就直接跌回原始时代。
在“极致的性能”与“绝对的秩序”之间,MQ 究竟是如何妥协的? 下一篇,我们将直击分布式系统的心脏痛点:《【秩序之争】被杀死的全局顺序:消息乱序与千万级积压的救火指南》!
