最近看了很多闪客老师的破玩意系列文章,借助AI模仿他的风格写了这篇文章。
为了讲明白消息队列(Message Queue,简称 MQ),咱们今天必须得跟个风,也用“拉踩”的思路,先把那个“没头脑”的传统架构拉出来鞭尸一顿,才能捧出我们消息队列这位“高富帅”到底帅在哪。
为了方便理解,以下所有代码都是伪代码,你懂那个意思就行。
Let's go
一根筋的同步调用
假设你是个电商平台的架构师,现在要实现一个用户下单的功能。产品经理屁颠屁颠跑过来说:“用户点击‘下单’后,我们要:1. 创建订单;2. 扣减库存;3. 通知物流发货;4. 给用户加积分。”
于是,你大笔一挥,写下了如下代码,逻辑清晰,简单粗暴。
function placeOrder(orderInfo) {
createOrder(orderInfo); // 1. 创建订单
decreaseStock(orderInfo); // 2. 扣减库存
notifyShipping(orderInfo); // 3. 通知物流
addPoints(orderInfo); // 4. 增加积分
return "下单成功!";
}
这段代码执行起来,就像一列绿皮火车,一站一站地停,一站不到,下一站就别想走。
(脑补一个任务条按顺序依次加载的动图)
这套操作,我们叫它同步调用。看起来很美,但实际上处处是坑。
慢得要死:四个步骤加起来,总耗时 T = T1+T2+T3+T4。万一那个“加积分”的服务今天闹脾气,响应慢了3秒,那用户就得在屏幕前干等3秒,心里一万头羊驼奔腾而过,最后可能直接关了App。
脆弱得像玻璃:如果“通知物流”的系统突然宕机了,
notifyShipping
调用失败,整个下单流程就得回滚。用户看到的是“下单失败”,但其实订单创建和库存扣减可能都成功了。这就造成了数据不一致,得靠程序员半夜起来手动补数据。
这肯定是不行的。
耍小聪明的异步线程
为了解决上面的问题,你灵机一动:不是所有步骤都那么重要嘛!创建订单和扣库存是核心,必须同步完成。但通知物流和加积分,晚个一两秒天塌不下来。
于是你耍了个小聪明,把非核心的逻辑扔到新线程里去跑。
function placeOrder(orderInfo) {
createOrder(orderInfo); // 1. 创建订单(核心)
decreaseStock(orderInfo); // 2. 扣减库存(核心)
// 创建新线程去干剩下的活
new Thread(() -> {
notifyShipping(orderInfo); // 3. 通知物流
addPoints(orderInfo); // 4. 增加积分
}).start();
return "下单成功!"; // 立刻返回给用户
}
现在,用户体验起飞了!只要核心流程走完,页面马上就显示“下单成功”。用户爽了,老板也给你点了个赞。
(脑补一个主线任务迅速完成,旁边分出两个小任务在后台慢慢跑的动图)
不过,这不叫真正的架构升级,这只是你作为应用层程序员的“小把戏”。问题依然存在:
消息可能会丢:如果你的服务器在启动新线程后,还没来得及执行里面的代码就挂了(比如断电、OOM),那这次“通知物流”和“加积分”的操作就永远地石沉大海了。
把服务器搞垮:如果搞个“双十一”大促,瞬间进来1万个订单,你就要创建1万个线程。操作系统线程资源是有限且宝贵的,这么搞下去,服务器很快就会因为资源耗尽而崩溃。
所以,真正的解决方案,不能靠我们用户层的小把d戏,而是要恳请架构层给我们提供一个专门处理这种“把事情往后稍稍”的专业组件。
消息队列闪亮登场
为每个请求都开个线程,服务器资源很容易被耗光。
(脑补一个服务器被无数请求线程压垮的动图)
这个“专业组件”,就是消息队列(Message Queue)。
你可以把它想象成一个开在你们公司前台的“任务投递箱”。
下单服务现在的工作变得超级简单:完成核心操作后,写两张“待办事项”的小纸条(也就是“消息”),一张写着“给这个订单发货”,另一张写着“给这个用户加积分”,然后把这两张纸条往“任务投递箱”(消息队列)里一扔,就可以直接告诉用户“搞定了”,然后潇洒下班。
// 生产者 (Producer)
function placeOrder(orderInfo) {
createOrder(orderInfo);
decreaseStock(orderInfo);
// 把消息扔进消息队列
messageQueue.send("shipping_topic", orderInfo);
messageQueue.send("points_topic", orderInfo);
return "下单成功!";
}
而物流系统和积分系统,就像是两个专门守在“任务投递箱”旁的员工,他们会不断地从箱子里取出属于自己的待办纸条,然后按部就班地去执行。
// 消费者 (Consumer) - 物流系统
while(true) {
message = messageQueue.listen("shipping_topic");
processShipping(message);
}
// 消费者 (Consumer) - 积分系统
while(true) {
message = messageQueue.listen("points_topic");
processPoints(message);
}
你看,这就实现了三个核心目标:
异步(Asynchronous):下单服务扔完纸条就走人,响应速度极快。
解耦(Decoupling):下单服务根本不关心物流系统和积分系统是死是活。就算积分系统宕机一整天,下单服务也照常运行,等积分系统恢复了,它会自己去“任务投递箱”里把积压的纸条拿出来处理掉。
削峰填谷(Peak Shaving):“双十一”大促,瞬间来了100万个订单,下单服务就疯狂往“任务投递箱”里扔100万张纸条。箱子堆成山也没关系,下游的物流和积分系统可以根据自己的处理能力,慢慢地、平稳地从箱子里取纸条来处理,不会被瞬间的洪峰流量冲垮。
(脑补一个生产者疯狂投递,队列堆积,消费者匀速消费的动图)
这,就是消息队列的威力。它不是什么魔法,而是一种专业的分工思想,把“立即要做的事”和“可以稍后做的事”分离开。
究极进化体 Kafka
你以为有个“任务投递箱”就万事大吉了?天真!
普通的投递箱(比如早期的 ActiveMQ、RabbitMQ 的某些模式)有几个问题:
纸条拿走就没了:物流系统把纸条拿走处理了,这时候财务部门也想看看发货记录来对账,怎么办?没门,纸条已经被拿走了。
箱子容量有限:如果纸条太多,箱子满了怎么办?
可靠性问题:如果管理员把整个投递箱不小心弄丢了,所有没处理的纸条就全没了。
为了解决这些问题,终极大 Boss Kafka 出现了。
别把 Kafka 想成一个简单的“投递箱”,你应该把它想象成一个“银行的流水账本”或者一个“无限长的公告栏”。
数据不消失(持久化):生产者不是“投递”纸条,而是把“事件”记录到这个账本上。消费者也不是“取出”纸条,而是“读取”账本的某一页。物流系统读完了,积分系统还能来读,财务系统也能来读,大家互不干扰。数据被持久化存储在磁盘上,非常可靠。
消费位点(Offset):每个来读账本的消费者(比如物流系统),自己手里都有个书签,记录着“我读到第几页了”。这个书签,在 Kafka 里叫 Offset。下次再来,从书签的地方继续读就行了。不同的人(消费组)可以有自己的书签,读到哪互不影响。
分区(Partition):为了让读写速度更快,Kafka 把一个大的账本(Topic),分成了好几个子账本(Partition),可以并行读写,吞吐量极高。这就好比银行开了好几个窗口,可以同时办理业务。
整个 Kafka 的工作流程,就像下面这样丝滑。
(脑补一个数据流式写入Partition,多个Consumer Group从不同Offset独立消费的动图)
Kafka 之所以快得变态,除了分区并行处理,还有一个绝活叫零拷贝(Zero-copy)。就像文章开头讲 IO 多路复用一样,性能的极致优化,最终都要深入到操作系统层面。Kafka 直接利用操作系统的能力,把数据从磁盘文件直接发送到网卡,避免了在内核和用户态之间的来回复制,大大提升了效率。
后记
大白话总结一下。
一切的开始,都起源于服务之间一根筋的同步调用,又慢又脆弱。
为了破这个局,程序员在应用层通过异步线程来耍小聪明,但这既不可靠也扛不住高并发。
后来,架构师们发现这个需求是刚需,于是设计出了消息队列这个专业组件,实现了异步、解耦和削峰填谷。
但简单的消息队列在多系统消费、数据持久化等方面有不足,于是究极进化体 Kafka 诞生了,它用“日志流”的思想,配合分区、位点和零拷贝等技术,成为了大数据领域消息传递事实上的标准。
所以,架构的演进,其实就是业务场景的变化,倒逼着我们用更专业、更强大的工具去解决问题。
如果你建立了这样的思维,就很容易发现网上的一些错误说法。
比如好多文章说,消息队列就是为了“削峰填谷”。
这显然是知其然而不知其所以然。削峰填谷只是消息队列带来的一个效果。它最核心的价值在于“解耦”,让系统与系统之间不再是强依赖的“铁板一块”,而是可以独立演化、独立伸缩、独立部署的“积木”,这才是构建大型分布式系统的基石。
就好比我们平时写业务代码,把一个巨大的 if-else
坨坨代码,重构成多个独立的策略模式实现。
一个道理。