在上一篇我们利用“业务状态机”给消费者穿上了完美的防弹衣彻底防住了网卡顿导致的“重复扣款”。看着固若金汤的秒杀系统你觉得已经没有什么能打败你了。直到有一天老板脸色铁青地把你叫进办公室指着一条客诉问“这个用户明明是在下单后立刻点了取消为什么系统不仅没给他退款反而把货给他发出了”你调出日志瞬间三观崩塌 扣款系统生产者确实是按照顺序发出了两条消息先发了[订单 10086已支付]紧接着发了[订单 10086已取消]。 但在物流系统消费者这边的日志里竟然是先收到了 [已取消]后收到了 [已支付] 物流系统一看订单还没支付怎么取消直接把 [已取消] 的消息报错扔了。紧接着 [已支付] 的消息到了物流系统高高兴兴地把货给发出去了。“这消息是怎么在网络里超车的说好的先来后到呢”今天我们将直击分布式系统的心脏痛点揭开高并发与消息秩序之间的死结。并附带一份极度硬核的、大厂架构师专用的“线上千万级消息积压救火指南”。一、 乱序惨案消息是怎么在网络里超车的很多初学者对 MQ 有个浪漫的误解生产者按 1、2、3 的顺序发消息消费者肯定就按 1、2、3 的顺序收。在真实的物理世界里这简直是痴人说梦。假设你的 MQ 集群有 3 个节点你的消费者部署了 5 台机器。生产者发出了消息 1支付和消息 2取消。消息 1 被路由到了 Broker A消息 2 被路由到了 Broker B。消费者机器 X 从 Broker A 拉取了消息 1消费者机器 Y 从 Broker B 拉取了消息 2。超车点来了机器 X 所在的物理机正在做老火汤级别的垃圾回收Full GC卡顿了 2 秒。而机器 Y 畅通无阻瞬间把消息 2 执行完了进库结论只要存在多节点并行投递、多线程并发消费全局顺序必然被撕得粉碎。网络延迟、GC 停顿、CPU 调度任何一个微小的抖动都能让后发的消息轻松超车。二、 性能与秩序的死结被杀死的“全局顺序”你可能会拍桌子“既然会乱那就让 MQ 强制保持先进先出FIFO不就行了”可以但代价你承受不起。 要想实现全局绝对顺序在物理上只有一种解法单车道单收费站模式。MQ 只能用一个节点、一个队列来存消息。消费者只能开一台机器、单线程去拉取消息。这就好比在京港澳高速上为了保证所有车绝对不超车整条高速公路只留一条车道只有一个收费站。后果是什么吞吐量直接从 10 万 TPS 暴跌到 100 TPS。你的系统在双十一开启的第 1 秒钟就会被瞬间砸垮。在“极致的高并发”面前“全局有序”是一件极其奢侈的陪葬品。大厂的架构法则非常冷酷直接杀死全局顺序三、 破局之道局部顺序Hash 路由的终极妥协架构师冷静下来想了想我们真的需要所有的消息都排好队吗其实根本不需要。 张三下单和李四下单谁先谁后根本无所谓。我们只在乎【张三的支付】绝对不能跑到【张三的取消】前面去这就是破局的曙光我们不需要全局顺序我们只需要局部顺序Partition / Queue 级别有序。【大厂标准解法Hash 路由打点】像 Kafka 和 RocketMQ 这种顶级的消息中间件内部其实是分了很多个“区Partition / Queue”的。这就好比高速公路上开了 100 个收费站。生产者端的骚操作当我们发送 [订单 10086] 的相关消息时绝不能让 MQ 随机瞎分发。我们必须提取Order_ID (10086)作为路由键Routing Key做一个Hash(10086) % 分区数的运算。物理奇迹发生了经过 Hash 取模运算所有属于订单 10086的消息创建、支付、发货、取消不管发多少条全都会被死死地打进同一个 Partition 里消费者端的默契MQ 底层有一条铁律——一个 Partition 同一时刻只能被一个消费者线程消费。就这样属于同一个订单的消息在同一个队列里排着队被同一个线程按顺序拉走处理。我们用极其巧妙的 Hash 路由在保全了 100 个分区高并发吞吐量的同时完美守住了订单状态机流转的绝对秩序四、 灾难演练线上突然积压 1000 万条消息怎么办搞定了顺序接下来我们要面对 MQ 领域最恐怖、最让人心跳骤停的生产事故消息积压Lag。某天早晨你一到公司就听到警报狂鸣监控大盘红得发紫MQ 里的消息积压了 1000 万条用户付款后半个小时都收不到成功短信大批客诉正在路上。Step 1查内鬼为什么会积压MQ 积压99% 的黑锅根本不在 MQ 身上而在于你的消费者代码写得太烂了。是不是代码里调用第三方 API 超时卡死了消费线程是不是没建索引导致了一条极其缓慢的慢 SQL把数据库连接池拖垮了第一步立刻止血定位到有 Bug 的消费端代码立刻修复上线。Step 2极其残酷的现实坑死无数人的误区Bug 修复了单台机器消费速度恢复到了 1000条/秒。但面临 1000 万的积压你一算1000 万 / 1000 1万秒 接近 3 个小时 老板站在你背后“我给你 15 分钟立刻把积压给我清空”初级研发会怎么做“加机器啊原来部署了 4 台消费者我立刻去找运维要 40 台机器横向扩容 10 倍速度不就上来啦”错大错特错还记得上面局部顺序提到的“铁律”吗一个 Partition 只能被一个消费者线程消费假设你建 Topic 的时候只分了 4 个 Partition。即使你现在启动了 40 台消费者机器MQ 也只会把数据分给其中的 4 台剩下的 36 台机器全都在那干瞪眼完全起不到任何加速作用五、 架构师的救火 SOP临时换家战术十倍提速面对无法动态增加 Partition 的死局资深架构师会祭出一套极其冷血且高效的救火战术建新家紧急去 MQ 控制台新建一个名为Topic_New的新主题并且毫不吝啬地给它分配40 个 Partition派驻搬运工写一个极其简单的“临时消费者”代码去消费原来那个积压了千万数据的旧 Topic。注意这个搬运工不做任何业务逻辑不查库不掉接口它的唯一任务就是以光速把拉到的消息疯狂地扔进Topic_New里大军出击把你刚刚修复好 Bug 的真正业务代码订阅源改成Topic_New。然后痛痛快快地部署40 台消费者机器战果验收原本的 4 个口子通过“临时搬运工”这个漏斗瞬间被放大成了 40 个口子。 40 台机器同时发力火力全开原本需要 3 小时才能消化完的 1000 万积压短短十来分钟就被彻底吞噬殆尽。危机解除。 事后等凌晨流量低谷再把代码改回旧的 Topic平滑切回日常架构。 灵魂拷问为最终章埋下天坑读到这里你已经陪我走过了 MQ 的削峰、防丢、幂等防重、以及今天的乱序与积压救火。 我们在并发架构的泥潭里摸爬滚打看似已经堵住了所有的漏洞。但是只要你还在写分布式的代码就永远绕不开一个最深不见底的黑洞。回想一下你最核心的“扣款下单”代码JavaTransactional public void createOrder() { // 1. 本地数据库扣钱 db.updateBalance(); // 2. 发送 MQ 消息给物流系统发货 mq.send(发货消息); }你以为加了Transactional就万事大吉了假设先写 DB 后发 MQDB 写成功了正准备发 MQ 时服务器宕机了。钱扣了消息没发出去货没发。假设先发 MQ 后写 DBMQ 发送成功了物流系统已经把货发出去了。但本地写 DB 时触发了唯一约束冲突DB 回滚了。货发出去了钱没扣