☰
消息中间件选型指南:Redis List、RabbitMQ、RocketMQ与Kafka对比
2026/10/18 7:06:34 网站建设 项目流程

做后端开发这几年,消息中间件是绕不过去的一道坎。前阵子帮朋友复盘一个订单超时未支付自动关单的场景,他们居然一边用 Redis List 塞消息,一边用定时任务扫表,两边数据对不上,乱成一团。这类问题我见过太多次了。Redis List、RabbitMQ、RocketMQ、Kafka 这四个名字,单拎出来每个都能找到大把教程,但很少有人把它们的底层逻辑、适用场景、坑点放在一起讲清楚,导致很多人选型全靠"听说"两个字。这篇文章我想换个思路,不贴一堆概念名词,而是从"解决什么问题"出发,把这四个消息中间件从设计初衷到核心机制,再到实操踩坑,一层层剥开。适合刚接触消息队列的后端同学,也适合已经在用但总被各种怪异现象困扰的开发者,看完你应该能建立一张完整的选型地图。

想搞清楚这四个中间件,先别急着看性能对比表。Redis List 本质是个数据结构,RabbitMQ 是个完整的 AMQP 协议实现,RocketMQ 是电商场景磨出来的消息引擎,Kafka 是海量日志管道的事实标准。它们的出身决定了它们擅长什么、不擅长什么。理解了这一层,后面的技术细节才有落脚点。

1. 四个消息中间件到底在解决什么问题

1.1 从一个订单超时场景说起

先还原一个很常见的业务场景:用户下单后,系统需要在 30 分钟内检测订单是否支付,超时未支付就自动取消,释放库存。最简单粗暴的写法是起一个定时任务,每分钟扫描一次订单表,把超过 30 分钟未支付的订单找出来关掉。这个方案在小流量下能跑,订单量一上来就顶不住了:扫描全表越来越慢,任务执行时间越来越长,两次扫描之间还可能跨过业务高峰期,关单不及时,用户体验变差,库存释放延迟。

如果引入消息队列,场景就变成:下单成功后,往队列里塞一条"延迟关单"的消息,消息中间件在 30 分钟后把它投递给消费者,消费者取出消息执行关单操作。这个过程本质上是把"定时轮询找活干"变成了"消息主动找消费者",从被动扫描变成主动驱动。这就是消息中间件最朴素的价值:让业务的触发方式从"拉"变成"推"。

实际上,消息中间件解决的远不止定时任务这一件事。把它放到整个分布式系统里看,核心价值集中在三个词上:解耦、削峰、异步。

1.2 消息中间件的三个价值:解耦、削峰、异步

解耦最好理解。订单系统下单成功后,要通知积分系统加积分、通知短信系统发通知、通知风控系统做校验。如果不走消息队列,订单系统就要在代码里显式调用这三个系统的接口,每接入一个下游系统,订单系统就要改一次代码。下游系统挂了,订单系统还要考虑重试、降级,耦合度极高。引入消息队列后,订单系统只管往队列里发一条"订单创建成功"的消息,下游系统各自订阅自己关心的消息,互不干扰。订单系统不需要知道下游有哪些系统,下游系统的变更也不影响订单系统。

削峰是另一个刚需。假设一个秒杀系统的峰值 QPS 是 2 万,数据库能承受的写入 QPS 撑死到 2000,直接同步写库,数据库瞬间被打爆。加一层消息队列后,前端请求先把"秒杀资格"消息写入队列,后端服务按照自己能承受的速度从队列里消费、落库。队列就像一个缓冲池,把波峰拉平,保证系统不会因为瞬间流量崩溃。

异步解决的是响应时间问题。用户下单后,如果要在下单接口里同步完成扣库存、生成物流单、发短信、更新用户积分这一整套流程,接口响应时间可能从 20ms 涨到 800ms。这些操作里很多根本不需要用户在前端等待,完全可以发完消息就返回"下单成功",后面的事让消费者慢慢去做。用户体验提升了,系统的吞吐量也上去了。

1.3 消息模型基础:点对点与发布订阅

聊具体中间件之前,还得把两个基础消息模型说清楚,因为后面所有中间件都是在给这两个模型做具体实现。

点对点模型,也叫队列模型。生产者把消息发到队列,消息被一个消费者取走后,队列里的这条消息就没了,其他消费者不会重复收到。这个模型天然适合任务分发场景:一批待处理的任务,多个消费者各取一部分,大家并肩处理。就像一群人从一个筐里拿苹果,每个人拿到的都不会重复。

发布订阅模型则是另一个逻辑。生产者把消息发到一个主题,多个消费者都可以订阅这个主题,每个消费者都会收到同一份消息的副本。消息不是被"消费掉",而是被"广播"出去。这个模型适合通知类场景:一条订单创建消息,订单系统发一次,积分系统、短信系统、风控系统各收各的,彼此不受影响。

后文讲的四个中间件,支持的消息模型各不相同,这是选型的重要分水岭。

2. Redis List:最容易被低估的轻量队列

2.1 List 的结构本质:双向链表和阻塞读

很多人把 Redis List 当队列用,是因为它的操作命令太直白了:LPUSH从左边塞入,RPOP从右边弹出,先入先出,跟队列的语义完全吻合。但 Redis List 底层是一个双向链表,链表的特点是头尾操作是 O(1) 的,中间插入删除是 O(n) 的。对于队列场景,我们只关心头尾,所以性能足够好。

用 List 做队列,最基本的玩法是这样:

# 生产者 LPUSH order_queue order_1001 # 消费者(非阻塞,立刻返回,队列为空返回 nil) RPOP order_queue # 消费者(阻塞等待,最多等 10 秒) BRPOP order_queue 10

BRPOP是 List 队列的核心命令,它会在队列没有消息时阻塞住,直到有新的消息进来或者超时。这和轮询"每隔几秒 RPOP 一次"相比,大幅减少了无效请求对 Redis 的冲击,也降低了消息的消费延迟。一个简单的任务队列,用几十行代码加一个BRPOP循环就能跑起来,这也是很多中小团队在生产环境里真这么干的原因。

2.2 用 List 做队列的正确姿势

虽然简单,但真要落地,有几个细节必须处理好。

第一个是消息结构的设计。不要在 List 里塞裸字符串,最好把业务数据序列化成 JSON 塞进去。比如订单消息,至少应该包含订单号、用户 ID、业务类型、超时时间点这些字段,消费者拿到后能直接反序列化出业务对象。

第二个是消费失败的补偿。BRPOP取出一条消息后,消费者处理时抛异常了,这条消息已经不在队列里了,没有任何机制能把它放回去。所以生产环境里一个常见的做法是引入"处理中"队列和"死信"队列:消费者BRPOP到消息后,先把消息LPUSH到另一个"正在处理"队列,再开始业务处理,处理成功后从"处理中"队列移除;处理失败就把消息放到"死信"队列,由专门的任务去补偿。这套逻辑自己写起来需要几十行代码,但如果不写,一旦消费逻辑里有 bug,消息就悄悄丢了。

第三个是延迟消息的曲线实现。Redis 的ZSET(有序集合)可以用来做延迟队列:消息的到期时间戳作为 score,消息内容作为 member。用一个定时任务不断ZRANGEBYSCORE取出已到期的消息,再丢到 List 队列里让消费者处理。很多"用 Redis 做延迟队列"的方案,底层都是 List + ZSET 的组合拳,而不是单纯靠 List。

2.3 为什么 List 不是银弹:ACK 和消费确认

List 做队列最大的短板,就是它没有一个原生的 ACK 机制。

什么是 ACK?就是消费者告诉消息中间件"这条消息我处理完了,你可以删掉了"。List 的RPOP是幂等操作,消息一旦被某个消费者弹出,它在队列里就消失了。如果消费者 B 弹出消息后,在执行业务逻辑的瞬间进程崩溃了,这条消息就永远找不回来了。没有 ACK,就无法区分"消息正在处理中"和"消息处理失败",也就无法实现可靠投递。

还有一个被忽略的细节:BRPOP的阻塞是每个消费者各阻塞各的。如果有两个消费者同时BRPOP同一个 List,Redis 会保证消息只给其中一个,不会重复,但无法保证消息被轮询分配给所有消费者。RPOPLPUSH命令倒是可以把消息原子的从一个 List 移到另一个 List,给消息增加了一层"处理中"的保护,算是一个补救方案。

所以我的判断是:Redis List 适合内部小流量、对消息丢失容忍度较高、不想引入重量级中间件的场景。一旦业务涉及到资金、订单状态、核心数据流转,List 就撑不住了,你需要带 ACK 和事务能力的东西。

2.4 进阶:Redis Stream 比 List 更适合做队列

严格说起来,Redis 官方在 5.0 版本之后给出的队列答案不是 List,而是 Stream。Stream 是 Redis 专门为消息队列设计的数据结构,它解决了 List 的多个痛点。

Stream 的核心概念包括消费者组、消息 ID、Pending Entries List(PEL)。用XADD写入消息,用XREADGROUP以消费者组形式读取,消费者处理完消息后要XACK确认,消息才会从 PEL 里移除。如果消费者崩溃,消息会一直停留在 PEL 里,其他消费者可以通过XAUTOCLAIM或XCLAIM接管这些超时未确认的消息。这基本上就是一套轻量级的消息确认机制。

# 创建消费者组 XGROUP CREATE order_stream stream_group 0 # 写入消息 XADD order_stream * order_id 1001 user_id 88 # 消费者从组内读取消息,阻塞 5 秒 XREADGROUP GROUP stream_group consumer_1 COUNT 1 BLOCK 5000 STREAMS order_stream > # 处理完成后确认 XACK order_stream stream_group 1690000000000-0

Stream 的消费者组解决了"消息被谁消费了"和"消费失败怎么处理"两个问题,比原生的 List 队列可靠得多。如果你不想引入 RabbitMQ 或 Kafka,但又需要基本可靠的队列能力,Stream 是比 List 更合适的选择。只是它的生态和功能丰富度仍然无法和专门的 MQ 产品相比,比如它没有复杂路由、没有事务性消息,也没有跨机房高可用方案,定位依然是 Redis 体系内的轻量队列。

3. RabbitMQ:AMQP 协议的可靠通道

3.1 核心架构:Exchange、Binding、Queue

用惯 Redis List 再去看 RabbitMQ,最直观的感受是:多了一个交换机(Exchange),消息不是直接进队列,而是先进交换机,由交换机根据规则路由到队列。这个设计是 RabbitMQ 灵活性的根源,也是初学者最容易绕晕的地方。

RabbitMQ 的核心组件是生产者、交换机(Exchange)、队列、绑定(Binding)、消费者。生产者把消息发给交换机,交换机按照绑定规则把消息投递到符合条件的一个或多个队列,消费者再从这个队列里取消息。如果没有任何队列与消息的路由键匹配,消息就会被丢弃。

Exchange 有四种类型,实际生产里最常用的是三种:

  • direct类型:按路由键精确匹配。消息带着一个路由键,交换机把它投递到绑定键完全一致的队列。
  • fanout类型:无视路由键,把消息广播到所有绑定到该交换机上的队列。这个类型对应发布订阅模型。
  • topic类型:按通配符匹配路由键。*匹配一个词,#匹配零个或多个词,可以灵活实现按业务类型分发消息。

举个实际例子:订单系统发出一个订单事件,路由键是order.created,topic交换机把所有匹配order.#的队列都接收一份,于是积分队列、短信队列、风控队列都拿到了同一份订单消息,各自消费。这就是发布订阅模型在 RabbitMQ 里的标准实现。

3.2 消息可靠性:发布确认、消费确认、持久化

RabbitMQ 最大的卖点是可靠性,它把消息生命周期中的每个环节都给了"确认"能力。

生产端的可靠性靠发布确认(Publisher Confirm)。生产者发消息时开启确认模式,Broker 收到消息并落盘后,会给生产者返回一个 ACK。如果消息在到达 Broker 之前丢失或者 Broker 返回失败,生产者就可以选择重发。这个机制防止的是消息"从生产端到 Broker"这段路上的丢失。

消费端的可靠性靠消费确认(Consumer Ack)。消费者从队列取到消息后,默认是自动 ACK,即一取到就告诉 Broker"我处理好了",Broker 立刻删除消息。如果消费者在业务逻辑执行到一半挂了,消息就丢了。更严谨的姿势是手动 ACK:消费者业务处理成功后主动调用通道的 ack 方法;处理异常时调用 nack 并要求重新入队,或者把消息投递到死信队列。手动 ACK 是使用 RabbitMQ 时最容易忽略的可靠性保障,强烈建议生产环境的消费者一律走手动确认。

存储层的可靠性靠持久化。队列声明为durable,消息投递时设置为persistent,交换机也声明为durable,三者都具备时消息在 Broker 重启后不会丢失。把 JVM 内存里的数据刷到磁盘,性能必然有损失,所以持久化要跟性能要求一起权衡。

3.3 死信队列和延迟队列

RabbitMQ 的延迟队列并不是一个原生功能,它是靠死信交换机(DLX)和消息 TTL 组合实现的。

死信队列的触发条件有三个:消息被消费者显式拒绝(basic.reject 请求中requeue=false)、消息 TTL 过期、队列达到最大长度导致消息被丢弃。这些消息会按照配置被重新投递到指定的死信交换机,再路由到死信队列,由专门的消费者去处理。

延迟消息的实现思路是:给普通队列设置一个 TTL,比如 30 分钟,生产者发送消息时不直接投递到业务队列,而是投递到一个设置了 TTL 的等待队列,同时这个等待队列绑定一个"过期交换机",所有过期消息自动进入真正的业务队列。消费者从业务队列里消费时,正好是 TTL 之后。这个方案在生产环境非常成熟,唯一需要注意的是 TTL 的粒度,RabbitMQ 的 TTL 有三种维度的做法,逐条消息 TTL、队列级 TTL、还有使用插件rabbitmq_delayed_message_exchange让延迟属性直接挂在交换机上,生产上推荐插件方案,简单直接。

3.4 踩坑点:连接管理、Prefetch、消息堆积

RabbitMQ 使用 Erlang 编写,这个生态决定了它很多问题表现得很"隐晦"。我整理几个实操中高频遇到的坑。

第一个坑是连接和通道的使用方式。AMQP 协议里,连接(Connection)是重量级的 TCP 连接,通道(Channel)是连接内轻量的会话。很多新手每个线程都建一个 Connection,很快把 Broker 的连接数打满,服务端直接拒绝新连接。正确的做法是一个进程共享一个 Connection,每个线程用独立的 Channel。

第二个坑是 Prefetch 设置。basic.qos的prefetch_count表示消费者未被 ACK 的消息最大数量。如果不设置,Broker 会把队列里的消息一股脑推送给消费者,消费者内存里积压大量未处理消息,一旦消费者重启,这些消息全部重投,造成大量重复消费。设置了合适的prefetch_count,比如 100,消费者本地只保留 100 条在途消息,处理完一条再拉下一批,内存和重投风险都可控。

第三个坑是消息堆积。一个消费速度远低于生产速度的队列,RabbitMQ 会把大量消息堆积在 Broker 内存和磁盘,整个节点性能断崖式下跌。排查这类问题时,先看队列的 ready 和 unacknowledged 数量,再看消费者的prefetch_count和消费耗时,绝大多数堆积问题出在消费端,而不是 Broker 本身。

4. RocketMQ:为电商交易场景打磨出来的全能选手

4.1 组件模型:NameServer、Broker、Topic、ConsumerGroup

RocketMQ 是国内某大型电商团队内部孵化后开源的一个消息中间件,诞生背景就是电商大促场景,所以它的很多设计都带着交易系统的印记。它的架构比 RabbitMQ 复杂,核心组件有四个:NameServer、Broker、Topic、ConsumerGroup。

NameServer 是整个集群的"登记处",负责维护 Broker 的存活列表和 Topic 路由信息。Producer 和 Consumer 启动后先去 NameServer 拉取路由表,才知道该连哪台 Broker。NameServer 之间彼此独立,没有互相通信,这个设计是为了避免类似 ZooKeeper 那样的一致性协调开销,换来的是极低的延迟,代价则是路由信息可能短暂不一致,属于典型的"读写性能优先于强一致"的取舍。

Broker 是消息的实际存储和转发节点。Topic 是逻辑上的消息分类,一个 Topic 下可以有多个 MessageQueue(队列),消息实际是分布在各个 MessageQueue 里的。ConsumerGroup 是消费组,同一消费组内的消费者共同消费一个 Topic 的消息,每条消息只会被组内一个消费者消费;不同消费组则各自独立消费,互不影响。

RocketMQ 的消息模型本质上是一个"分区的点对点模型",通过多个队列实现并发消费,又通过不同的消费组实现发布订阅,思路非常清晰。用 RabbitMQ 的维度去理解,MessageQueue 就相当于 Queue,ConsumerGroup 就相当于 RabbitMQ 的订阅者分组。

4.2 普通消息与顺序消息:局部顺序是如何保证的

RocketMQ 的顺序消息是很多人选它的重要理由。电商场景里,创建订单、订单支付、订单取消,这三个事件是有严格先后顺序的,如果消费者乱序执行,把"取消"先处理了,后面的"支付"逻辑就会出错。

RocketMQ 的顺序机制很简单:同一个订单的消息,发送时必须投递到同一个 MessageQueue。只要保证消息在单个队列内的顺序,消费者单线程消费这个队列,就实现了局部顺序。实现的关键是 MessageQueueSelector,生产者在SendMessage时根据业务键(比如订单号)做 Hash,选出一个固定的 MessageQueue:

SendResult result = producer.send(msg, new MessageQueueSelector() { @Override public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { Long orderId = (Long) arg; // 对订单号取模,保证同一订单永远进入同一个队列 return mqs.get((int) (orderId % mqs.size())); } }, orderId);

如果生产者不指定 MessageQueue,RocketMQ 默认采用轮询策略,消息分散到所有队列里,顺序自然就没了。这是一个很容易被忽略的细节:顺序消息的保证不完全靠 Broker,消费端也必须配合。

消费端的配合体现在两点:消费者要从同一个 MessageQueue 里单线程取消息,不要启动大并发线程去消费一个队列;同时不要用集群模式随意负载均衡,因为一旦多个消费者同时消费一个 MessageQueue,顺序就乱了。实际生产里常见的是"不同队列的消费者可以有多个,但单队列内是串行的"。

4.3 延迟消息和事务消息

延迟消息是 RocketMQ 的另一个招牌能力。电商场景里"下单 30 分钟未支付自动关单",在 RocketMQ 里的标准答案是:发送一条延迟级别为 30 分钟的消息。RocketMQ 原生支持 18 个延迟级别,分别对应 1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。发送消息时设置message.setDelayTimeLevel(level)即可,Broker 会把这部分消息放在专门的延迟队列中,到期后才会投递到真正的目标队列。

这套机制的内部实现是"时间轮 + 多级队列",具体点说,Broker 会把延迟消息写入若干个基于延迟时间分组的内部队列,由一个后台线程定时扫描,把到期的消息转移给真实的 Topic。它解决的问题是框架层面的定时调度,不需要业务方做任何额外开发。但缺陷也很明显:延迟级别只有 18 个固定档次,如果你的需求是"延迟 3 秒再做",恰好没有这个档位,就会很尴尬。超过 2 小时的延迟需求,业界通常不靠延迟消息硬扛,而是结合定时任务做二次投递。

事务消息是 RocketMQ 最值得讲的能力。分布式系统里常见的"先扣库存,再送积分"这类跨库操作,要保证多个系统间的最终一致性,业界方案里相当经典的一种就是事务消息。

事务消息的执行流程分三步:首先是生产者发送一条"半消息"到 Broker,这半消息对消费者不可见,不会立刻被消费;然后生产者执行本地事务(比如扣库存的数据库操作);本地事务执行成功后,生产者向 Broker 提交消息,Broker 才把半消息变为可见,消费者才能消费。如果本地事务执行失败,生产者回滚消息,消费者永远不会看到这条消息。

这里面有一个关键问题:如果生产者发送半消息之后,在执行本地事务的过程中崩溃了,那这条半消息就永远卡在 Broker 里了。为了解决这个问题,RocketMQ 提供了事务回查机制:Broker 发现半消息长时间没有收到提交或回滚指令,会反向询问生产者"这条消息你本地事务到底成功了没",生产者通过查询本地事务表的状态,给出提交或回滚的结论,Broker 据此完成消息的最终处理。这个机制把分布式事务拆成了"本地事务 + 消息可靠投递 + 回查补偿"三个环节,是理解 RocketMQ 事务消息的钥匙。

4.4 RocketMQ 的高可用与主从复制

RocketMQ 的 Broker 可以部署为主从结构,一个 Master 带一个或多个 Slave。Master 负责接收写入和消费请求,Slave 负责在 Master 故障时接管读取流量。主从之间的同步策略有两种:同步复制和异步复制。

同步复制模式下,消息写入 Master 后,必须等 Slave 也写入成功才向生产者返回 ACK。这个模式消息可靠性最高,主备切换不丢消息,但写入延迟会明显上升,因为要等两个节点都落盘。异步复制模式则相反,Master 落盘后立刻返回 ACK,由后台线程异步同步给 Slave,性能好,但 Master 宕机且数据尚未同步到 Slave 时,会丢少量消息。

在 Raft 协议相关的实现(比如 DLedger 模式)出现之前,RocketMQ 的主从切换是需要人工干预的。DLedger 模式引入了 Raft 选主能力,Broker 集群内多个节点自动选举 Master,故障时自动切换,消息的一致性由 Raft 协议保证。启用这个模式之后,RocketMQ 的高可用能力基本可以对齐 Kafka 的复制机制。

5. Kafka:海量数据流的默认答案

5.1 从日志管道看 Kafka 的设计出发点

Kafka 的项目诞生背景和 RabbitMQ、RocketMQ 完全不一样。它最初是某社交平台团队为了解决"海量行为日志如何收集、如何分发"的问题而设计的。日志场景的特点是:数据量大、不要求每一条都绝对不丢、不要求强顺序、消费者可以慢慢追数据。这个背景决定了 Kafka 的整个技术风格:极致的吞吐、以日志文件为中心的存储、以追加写为主的 IO 路径。

Kafka 的架构里,一个 Topic 被分成多个分区(Partition)。分区是 Kafka 并行性的基本单位,一个分区的消息是有序的,分区之间无序。消息在分区内按顺序追加写,每个消息都有一个单调递增的 offset,消费者通过维护自己消费到哪个 offset 来记录进度。这个"offset 由消费者自己保存"的设计,和 RabbitMQ/RocketMQ 的"Broker 帮消费者管理进度"很不一样,也是很多人对 Kafka 产生误解的地方。

我把 Kafka 理解为一个"能重复读的日志"而不是传统意义的消息队列。消费者不是把消息从队列里拿走,而是从日志的某个位置开始读,读完推进 offset,下次继续往后读。正因为日志不会被消费掉,多个消费者组可以各自维护各自的 offset,互不干扰,天然实现发布订阅。同一分区内的消息不会被"删除",而是保留一段时间(一般是 7 天)后由后台任务清理。

5.2 高性能的秘密:顺序写、页缓存、零拷贝

Kafka 能达到单机几十万甚至百万级 TPS,靠的不是什么魔法,而是三件事:顺序写磁盘、充分利用页缓存、零拷贝发送。

磁盘随机写很慢,但顺序写在机械硬盘时代也能到几百 MB/s 每秒,在 SSD 上更是快到夸张。Kafka 对消息的写入永远采用追加写的方式,把数据顺序追加到分区日志文件末尾,写路径是标准的顺序 IO。所以它敢把数据落盘且保持高吞吐,这就是它和"内存型"的 Redis 队列之间最根本的差别。

页缓存的意思是,操作系统会把读过的磁盘数据缓存在内存里,Kafka 充分依赖这个机制。生产端写入的数据,先进入页缓存,消费端读数据时,如果命中了页缓存,压根不需要回到磁盘,直接从内存读。生产消费都命中页缓存,整体行为就像一个"内存队列",但数据又安全地落到了磁盘。这个设计非常聪明,它把性能优化交给了操作系统,而不是自己维护一套缓存逻辑。

零拷贝则是消费端高性能的关键。传统的数据读发送过程是:磁盘到内核缓冲区,内核缓冲区到应用缓冲区,应用缓冲区到内核 Socket 缓冲区,Socket 缓冲区到网卡。Kafka 使用sendfile系统调用,数据从磁盘到页缓存后,直接由内核发给网卡,用户空间完全跳过,省掉了多次拷贝和上下文切换。网络 IO 路径变短,单位时间能发出的消息数量也就上去了。

5.3 消费组、分区和 Offset 的管理

Kafka 的消费模型是"一个消费组共同消费一个 Topic"。组内成员共享订阅,每个分区同一时刻只会被组内一个消费者消费,所以一个消费者可以对应多个分区,但一个分区不能同时分配给多个组内消费者。这个规则引出了一个经典问题:当组内消费者数量大于分区数时,多余的消费者会闲置,因为分区不够分了。

消费组协调的核心机制是 Rebalance。当组内有消费者加入、退出、崩溃,或者订阅的 Topic 分区数量变化时,Kafka 会在组内重新分配分区。Rebalance 期间消费者会暂停工作,这是 Kafka 使用中一个很常见的"服务一切正常但消费停止了"的原因,尤其容易在大规模动态扩缩容时出现。

Offset 的提交方式直接决定消息是否不丢、是否不重。消费者每处理完一批消息,会把"这批消息里最大的 offset"提交给 Kafka 的内部主题。如果消费者在提交之前崩溃,重启后 Kafka 会让消费者从上次提交的 offset 重新消费,那崩溃前处理的这一段就会被再次消费,这就是至少一次语义。如果想精确一次语义,要么业务端做幂等,要么启用 Kafka 的幂等生产者和事务机制。

5.4 消息不丢与不重:ISR、Acks、幂等

Kafka 的消息可靠性配置比 Redis 复杂,但比 RocketMQ 更"参数化",核心就三个:acks、min.insync.replicas、enable.idempotence。

acks参数有三个取值。acks=0,生产者发完直接不管,性能最高,消息可能丢失。acks=1,Leader 写入成功就直接返回 ACK,性能好,但 Leader 在数据未同步到副本就宕机时会丢消息。acks=all,则要求 ISR 集合里的所有副本都写入成功才返回 ACK,是可靠性最强的配置,代价是延迟上升。ISR 是"同步副本集合",Kafka 会在后台维护一组跟得上 Leader 数据的副本,只有 ISR 里的副本才参与这次"全部写入成功"的判定。

min.insync.replicas是配合acks=all用的,它规定了 ISR 集合的最少副本数。如果实际存活的同步副本小于这个数,生产者的写入请求会被拒绝,宁可写不进去,也不能写一个只有单副本的"假可靠"状态。这是一个防止"Leader 独自活着,消息随时丢光"的兜底措施。

为了在acks=all下避免重复,Kafka 还提供了幂等生产者:生产者每次发送时带上一个序列号,Broker 根据 (生产者 ID + 分区 + 序号) 去重。幂等生产者在单个分区内有效,能防止重试导致的重复消息;跨分区跨会话的精确一次,则需要生产者开启事务并通过消费者端read_committed隔离级别配合,实现成本不低。很多生产环境里,实际贯彻的思路是"保证不丢,重复靠业务幂等扛",把精确一次的问题抛给业务层去解决,这是工程上最划算的取舍。

6. 一把尺子量到底:四款中间件怎么选

6.1 核心对比表

维度Redis List / StreamRabbitMQRocketMQKafka
核心定位轻量缓存队列AMQP 协议可靠通道电商场景全能引擎海量日志/数据管道
存储模型内存,Stream 可持久化内存 + 磁盘CommitLog 顺序写盘分区日志顺序写盘
消息模型点对点为主点对点 + 发布订阅点对点 + 发布订阅发布订阅为主
事务消息不支持不支持支持,事务回查支持,事务幂等
顺序消息单 List 天然有序单队列内有序局部顺序,Select 控制单分区内有序
延迟消息ZSET 自行实现TTL + 死信实现原生 18 级别不支持原生延迟
消费者确认List 无 ACK,Stream 有手动 ACK 成熟原生 ACKoffset 手动提交
吞吐量高(内存)中(单队列瓶颈)高(顺序写)极高(页缓存 + 零拷贝)
运维复杂度极低中高(组件多)高(分区/副本管理)
典型场景任务队列、缓存削峰系统集成、微服务解耦订单交易、事务消息日志采集、流计算、大数据

6.2 选型决策的几个视角

很多团队选型时只看性能指标,实际这是最不重要的维度。四个中间件的吞吐差异只有在百万级 QPS 的极端场景才真正影响架构,绝大多数业务系统在几千 QPS 的水平上,四者的性能都远远够用。真正影响选型的是可靠性要求、消息模型、生态成熟度、运维成本这四个因素。

如果需求只是"任务异步处理,丢几条也能接受",Redis List/Stream 是最轻量的方案。一个 Redis 节点已经在线上了,不用额外引入任何组件,开发成本几乎为零。这个选择适合内部工具、报表生成、异步通知这类非核心链路。

如果系统需要稳定长期的运行,要对消息可靠性、路由灵活性有较高要求,RabbitMQ 是稳妥之选。它的文档极其详尽,社区里能搜到几乎所有问题的答案,团队上手成本低。用 RabbitMQ 时我不太担心消息丢失,核心链路全靠手动 ACK + 持久化,就能做到很可靠的投递。

如果项目有明显的电商/交易属性,比如订单状态流转、支付回调通知、跨系统积分同步这类业务强一致需求,RocketMQ 的事务消息几乎是为这类场景量身定制的。它把"本地事务和消息解耦"做到了开箱即用的程度,这是 RabbitMQ 和 Kafka 都需要额外开发才能补齐的功能。选择 RocketMQ 之前要想清楚一点:它比 RabbitMQ 重,需要维护 NameServer 和 Broker 两组节点,运维成本要预留出来。

如果系统有海量日志、用户行为采集、埋点数据上报这类吞吐优先的场景,Kafka 是事实标准。它跟大数据生态的整合深度几乎没有对手:日志采集、流式计算、数据仓库同步,默认连接的都是 Kafka。但要注意,Kafka 不是传统意义上的"业务消息队列",硬拿它做订单通知这类低延迟、强顺序的场景,会很别扭。

6.3 处理速查表:什么业务选什么

给出一个可以直接照抄的选型速查表:

业务场景需求推荐选择理由
临时任务、低延迟、可少量丢失Redis List零运维成本,在现有 Redis 上直接用
需要消息确认、重复消费时可容忍Redis Stream比 List 多了 ACK 和消费组,仍保持轻量
微服务间解耦、复杂路由、企业信息化RabbitMQ协议成熟,路由灵活,运维资料最丰富
订单/支付/库存等交易类异步消息RocketMQ事务消息和顺序消息能力最贴合交易场景
延迟任务且要求原生支持RocketMQ原生多级延迟消息,不需要额外开发
百万级 QPS 日志、埋点、流计算Kafka高吞吐 + 生态最完整
需要广播给多个下游系统RabbitMQ / RocketMQ发布订阅模型成熟

7. 高频问题排查实录

7.1 Redis List 消费丢消息

现象:消费者从 List 里RPOP出消息后应用重启,消息消失,业务数据缺失。

这个问题的根源就是 List 没有 ACK。解决办法有两个方向。如果还在用 List,至少要改成RPOPLPUSH把消息先转到"处理中"队列,处理完成后再做转移确认,相当于手动补了一层 ACK。更推荐的做法是切换到 Redis Stream,用XREADGROUP+XACK,消费者崩溃后消息留在 PEL 里,由其他消费者通过XAUTOCLAIM接管。

7.2 RabbitMQ 消费者被"饿死"和重复消费

现象一:消费者进程正常,队列里消息堆积,但消费者一个消息都不处理,RabbitMQ 管理界面显示的消费者连接还在。

排查方向是消费端的通道状态和prefetch_count。有一种常见情况是消费者里抛了未捕获异常,导致手动 ACK 永远没执行,消息一直卡在 unacknowledged 状态,队列感知的消费者容量是满的,就不给新的消息了。解决办法是在消费逻辑里无论成功失败都要给出 ACK 或 NACK,不要让消息卡死在在途状态。还有一个经典情况:prefetch_count设置得过小,消费者处理速率跟不上,管理界面上表现为"ready 在涨 unacked 也很高",适当调大prefetch_count可以缓解。

现象二:同一个订单消息被消费了多次,下游系统出现重复通知。

RabbitMQ 的重复消费绝大多数发生在"消费者处理成功,但在 ACK 返回之前连接断开"的场景。Broker 收不到 ACK,会重投消息给其他消费者,产生重复。这是分布式环境下无法完全避免的,靠消息中间件层面很难消除,必须在业务侧做幂等。下单、支付回调、积分变更这类操作,数据库表上要加唯一索引,接口要能接受重复调用而结果不变。

7.3 RocketMQ 延迟消息、顺序消息踩坑

现象一:设置了延迟级别 30 分钟,但消息 28 分钟就消费了。

先确认延迟级别对应关系对不对。RocketMQ 的延迟级别是固定的,第 16 级才是 30s,17 级是 1m,如果按位置而不是按级别号去配,很容易配错成更短的时间。另外要确认 Broker 的messageDelayLevel配置没有被改过,很多自建集群会自定义延迟级别列表,不同环境的级别映射不一致会导致同样的参数在不同环境表现不同。

现象二:用了顺序消息,但消费顺序还是乱的。

顺序消息要求两件事同时成立:消息按业务键固定进入同一个 MessageQueue,消费者单线程消费该队列。最常见的错误是消费者代码里启用了多个线程拉取同一个队列的数据,或者设置了消费端并发数大于 1,导致单队列内并行消费打乱了顺序。另外一个隐蔽的问题是生产端的消息发送没有使用 MessageQueueSelector,而是用了普通的send方法,同一订单的消息随机进入不同队列,无论如何优化消费端,顺序都没法保证。生产端先用订单号做 Hash 选定队列,这一步是根本。

7.4 Kafka 重复消费与 Rebalance 风暴

现象一:消费者重启后,又会收到一批之前已经处理过的消息。

这是 offset 提交不及时导致的。消费者把消息处理完后,如果使用自动提交策略,但auto.commit.interval.ms设置得比较大(比如默认 5 秒),而消息在一秒内就处理完了,这时消费者崩溃,offset 还没来得及提交,重启后就会从旧位置开始重新消费。标准做法是用手动提交 offset:每条或每批消息处理完成后立即commit,把提交和业务处理放在一起。但就算手动提交,也无法杜绝重复,处理逻辑与提交逻辑之间天然有一个时间差,Kafka 的 at-least-once 语义决定了重复消费是常态,业务侧必须具备幂等能力。

现象二:消费组频繁触发 Rebalance,消费者一会儿停止一会儿恢复,消费吞吐量断崖下跌。

Rebalance 风暴的常见诱因是消费组内有消费者"卡死"或者"频繁加入退出"。Kafka 的心跳线程会周期性地向 Group Coordinator 报告存活状态,如果消费者处理消息耗时超过max.poll.interval.ms,协调者会认为它挂了,触发整个消费组的再平衡。处理耗时的消费者在接下来的一轮拉取中又会承担更多的消息,进一步加剧耗时,进入恶性循环。解决办法是合理调大max.poll.interval.ms,给慢消费留出余量;或者让消费者进程内单独处理心跳时机;更根本的办法是提升消费速度,比如增加分区数和消费者数量。

8. 最后聊几句实操体会

这四个中间件我都实际部署和用过,说点个人体会。Redis List 是我解决问题时最常想到的,它就在那里,零成本,能解决一大批实际业务问题,但它没有资格承担核心链路。RabbitMQ 是那种"用起来很舒服"的中间件,它的文档完善、社区活跃、踩坑成本低,适合作为多数中小团队的默认选择。RocketMQ 是交易系统的老搭档,事务消息真的能解决其他几个中间件搞不定的问题,但启用它之前一定要算好运维成本。Kafka 是海量数据的搬运工,它的性能和生态无敌,但把它拿来做业务消息队列,就好像开着集卡去小区送快递,能用,但憋屈。

对于刚开始接触消息队列的同事,我的建议是:先别急着选中间件,先把你业务里的消息模型画出来,是点对点、广播还是有事务需求;再弄清楚一条消息从发送到消费,你要承担多大的丢失风险;最后评估你团队有没有人能长期维护这套集群。把这三件事想清楚,选型表其实很好做。最后再分享一个小技巧:无论用哪一款中间件,给每个 Topic/Queue 都规划好命名规范,从一开始就带上环境、业务模块、事件类型这些标注。等你维护的队列超过几十个之后,会发现这个习惯帮你省下大量的排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询