消息队列:为什么要 MQ,以及丢失、重复、顺序怎么破
下单成功后,要发短信、加积分、发优惠券、通知物流……如果一件件同步串着做,用户得干等到全部做完;更糟的是,只要积分服务挂了,整个下单就跟着失败——明明钱已经付了。
消息队列(MQ)就是用来拆掉这种"强耦合 + 同步等待"的。这篇讲清楚:MQ 到底解决什么、怎么用、以及绕不开的三个问题——消息丢失、重复、顺序怎么破。配套一个零依赖 demo,
node demo.js一跑就能看到每个机制的效果。
一、MQ 到底解决什么
很多人对 MQ 的第一印象是"一个传消息的中间件",但说不清它到底带来什么。其实就三个核心价值,对着真实场景看一遍就懂了。
① 解耦:下单只管发消息,谁消费它不关心
回到开头的下单。同步串行的写法是这样:
下单 → 调短信服务 → 调积分服务 → 调发券服务 → 返回成功
(任意一步挂,整个下单失败)
下单这段代码,得知道下游有哪些服务、按什么顺序调、每个怎么调。哪天新增一个"下单送成长值",还得回来改下单的代码。这就是强耦合。
换成 MQ:下单做完核心的事(扣库存、生成订单),只往队列里发一条"订单已创建"消息,然后立刻返回成功。短信、积分、发券各自去队列里订阅这条消息,自己处理自己的。
下单服务从此不认识任何下游。 新增一个下游?让它去订阅就行,下单代码一个字不用改;某个下游挂了?消息还堆在队列里,等它恢复了接着消费,下单完全不受影响。
一句话点破:解耦的本质,是把"我要主动去调用谁"变成"我只发一个事件,谁关心谁来订"。
② 异步削峰:把瞬时洪峰先堆进队列,下游按自己节奏处理
秒杀开始的那一秒,可能涌进十万个下单请求。如果每个请求都同步地把短信、积分、发券都做完,这些下游(尤其是发短信这种依赖外部网关的)根本扛不住瞬时洪峰,直接被打挂。
MQ 在中间当了一个蓄水池:洪峰来了,消息先快速堆进队列(入队很轻,几乎不耗时),下游消费者按自己能处理的速度慢慢取、慢慢做。十万条消息可能要几分钟才消费完,但下游始终在安全水位运行,不会被冲垮。
这就是削峰填谷:把"一瞬间的高峰"摊平成"一段时间的平稳流量"。代价是下游处理有延迟(发短信可能晚几秒到),但对这类非核心、能容忍延迟的操作,完全划算。
③ 广播:一条消息,多个系统各取所需
同一条"订单已创建",可能有一堆系统都关心:积分要加分、风控要记录、数据团队要统计、推荐系统要更新画像……
同步写法里,下单得挨个去调它们,列表越来越长。用 MQ 的发布/订阅模式:下单只发一次,所有订阅了这个 topic 的系统各收到一份,各干各的,互不影响。新增一个消费方,对生产者透明。
这三个价值往往是一起来的:一条订单消息,既解耦了下单与下游(①),又削峰扛住了洪峰(②),还广播给了多个系统(③)。
二、怎么用:生产者 → broker → 消费者
MQ 的基本模型就三个角色,很好记:
生产者(Producer) → broker(队列/服务端) → 消费者(Consumer)
发消息 存消息、转发 取消息、处理
- 生产者:发消息的一方(下单服务)。
- broker:MQ 的服务端,负责接收、存储、投递消息——它是整个系统的核心。
- 消费者:取消息处理的一方(积分服务)。
几个绕不开的概念
| 概念 | 是什么 | 一句话理解 |
|---|---|---|
| topic(主题) | 消息的分类 | 一类业务一个 topic,如 order.created |
| partition(分区) | 一个 topic 拆成的多条并行队列 | 分区越多,能并行消费的吞吐越高 |
| consumer group(消费组) | 一组协作消费同一 topic 的消费者 | 组内分摊消息(每条只给组里一个),不同组各收一份(广播) |
partition 和 consumer group 是理解吞吐与顺序的关键。 一个 topic 分成多个 partition,就能让多个消费者并行处理(每人盯几个分区),吞吐随之提升;而"同一组内每条消息只投给一个成员、不同组各收一份"——前者实现了负载分摊,后者实现了广播(第一节的 ③ 就靠它)。
主流 MQ 一句话区别
选型时大致这么记(都能做基本的队列,差异在侧重点):
| MQ | 一句话定位 | 典型场景 |
|---|---|---|
| Kafka | 高吞吐的分布式日志流 | 海量日志 / 数据管道 / 流处理 |
| RabbitMQ | 灵活路由、协议成熟 | 复杂的路由分发、传统业务异步化 |
| RocketMQ | 强在事务消息、金融级 | 电商交易、要"消息和本地事务一致"的场景 |
记不住也没关系——它们的核心模型和下面要讲的三个问题是相通的,搞懂一个,换另一个主要是 API 和运维细节的差异。
重点说说 Kafka
三个里 Kafka 最主流,也最该单独理解——因为上面讲的 topic / partition / consumer group 这套术语,本就源自 Kafka(所以你搞懂这篇,Kafka 的骨架已经会了一大半)。
它和传统队列最大的不同:Kafka 本质是一份"日志(log)",不是"用完即焚的队列"。
- 传统队列(如 RabbitMQ):消息被消费、确认后就删掉。
- Kafka:消息读完不删,在磁盘上按顺序保留一段时间(比如 7 天)。每个 partition 是一条只能往末尾追加的有序日志,每条消息带个序号 offset;消费者自己记"我读到哪个 offset 了"。
这带来一个杀手锏——可回放:想重读历史,把 offset 往回拨就行;新接入一个消费方,能把过去的消息从头重放一遍。所以 Kafka 特别适合海量日志 / 埋点收集、数据管道(源源不断把数据送进数仓)、事件流、配 Flink / Spark 做实时计算。
它为什么能扛到每秒几十万上百万条(高吞吐从哪来):
- 顺序写磁盘:消息只往日志末尾追加,顺序写磁盘极快(比随机写快几个数量级,接近内存速度)——反直觉但关键。
- 分区并行:一个 topic 切多个 partition,多机器、多消费者并行读写,吞吐随分区数扩展。
- 批量 + 零拷贝:批量收发 + 操作系统零拷贝,数据直接从磁盘送到网卡,省掉多次内存拷贝。
一句话:Kafka 把消息当"可重读的日志流"来存,所以它高吞吐、能回放,是大数据 / 流处理场景的事实标准;而 RabbitMQ 那种"确认后即删"的队列,更适合传统业务的异步解耦、灵活路由。两者模型不同,别套混。
真要用,怎么搭和接入
绝大多数情况你不用自己从零搭 MQ 集群,按规模大致是:
- 本地开发:用 Docker 起一个单机的 Kafka 或 RabbitMQ,连着调试就行。
- 中小公司 / 创业:直接用云托管最省事——阿里云有消息队列 Kafka 版 / RabbitMQ 版 / RocketMQ 版,AWS 有 MSK(Kafka)、Amazon MQ(RabbitMQ)、SQS(自家队列)。控制台开个实例、拿到接入点和鉴权就能用,扩容和高可用云厂商负责。
- 大公司:通常有专门的中间件团队把集群部署、运维好,业务方只需申请一个 topic / 队列 + 接入点 + 权限接入,不碰底层。
应用层接入,各主流后端语言(Java、Go 等)都有成熟的客户端库,配好接入点、主题、消费组就能收发。两种模型接入时的关注点不同:
- Kafka:生产时把消息发到某个 topic,用一个 key(如订单号)决定落到哪个分区(保序就靠它);消费时加入一个 consumer group,按 offset 拉取。
- RabbitMQ:生产时把消息发到 exchange(交换机),由它按路由规则分发到一个或多个队列;消费时从队列取、逐条确认(ack)。多出来的这层 exchange,就是 RabbitMQ"灵活路由"的来源。
一句话:搭法看规模(本地 Docker / 云托管 / 公司平台),接入看模型(Kafka 面向 topic + key,RabbitMQ 面向 exchange + 路由规则)——但底层要解决的,还是前面那三个问题。
三、三个必须搞懂的问题
用 MQ,这三个问题躲不掉:消息会不会丢、会不会重复、能不能保证顺序。面试常问,线上更常踩。一个个拆。
问题一:消息丢失——三个环节都可能丢
一条消息从生产者到被消费,要走三段路,每一段都可能丢:
生产者 ──①──> broker ──②──> (持久化) ──③──> 消费者
- ① 生产端丢:消息发出去了,但网络抖动,根本没到 broker,生产者却以为成功了。
- ② broker 丢:消息到了 broker,但还存在内存里没落盘,broker 这时宕机,消息没了。
- ③ 消费端丢:消费者把消息取走了,还没处理完(或处理了一半)就崩了,但 broker 那边已经把这条消息标记为"已消费"——于是它既没处理成功,又不会被再投递,丢了。
对策,正好对着三个环节:
| 环节 | 怎么防丢 |
|---|---|
| ① 生产端 | 开启 confirm / ack:broker 真正收下并落盘后才回确认,没收到确认就重发 |
| ② broker | 持久化(消息落盘)+ 多副本:存到多台机器,挂一台不丢 |
| ③ 消费端 | 处理完再 ack:业务真正做完了,才告诉 broker"这条我消费好了";没 ack 的消息会被重新投递 |
第三点最容易写错。很多人图省事用"自动 ack"(取到消息就自动确认),消费逻辑还没跑完就 ack 了,一崩就丢。正确姿势是手动 ack:处理 → 成功 → 再 ack。
一句话点破:防丢失的核心,是把"确认"卡在"真正做完"之后——生产端等 broker 落盘的确认,消费端等业务处理完才确认。
demo 场景二演示的就是消费端这一环:消费者"处理中崩溃"(没 ack),消息被重投,直到处理成功才 ack——这就是"消息不丢"。但它带来下一个问题。
问题二:重复消费——靠"至少一次"投递,就会重复
注意上面防丢的代价:消息没 ack 就重投。可"没收到 ack"有两种可能——消费者真没处理,或者处理完了但 ack 在回去的路上丢了。broker 分不清这两种,为了不丢,它只能选择重投。
于是主流 MQ 的投递语义是 at-least-once(至少一次):宁可重复,绝不丢失。这意味着同一条消息可能被消费多次(网络重试、ack 丢失、消费者重启都会触发)。
如果消费逻辑是"加 100 积分",重复消费两次就加了 200——错了。
对策:幂等。 让消费逻辑"执行一次和执行多次,结果一样"。最常用的做法:
- 给每条业务消息带一个唯一业务 id(订单号、事件 id);
- 消费时先查"这个 id 处理过没":用一张去重表(数据库唯一索引)或 Redis 记录已处理的 id;
- 处理过了就直接跳过(但仍要 ack,告诉 broker 别再投了)。
数据库的唯一索引是最稳的兜底:插入去重记录时,重复的那条会因为唯一键冲突直接失败,天然挡住重复。
一句话点破:既然 MQ 给的是"至少一次",消费端就必须做幂等——这不是可选项,是 at-least-once 的配套义务。
demo 场景三把这事说透了:同一条消息投递两次,不做幂等积分加成了 100(错,应是 50);用去重表记下业务 id 后,第二次直接跳过,只生效一次(对)。
问题三:顺序——全局不保证,同一类才能有序
"创建订单 → 支付 → 发货"这三条消息,必须按这个顺序处理,不然就乱套(还没创建就发货?)。但 MQ 默认不保证全局顺序——为什么?
因为吞吐。一个 topic 拆成多个 partition,就是为了让多个消费者并行处理。可一旦并行,不同分区谁先处理完就不确定了:三条消息散到三个分区,各分区的消费者各跑各的,合起来的完成顺序自然可能乱。顺序和并行,本质上是矛盾的。
对策:不追求全局有序,只保证"需要有序的那一类"有序。
- 把需要保序的消息路由到同一个分区:用同一个 key(比如订单号),MQ 按 key 哈希,同 key 必落同一分区;
- 同一分区单线程按序消费:一条处理完才取下一条,顺序就稳了。
代价是:同一个 key 的消息只能串行处理,牺牲了这一个 key 内部的并行度。但不同 key(不同订单)之间仍然是并行的,整体吞吐基本不受影响——因为你只对"同一订单"要求有序,不同订单本来就互不相干。
一句话点破:顺序的解法不是"让全局有序"(那等于放弃并行),而是"把要有序的归到同一分区,串行消费",其余照常并行。
demo 场景四直接对比:三条消息散到多个分区并行消费,顺序乱成了"发货→支付→创建";用同一个订单号当 key 路由到同一分区后,严格还原成"创建→支付→发货"。
四、什么时候别用 MQ
MQ 不是免费的。引入它,等于给系统多加了一个要独立运维的中间件,还把原本"一步到位"的同步调用,变成了最终一致(下游晚一会儿才处理完),并且强制你处理上面三个问题(丢失、重复、顺序)。这些复杂度都是实打实的。
所以这些情况别急着上 MQ:
- 系统简单、调用链短:就一个下单加一个加积分,直接同步调用更清晰,引入 MQ 是徒增复杂度。
- 需要同步拿到结果:比如下单要实时返回"积分加成功了吗",异步化反而别扭(MQ 擅长的是"发完不管")。
- 强一致要求高、且容不下延迟:虽然有事务消息能缓解,但如果业务本身简单到没有解耦/削峰需求,上 MQ 得不偿失。
上 MQ 前先问三句:有下游需要解耦吗?有瞬时洪峰要削峰吗?这步操作能接受异步(晚点做完)吗?——有 yes 才值得引入。
一句话点破:MQ 解决的是"解耦、削峰、广播";如果你的系统没有这三个痛点,它带来的复杂度就是纯负担。
五、一张表:场景 → 用 MQ 的哪个能力
把全文对到真实场景,一张表带走:
| 业务场景 | 用 MQ 的哪个能力 | 为什么 |
|---|---|---|
| 下单后发短信 / 加积分 / 发券 | 解耦 | 下单不该被非核心下游拖累、绑死 |
| 秒杀瞬时十万下单 | 削峰 | 把洪峰堆进队列,下游按自己节奏消化 |
| 一条订单事件,多系统都要 | 广播 | 发一次,风控 / 积分 / 数据各收一份 |
| 上传视频后转码 / 截图 / 审核 | 解耦 + 削峰 | 耗时操作异步做,不阻塞上传响应 |
| 同一订单"创建→支付→发货"保序 | 解耦 + 同 key 分区 | 用订单号当 key,同分区串行,保证有序 |
| 需实时返回结果的查询 | 别用 MQ | 同步调用更直接,异步反而别扭 |
名词解释
- MQ(消息队列):在生产者和消费者之间传递消息的中间件,实现异步、解耦、削峰。
- 生产者 / 消费者(Producer / Consumer):发消息的一方 / 取消息处理的一方。
- broker:MQ 的服务端,负责接收、存储、投递消息,是整个系统的核心。
- topic(主题):消息的分类,一类业务一个 topic。
- partition(分区):一个 topic 拆成的多条并行队列;分区内有序,分区间并行。
- offset(偏移量):消息在一个 partition 内的顺序序号;消费者靠记录自己的 offset 标记"读到哪了",把 offset 往回拨即可重读历史(Kafka 的"回放")。
- consumer group(消费组):协作消费同一 topic 的一组消费者;组内分摊消息,不同组各收一份(广播)。
- 日志型 MQ(如 Kafka):把消息当成"只追加、读完不删、可按 offset 回放的日志"来存,区别于"确认后即删"的传统队列(如 RabbitMQ)。
- ack(确认):消费者告诉 broker"这条我处理好了";没 ack 的消息会被重新投递。
- at-least-once(至少一次):主流投递语义,保证不丢,但可能重复,因此消费端要幂等。
- 幂等(Idempotent):同一操作执行一次和执行多次,结果一样;靠唯一业务 id + 去重表实现。
- 削峰填谷:把瞬时高峰流量堆进队列,让下游按平稳速度处理,保护下游不被冲垮。
- 死信队列(DLQ):重试多次仍处理失败的消息被丢进来的特殊队列,供人工排查,避免无限重投。
- 事务消息:保证"发消息"和"本地数据库事务"要么都成功、要么都不发,解决两者不一致的问题。
配套 demo:backend-notes/02-features/message-queue/mq-demo ——
node demo.js一跑,四个场景直观看到:解耦(下单发完即返回)、防丢(崩溃没 ack 则重投,最终不丢)、幂等(同消息投两次,有无去重表差出一倍积分)、顺序(多分区乱序 vs 同 key 同分区严格有序)。零依赖,跑完即退出。本文属《研发都要懂的事》·「功能怎么实现」专题。完整代码与系列在 GitHub · backend-notes。
评论(0)
登录后参与评论。
还没有评论,来抢沙发吧。

