我在很多技术评审会上见过一个奇怪的现象:业务请求量每天才几万,系统也只有一个实例,但一聊到某些异步任务,第一反应就是“要不要上消息队列”。其实消息队列(message queue)不是装饰品,它解决的是确定场景下的确定问题:异步解耦、流量削峰、可靠传递。反过来说,很多系统真正引入消息队列之后,问题不但没有变少,反而多了一堆“消息丢失了怎么办”“重复消费了怎么办”“消费积压了怎么办”的烦恼。
这篇文章我会从实际经历出发,把消息队列最核心的东西掰开讲清楚。不回避原理,也不堆概念,重点放在:为什么业务需要队列、队列的内部机制是什么、怎么用 Spring Boot 接一个能跑的 Redis Stream 示例、重复消费和延迟队列怎么处理,以及面试和实战里绕不开的丢消息、乱序和积压三大话题。无论你是在做单体架构改造、准备面试,还是已经在上消息队列但老觉得不稳,这篇文章都适合你慢慢读。
1. 中间件还能扛得住,为什么业务还是需要消息队列
很多开发者在系统还很小时不理解消息队列的价值,因为同步调用看起来没啥问题:用户注册,我把账号插入数据库,然后调用短信服务发一条欢迎短信,调用邮件服务发一封激活邮件,再调用积分服务送一点新人积分。外部接口慢一点,用户等着就好,反正也就多几百毫秒。
但真实的业务增长从来不跟你商量。短信服务如果是一次第三方 HTTP 调用,网络抖动、对方服务超时、回调延迟都会直接拖垮你的注册接口;积分服务一旦出现慢 SQL,整个链路跟着遭殃;更别提秒杀场景,流量瞬间是平时的几十倍,如果所有请求都同步打到数据库,系统基本当场就垮。
消息队列在这个位置发挥的作用非常具体:
- 异步化:用户只需要关注“注册成功”这个核心结果,发短信、发邮件、加积分这些动作全部丢到队列里慢慢执行;
- 削峰:瞬时流量先进队列,消费者根据自己的处理能力源源不断去拉,数据库永远不会被瞬间流量冲垮;
- 解耦:订单系统和库存系统不再需要互相知道对方的存在,订单完成就发一条消息,谁关心谁订阅。
举一个我优化过的例子。注册接口原本的调用链路是:落库 300ms,短信服务 500ms,邮件服务 800ms,新人积分 300ms,总响应时间接近 1.9s。把短信、邮件、积分全部改成投递到消息队列后,核心链路就只剩下落库这一步,响应时间直接降到 400ms 以内,而短信和邮件后台照常发送,用户完全感知不到差别。
这就是消息队列的核心价值:它不是用来炫技的中间件,而是换个方式思考“哪些事情必须现在做,哪些事情可以等一下做”。这里的“等一下做”不是延迟,而是交给别人异步做。
但必须承认,引入消息队列不是零成本的。机器、运维、监控、告警、消息重复处理、消费失败重试,这些都是成本。所以接下来我要讲的不是“无脑上队列”,而是当你真的决定使用队列之后,有哪些机制必须想清楚,以及写代码时最容易踩哪些坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂队列里的七件事,再动手写任何代码
我看到很多年轻同学写消息队列代码,流程就是找个 Demo,生产者往队列里塞一条 JSON,消费者收到后打一行日志,然后就以为消息队列学完了。一旦生产环境出现丢消息、重复消费、顺序错乱,立刻懵了。只了解 API、不理解机制,是消息队列使用者的通病。
在写代码前,请把下面七个机制刻在脑子里。这七个东西比任何 API 都重要。
2.1 点对点与发布订阅
点对点模型好理解:一条消息只会被一个消费者取走,取走后消息就没了。它适合任务分配,比如“有一批邮件要发送,消费者集群一起抢活,每个消费者处理一部分”。
发布订阅模型则是广播:一条消息被所有订阅者都收一遍。它适合事件通知,比如订单创建后,积分服务、短信服务、搜索服务都想知道这件事。这个场景如果用点对点模型,只能给每个服务各发一份,代码和维护成本都会比较高。
很多队列同时支持两种模型。Kafka 的 topic 和消费者组就是一种组合:同一个消费者组内部是竞争消费(点对点),不同消费者组之间是广播订阅(发布订阅)。RabbitMQ 的 exchange 模式和 Redis Stream 的消费者组也有类似的逻辑。搞混了模型,消息就会“发不出去”或者“大家都抢着消费”。
2.2 消费者组为什么能水平扩展
消费者组是队列的核心抽象。它代表一组共同消费某个队列的处理节点。同一组内,一条消息只会分给其中一个消费者,分给谁由队列的负载均衡策略决定。这样做的直接好处是:一个消费者处理不过来,加机器即可,消费者组自动做拆分。
Kafka 里组内消费者数量和分区数量存在强相关:理想情况下一个分区同时只有一个消费者在处理,如果消费者数量超过分区数,多出来的消费者只能闲着。原因很简单,同一个分区内的消息必须保证顺序,如果同一个分区的消息被多个消费者同时抢,顺序就没法保证了。很多人扩容后仍然消费不过来,就是没理解这个约束。
2.3 消息确认和 at least once
消费者拿到消息并处理完毕后,必须给队列一个明确信号:“我处理好了”,这个信号就是 ack。队列收到 ack 后才算这条消息被消费完成,否则它会觉得消费者处理失败,然后把消息重新投递。
这里的“否则就会重投”是一把双刃剑。它在保证消息不丢失的同时,制造了重复消费的可能。几乎所有主流消息队列在默认语义下都是 at least once:消息不丢,但可能重复。严谨地去重和幂等,是消费者拿消息后必须做的事。
2.4 持久化与重试
队列通常会提供持久化选项。Redis Stream 依赖 RDB/AOF 持久化,RabbitMQ 的消息可以标记为持久化,Kafka 通过分区日志落盘。如果关掉持久化,队列重启后消息会丢。很多中小团队在测试阶段图省事,生产环境也用默认配置,直到队列宕机才发现消息全部蒸发。
消费失败的重试也很关键。失败消息不能无限循环消费,否则会卡住后面的消息;也不能没有任何限制地重投,否则失败消息频繁重复会让业务不断重复做无效动作。比较实用的做法是:配置有限次失败重试,超过阈值后转入死信队列,由人工或专门进程处理。
2.5 顺序性
队列只能保证它内部存储的先后顺序,不能保证消费者处理结果一定按这个顺序落地。一个订单的“支付成功”和“关闭订单”两条消息如果被不同消费者抢走,可能出现“关闭”先执行,“支付成功”后执行,最终把支付流水给冲掉。
保证顺序的通用方法只有一个:把需要保持顺序的消息路由到同一个分区/队列,并且这个分区/队列只被一个消费者顺序消费。不要一边谈多线程消费带来吞吐提升,一边又要求绝对顺序,这两个需求在单条消息层面天然矛盾。
2.6 死信队列
死信是重试到极限仍失败的消息的最终去处。它不是一个花哨功能,而是生产队列必须有的“保险丝”。人为开发的消息队列系统,一旦没有死信机制,失败消息会一直重试,日志里全是错误,磁盘和 CPU 白忙,而业务早就不正常了。
2.7 回溯能力
Kafka 这类基于日志的队列支持按 offset 重新消费,Redis Stream 也支持从指定 ID 重新读。生产环境经常出现“修复 bug 后要把过去一小时的消息重新消费一遍”的情况,没有回溯能力就只能干瞪眼。
把上面七个机制放在一起看,你会发现自己需要的根本不是“把消息从 A 搬到 B”,而是一套关于可靠性、并发、顺序、失败处理的完整设计。后面所有实战代码,都是围绕这套设计展开的。
3. Spring Boot + Redis Stream:一个能落地的消息队列示例
技术选型时很多人会纠结:用 Kafka?RabbitMQ?RocketMQ?还是用 Redis Stream?我的建议是:如果你们团队已经把 Redis 作为基础依赖,消息量在每天百万级以内,对高级特性要求不高,Redis Stream 是一个非常好的起步选择。如果消息量达到千万级以上,对事务、死信、消息轨迹、顺序有硬性要求,再考虑上 RabbitMQ 或 Kafka。
为了让你看到一整套可运行的消息队列设计流程,这一节我带着你用 Spring Boot 3.x + Redis Stream 实现订单创建后的异步处理场景。生产端提交订单,消费端监听“订单创建成功”事件,执行异步加分、发送通知等操作。
3.1 为什么示例选了 Redis Stream 而不是 RabbitMQ 或 Kafka
我知道一定有人会质疑:Redis 还能当消息队列用?
先看数据:Redis Stream 是 Redis 5.0 引入的数据结构,6.x 已经相当成熟。它天然经历了 Redis 自身的单线程高性能验证,支持持久化,支持消费者组模式,操作接口也足够简洁。对一个日活不高、业务消息量平稳的项目,它完全能扛住。
再看成本:很多团队没有专职运维消息中间件的人,部署 Kafka 至少三节点,磁盘、内存、监控、告警全都要配,出问题
