消息队列基础
为什么系统里要用消息队列?请结合「跨服务最终一致性」说说,MQ 解决了哪一类问题。引入 MQ 会带来哪些新问题?什么场景不该用 MQ?
在跨服务场景中,三大价值是这样体现的:
- 解耦:例如,订单服务不需要在代码里硬编码调用库存、积分、物流的接口。它只管把“订单已创建”这个事件扔进 MQ,谁关心这个事件谁就自己去订阅。以后新增一个相关的业务,订单服务一行代码都不用改。
- 异步:同步调用三个下游假设分别耗时 200ms、300ms、150ms,加上本地写库,用户要等近 1 秒。引入 MQ 后,订单服务写完本地库、发完消息(假设 5ms)就直接返回给用户,把服务必须的同步等待变成了最终一致性,用户体验是质变。
- 削峰:如果遇到大促,订单创建 TPS 瞬间飙到 5000,但库存服务数据库最多只能抗 2000 TPS。MQ 作为缓冲池,把 5000 个请求先囤起来,库存服务按自己的节奏以 2000 TPS 慢慢拉取消费,峰值期间系统不会被打死
当然,这个选择是有代价的。它放弃了强一致性,就必须接受中间状态的存在,并且要额外建设一套机制来保证消息不丢、不重、不乱、而且能够被正确处理。这也就是引入 MQ 带来的新问题。
- 系统可用性下降:原来订单服务只依赖数据库,现在多了一个 MQ 为强依赖。如果 MQ 挂了,订单服务发不出消息,下游全部断流,整条业务链路就瘫痪了。所以 MQ 本身变成了新的单点,就需要处理针对 MQ 的高可用
- 一致性问题:写库和发 MQ 消息是两个操作,所以,本地事务与发消息不是原子的。如果写库成功了,但发消息时没发出去,下游就永远不知道有这个订单,数据就不一致了。如果发消息成功了,但写库回滚了,下游就会收到一个不存在的订单
- 复杂度上升:MQ需要考虑,重复消费,消息积压,顺序性等问题
正因为有这些代价,流量小、要求强实时、调用链很短,这种类型的场景不会引入 MQ。MQ 是解决 高并发、跨服务、可异步 场景的。
RabbitMQ、Kafka、RocketMQ,这些消息队列有什么区别?你在引入MQ的时候怎么选?RabbitMQ 和 Kafka 的本质差异是什么?
RabbitMQ 的核心是 Exchange(交换机)和 Binding(绑定规则),路由能力非常强,支持直连、主题、扇形、首部等多种模式。而且延迟极低,自带完善的管理控制台,适合中小型微服务解耦、需要复杂路由规则的业务通知。但它的队列是状态核心,镜像集群同步成本高,海量堆积时性能会明显下降,不适合大数据流场景。
Kafka 的设计初衷就是海量数据处理和流处理。基于磁盘顺序写和分区日志,吞吐量很高,而且分区内有序,消息保留,消息可回放(消费者可以重置 offset 重新消费历史数据)。但 Kafka 不支持延迟消息、死信队列等业务特性,消费失败的重试逻辑要业务自己实现。适合日志聚合、事件溯源、实时流计算管道。
日志聚合:把日志本身抽象成一条持续的消息流,生产者把日志发到 Kafka,下游消费者按需拉取处理(比如实时告警,服务状态分析等)
事件溯源:把每一次状态变化都作为一条不可变事件追加到日志里。当前状态不直接存,而是通过重放事件序列推导出来
事实流计算:数据从生产者流入 Kafka,持续消费、聚合、转换,把结果写回另一个 Topic 或外部系统。一般是实时服务监控,实时推荐,多阶段数据处理等使用。
RocketMQ 原生支持事务消息和延迟消息,事务消息让你不必自己手搓本地消息表,延迟消息可以处理“订单 30 分钟未支付自动取消”这类场景。吞吐和堆积能力都不错
选型不是看吞吐量排行榜。如果业务有事务和延迟需求,RocketMQ 最顺手;如果是涉及到分布式日志处理,数据是流的形式而不是任务,需要重放能力的场景,基本需要选择 Kafka;如果业务是中小规模,而且涉及到路由复杂的业务解耦时,选择 RabbitMQ
RabbitMQ 是消息代理,Kafka 是分布式流式平台
| 维度 | RabbitMQ | Kafka |
|---|---|---|
| 核心模型 | 队列模型:消息进入 Queue,被消费后删除 | 日志模型:消息追加到 Partition 日志尾部,保留可回放,消费只是移动 offset |
| 消费后行为 | Broker 收到 ack 后删除消息 | 消息不因消费而删除,按时间/大小策略保留 |
| 消费模式 | 推荐 Push(broker 推给消费者),也支持 Pull | 仅 Pull,消费者主动按 offset 拉取 |
| 路由能力 | 强:Exchange + Binding 支持复杂路由规则 | 弱:只有 Topic/Partition,无业务级路由过滤 |
| 顺序保证 | 单消费者单队列时有序,多消费者并发消费即失序 | 分区内严格有序,跨分区不保证 |
| 确认粒度 | 逐条 ack(或批量 ack),broker 据此删除消息 | offset 提交,消费者维护自己的读取位置 |
| 设计目标 | 低延迟、灵活路由、业务消息的可靠投递 | 高吞吐、持久化日志、流数据管道 |
RabbitMQ 把消息当作待处理的任务,处理完就消失,核心是路由和分发。Kafka 把消息当作不可变的事件日志,写进去就留着,消费者自己决定读到哪,核心是持久化和回放。所以 RabbitMQ 适合‘这件事需要谁来做’,Kafka 适合‘发生过什么,谁关心谁来读’。
什么是 Push 和 Pull?各有什么问题?Kafka 为什么坚持 Pull?RabbitMQ 的 Push 怎么做背压?
Push 是生产者主动推,节奏由服务端掌握,消费者被动承受;Pull 是消费者主动拉,节奏由自己掌握,但需要自己解决“没数据时怎么办”的问题。Push 适合业务消息的实时投递,Pull 适合流数据的高吞吐消费
Push 模式下,Broker 一旦收到消息就立即推给消费者。如果生产速率远大于消费速率,或者消费者处理逻辑本身很慢,Broker 无法感知消费者的真实负载,导致消费者消息堆积。
RabbitMQ 的解法:信用滑动窗口
RabbitMQ 通过
basic.qos的prefetch_count参数,限定一个消费者同时能持有多少条未确认(unacked) 的消息。比如设置prefetch_count=10,Broker 最多只会给这个消费者推 10 条还没 ack 的消息,消费者每 ack 一条,Broker 才会再推一条。
Pull 模式下,消费者需要自己循环拉取。如果队列里没消息,消费者反复发请求却拿不到数据,这就是空轮询,白白消耗 CPU 和网络。反过来,如果为了减少空轮询而拉大轮询间隔,新消息到达时消费者可能还在间隔,带来延迟。
Kafka 长轮询
Kafka 消费者调用
poll()时,如果 Broker 端没有足够的数据,请求不会立刻返回空结果,而是挂起等待,直到有数据到达或超时。
Kafka 选择 Pull 是与其日志模型的设计目标一致
- Pull 天然支持批量化。消费者一次拉取一批消息,Broker 不需要为每条消息单独推送,大幅提升吞吐。
- 消费速率由消费者完全掌控。Kafka 的消费者用 offset 记录自己的位置,拉多少、拉多快、何时提交 offset,全部由消费者决定。这是消息重放所必要的,而且也避免了 Push 模式下 Broker 需要感知消费者状态的复杂度。
- Pull 让 Kafka 的存储与消费解耦。Broker 不需要维护“哪些消息已经推给谁、推了多少”的状态,它只是一个被动的日志存储,消费者按 offset 读取即可。
消息不丢怎么保证?请按生产端、Broker、消费端三段回答。
消息经过生产端、Broker、消费端三段,每一段都有丢的可能,每一段都要有对应的确认或持久化机制兜底。
生产端:确认 + 重试
这种情况下消息丢失的典型场景是,消息发出去了,但网络抖动或 Broker 暂时不可用,生产者不知道到底成没成功。
解法是要求 Broker 给确认,没确认就重试。
- Kafka:设置
acks=all,要求 Leader 和所有 ISR 副本都写入成功才返回确认。配合合理的retries和重试间隔,应对瞬时故障。 - RabbitMQ:开启 Publisher Confirms,Broker 收到消息后回调通知生产者 ack/nack。
- RocketMQ:使用同步发送,并检查
SendResult的状态。发送失败或超时则重试。异步发送需要在回调里检查结果。
- Kafka:设置
Broker:持久化 + 多副本
消息到了 Broker 丢的话一般是 Broker 没来得及落盘就宕机的情况
- 持久化:消息先写内存,再异步或同步刷到磁盘。RocketMQ 通过
flushDiskType=SYNC_FLUSH开启同步刷盘。RabbitMQ 需要 Exchange、Queue、Message 三者都设置持久化,缺一不可。 - 多副本:单机持久化挡不住磁盘损坏。Kafka 依赖分区副本机制。RocketMQ 主也有从同步复制
- 持久化:消息先写内存,再异步或同步刷到磁盘。RocketMQ 通过
消费端:处理成功后再确认
关闭自动确认,并且必须在业务逻辑执行成功之后才确认。
- Kafka:
enable.auto.commit=false,在消息处理完成后手动提交 offset。 - RabbitMQ:
acknowledge-mode=manual,业务成功后调用basicAck,失败则basicNack让消息重回队列或入死信。 - RocketMQ:消费成功后返回
CONSUME_SUCCESS,失败返回RECONSUME_LATER触发重试。
但这里有一个无法绕开的矛盾:如果消费者处理完业务、还没来得及 ack 就崩溃了,Broker 会认为消息没被消费,重新投递。这就是重复的来源。所以消费端“不丢”的代价,就是接受重复。
- Kafka:
三段机制做满,只能保证消息不丢,但代价是可能重复。因此消费端必须做幂等,用业务唯一键或 messageId 去重,让同一条消息处理一次和处理多次的结果一致。
消息不重怎么保证?为什么工程上更常说 至少一次 + 幂等 而不是追求 exactly-once?
消息不重”从来不是靠 MQ 本身保证的,而是靠消费端去做业务幂等
分布式系统中,消息代理(Broker)能控制的只是 消息从生产者到消费者 这一段。因为消费者收到消息后,要写数据库、调第三方接口、发通知,这些都是外部副作用。所谓的事务消息只是保证消息队列内部的原子性。
在分布式系统中追求 exactly-once 的投递是不切实际的
消费端幂等的三种常用方式
- 去重表 / 唯一索引:在数据库中建一张去重表,以业务唯一键为主键或唯一索引。消费前先尝试插入,插入成功说明是首次处理,继续执行业务;插入冲突说明重复,直接跳过或返回已有结果。
- Redis SETNX:用
SET key value NX原子地占位,key 由业务唯一标识生成。返回成功说明是首次,返回失败说明重复。 - 状态机 / 版本号:状态机自去重适用于业务本身有状态流转的场景。用版本号就是做乐观锁
消息的顺序性怎么保证?全局有序和局部有序怎么取舍?
消息从生产者到消费者,顺序可能在三个环节被破坏
- 生产端乱序:异步发送 + 重试,基本上就会涉及到这个问题。消息 A 发送失败进入重试队列,消息 B 却发送成功了,Broker 里 B 就排在了 A 前面。多线程并发发送同一业务的消息也会乱序。
- Broker 端乱序:消息被分散到多个队列/分区,天然并行存储。如果同一业务 Key 的消息被路由到了不同队列,存储顺序就已经无法保证。另外,Topic 扩容分区时,哈希取模结果变化,同一 Key 的消息可能漂移到新分区,顺序断裂。
- 消费端乱序:多线程/多实例并发消费同一队列,先拉到的消息可能后处理完;而这样消费重试也会打乱顺序
各 MQ 是这样保证顺序的
- RabbitMQ:顺序存储靠
同一队列的消息在单节点上天然有序,但消费端必须开启
x-single-active-consumer=true,让队列同时只有一个活跃消费者,但是这样拉低了并发度。 - Kafka:只保证 Partition
内有序。生产端指定 Key,按
hash(Key) % partitionNum把同一 Key 的消息路由到同一 Partition;消费端保证一个 Partition 只被一个消费者线程消费。生产端如果开了重试,还要注意max.in.flight.requests.per.connection=1或开启幂等生产者,避免重试导致乱序。 - RocketMQ:对顺序消费的支持最完善。生产端用
MessageQueueSelector按业务 Key 选队列,消费端用MessageListenerOrderly,Broker 端会对队列加锁,保证同一队列同时只有一个消费者线程在处理。但要注意,顺序消息只支持同步发送,且顺序消息与定时消息、事务消息互斥,不能叠加使用。
全局有序:整个 Topic 只有一个分区/队列,全链路串行。吞吐量断崖式下跌,因为上一条消息没消费完,下一条就一直等着。
局部有序:按业务键(订单 ID、用户 ID)哈希到固定分区,同一键内有序,不同键之间并行。
微软的架构模式文档把这个思路称为“顺序车队模式”(Sequential Convoy):将相关消息按类别键分组,组内严格 FIFO,不同组并行处理,既保顺序又不牺牲吞吐。
延迟消息和定时消息有哪几种实现?各自的问题是什么?
延迟消息是“消息在未来的某个时间点才被消费”。
RabbitMQ 有两种实现
TTL + DLX:消息发到一个等待队列,设置 TTL,过期后通过 DLX 转发到真正的消费队列。
问题在于队头阻塞,RabbitMQ 只检查队头消息是否过期。如果队头消息 TTL 是 60 秒,后面消息 TTL 是 5 秒,后面那条必须等队头的过期了才能被检查到,因此后面的就没法被消费。不同延迟时长需要拆成多个队列,管理成本高。
延迟消息插件:声明一个
x-delayed-message类型的 Exchange,发送时在 header 里设x-delay,支持逐消息灵活延迟。问题在于可靠性:延迟消息存储在单节点的 Mnesia 表中,不跨集群复制,节点宕机则待投递的延迟消息丢失;且不适合百万级以上的延迟消息量。
Kafka 原生不支持,没有延迟投递的原语。
RocketMQ 原生延迟等级,简单但有约束
- RocketMQ 开源版内置 18 个延迟等级,从 1s 到 2h,发送时设置
delayLevel即可。问题就是等级固定,不能任意指定延迟时间。
为什么补偿任务用 @Scheduled 而不用延迟消息
- 延迟消息适合 确定时间点的单次触发,比如订单 30
分钟未支付自动关闭。补偿任务是
对已经失败的操作做兜底重试,它的触发时机是不确定的,而且可能涉及到重试,用
@Scheduled扫表 + 幂等执行更好一些。
线上消息积压了,怎么排查、怎么处理?
消费位点与最新生产位点的差值,就代表消息积压的情况。
看指标
- Kafka:看
records-lag。Lag 持续增长说明消费能力不足;Lag 周期性回落后又上涨,可能是消费者频繁触发 rebalance。 - RocketMQ:看 Group 的实时消息堆积量,以及客户端的
consumeRT(消费平均延迟)。 - RabbitMQ:看队列的 Ready 消息数,以及消费者 IP 数量和消费时长是否符合预期。
定位根因
- 消费者挂了或变慢
- 下游变慢:消费逻辑依赖的数据库、Redis、第三方接口变慢了,导致整体消费吞吐下降。
- 生产者请求突增
- 分区/队列数不够
应急处理
- 如果根因是消费端逻辑问题,一般是降级下游依赖,再考虑扩容和优化
- 如果根因是消费能力不足且下游健康:扩消费者实例。
- 如果积压量极大且消息必须处理:空间换时间。临时新建一个分区数更多的 Topic,写一个转发消费者把积压 Topic 的消息快速搬运到新 Topic,再用临时扩容的消费者集群并行消费新 Topic。
长期预防
- 做好监控告警
- 死信隔离:处理失败 N 次的消息进死信队列,避免卡住整个分区,导致后续消息全部无法消费。
消息队列的高可用是怎么做的?RabbitMQ 和 Kafka 的副本/选举机制有什么不同?
消息队列的高可用,核心目标只有一个:当某个节点挂掉时,消息不丢、服务不停。
RabbitMQ 的高可用是队列级别的复制,而 Kafka 的高可用是分区级别的副本与选举。
RabbitMQ 的高可用机制,早期依赖镜像队列(Classic Mirrored Queues),现在官方推荐的是仲裁队列(Quorum Queues)。
镜像队列:镜像队列有一个主队列和多个从队列。生产者发消息给主队列,主队列同步给所有从队列,所有从队列都保存成功后,主队列才向生产者确认。
这意味着最慢的那个从节点会拖慢整个队列的写入性能。RabbitMQ 4 中镜像队列被完全移除
仲裁队列:仲裁队列基于 Raft 共识算法的变种。写入时,主副本把消息同步给从副本,超过半数的副本确认后,主副本就向生产者返回成功。主副本的选举也需要超过半数副本同意
Kafka 的高可用单位是分区(Partition)。每个分区有多个副本(Replica),分布在不同 Broker 上。其中一个副本是 Leader,负责处理所有读写请求;其余是 Follower,持续从 Leader 拉取消息进行同步。
- ISR:Kafka 维护一个
ISR(同步副本)列表,里面是跟得上 Leader
进度的副本。生产端设置
acks=all时,消息必须被 ISR 中所有副本写入后才算成功。如果 Leader 宕机,Controller 会从 ISR 中选出一个新 Leader。因为 ISR 中的副本都拥有完整的数据,切换后不会丢消息。 - 如果 ISR 缩小到只有一个副本,此时 Leader 挂了。Kafka 的默认策略是从非 ISR 副本中选一个新 Leader,牺牲部分消息换取高可用
RabbitMQ
画一下 RabbitMQ 的核心模型:Producer、Exchange、Binding、Queue、Consumer、Channel、vhost 各自是什么?讲解一下核心模型和核心角色。
RabbitMQ 是一个 生产者把消息发给交换机,交换机按绑定规则路由到一个或多个队列,消费者从队列里取 这样的一个模型。
Channel 是复用 TCP 的虚拟连接,vhost 是逻辑隔离单元,Exchange 交换机本身不存消息。
Producer(生产者):只负责把消息发给交换机,并带上 RoutingKey。它不知道消息最终会进哪个队列,这是解耦的关键。
- RoutingKey,属于消息,生产者发消息时携带,是消息的”地址标签”
- BindingKey:属于绑定,是 Exchange 与 Queue 之间的绑定关系,声明绑定时出现,是队列的”收件规则”
路由的本质是 Exchange 拿消息的 RoutingKey 去和每条 Binding 的 BindingKey 做匹配,匹配成功就把消息投到对应的 Queue。
Exchange(交换机):路由组件,收到消息后按类型 + Binding 决定投递到哪些 Queue。它不持久化消息,路由不到就丢弃。
Binding(绑定):Exchange 和 Queue 之间的 路由规则。BindingKey 和消息的 RoutingKey 匹配,匹配上才投递。一个 Exchange 可以绑多个 Queue,一个 Queue 也可以被多个 Exchange 绑定。
Queue(队列):真正存消息的地方。消息在队列里排队,等消费者来取;消费并 ack 后才删除。
Consumer(消费者):从 Queue 拉/被推消息,处理完 ack。
Channel(信道):建立在 Connection(TCP)上的虚拟连接。AMQP 的绝大多数操作(声明队列、发消息、ack)都在 Channel 上完成。一个 Connection 可以开多个 Channel,避免频繁建 TCP 连接的开销。Channel 不是线程安全的,通常一个线程一个 Channel。
vhost(虚拟主机):权限和资源的逻辑隔离,类似 MySQL 的 database。不同 vhost 的 Exchange/Queue 名字可以重复,互不影响。多租户系统常用 vhost 做隔离。
一条消息的完整路径如下
1 | Producer |
- Exchange 不存消息:交换机是纯路由,消息落点是 Queue
- Channel 不是连接:连接是 TCP。Channel 是连接上的多路复用;Channel 关闭不影响连接,但连接断了所有 Channel 都会失效。
- vhost 是隔离不是集群:vhost 隔离的是命名空间和权限,跟高可用、副本没有关系。
四种 Exchange 类型(direct / fanout / topic / headers)的区别?各自典型场景?
direct 精确匹配、fanout 广播、topic 通配、headers 按消息头匹配
生产上 90% 的场景是 direct + topic,fanout 用于广播,headers 基本不用
| 类型 | 路由规则 | 典型场景 | 注意点 |
|---|---|---|---|
| direct | RoutingKey 完全等于 BindingKey | 点对点任务分发、按日志级别路由(error/info) | 最常用;默认交换机就是 direct,用队列名当 RoutingKey |
| fanout | 忽略 RoutingKey,广播给所有绑定的队列 | 配置刷新、缓存失效广播、多系统同时感知某个事件 | 每个队列都会收到一份;队列多时放大写压力 |
| topic | BindingKey 支持通配:*
匹配一个词,#
匹配零个或多个词,用 . 分隔 |
按业务维度订阅:order.created.*、log.#.error |
最灵活;通配过度会导致路由变慢、误投 |
| headers | 按消息 header 的键值对匹配(x-match=all/any) |
需要多维度条件路由、RoutingKey 表达不了的场景 | 性能比 direct/topic 差,实际项目很少用 |
- 默认交换机:名字是空字符串
"",类型是 direct,自动绑定所有队列,RoutingKey 就是队列名。所以basic.publish("", "order.queue", body)可以直接发到order.queue。 - topic 的通配示例:topic 的
*和#区别很大,order.*能匹配order.created,但匹配不了order.created.v2;order.#两个都能匹配。 - fanout 的坑:如果某个队列没人消费,消息会在队列里堆积(每个队列都有一份),广播场景要配合 TTL / 队列长度上限。
- headers 的坑:
x-match=all要求所有 header 都匹配,any只要一个匹配;header 值类型要一致,否则匹配不上。
一条消息从 Producer 到 Consumer 完整经过哪些步骤?哪几步可能出现消息丢失?RabbitMQ 怎么保证消息不丢?生产端、Broker、消费端分别怎么做?
消息要经过「生产端 → Exchange 路由 → Queue 持久化 → 推送/拉取 → 消费 ack」五段,每一段都可能丢;RabbitMQ 的可靠性就是在这五段上分别加确认和持久化。
- Producer 发出,如果 Broker 挂了,连接断了,就会在这里进行丢失
- Exchange 路由不到任何队列,消息被静默丢弃,而且默认行为不报错,消息就丢了
- Queue 或 Message 没设持久化,Broker 重启后消息没了;或者设了持久化但还没刷盘就宕机。消息在 Queue 落盘中丢失了
- 投递给 Consumer 但还没 ack,消费者进程崩了,Broker 会把消息重投给别的消费者
解决方式如下
开启 Publisher Confirms 发布确认: 这是解决 Broker 没收到 的核心。将 Channel 设置为 confirm 模式后,Broker 会在消息被接收或者持久化后,回传一个 ack 给生产者。如果 Broker 内部处理失败,会回传 nack,生产者可以据此重发。注意,它是异步的
配合 mandatory + ReturnListener:只开 Confirm 还不够,因为消息可能到了 Exchange 但没找到匹配的 Queue,这时 Confirm 可能依然会返回 ack。解决办法是发送时设置
mandatory=true,并添加 ReturnListener。当消息无法路由时,Broker 会触发basic.return把消息退回给生产者,可以在 ReturnListener 中做一些处理持久化三件套:
- Exchange 持久化:声明时
durable=true。 - Queue 持久化:声明时
durable=true。 - Message 持久化:发送时设置
deliveryMode=2(PERSISTENT)。
只要其中任何一个没开,重启后消息都可能丢。但是如果全开了,RabbitMQ 默认是异步批量刷盘的,并非逐条实时落盘。所以必须配合生产端的 Publisher Confirms,因为 Confirm 的 ack 是在持久化消息落盘后才发出的,这样生产者才能真正确认消息安全了。
- Exchange 持久化:声明时
仲裁队列(Quorum Queue):它由一主多从副本组成,消息必须被超过半数的副本确认后才算提交成功,主副本宕机时会自动从副本中选举出新主
消费端关闭自动确认,改用手动 ACK:如果
autoAck=true,Broker 把消息一发出去就立刻删除,消费者这时候崩了,消息就真没了。设置autoAck=false后,消息投递出去会处于 Unacked 状态。只要消费者连接断开,RabbitMQ 发现这个 Channel 关了,就会自动把那条未确认的消息重新入队,投给其他消费者。只有在业务代码执行成功后,显式调用basicAck,Broker 才会真正删除消息。因为有了重投机制,消息可能会被重复消费。所以消费端必须实现幂等
publisher confirm 和事务(txSelect/txCommit)有什么区别?为什么生产上更推荐 confirm?
事务是同步阻塞的强一致方案,confirm 是异步回调的轻量方案;confirm 吞吐高得多,生产上默认用 confirm,事务只在对一致性要求极高、且量很小的场景才考虑。
对于 AMQP
事务,语义上也是多条消息要么都成功要么都失败,同步txCommit
要等 Broker
返回,期间生产者阻塞,所以吞吐很低,一次事务要等一次网络往返和落盘,但是
commit 失败要自己回滚、重发。每次 commit 都是一次同步往返,而且事务期间
Broker 要维护事务状态。吞吐通常只有 confirm 的几分之一。
对于 Publisher Confirm,它是异步,发完继续干别的,Broker 回调通知,因此吞吐高,可以批量 confirm,失败的话就是nack 后超时回调里重发,针对的是单条消息
confirm 的正确用法
- 异步 confirm:注册
ConfirmCallback,在回调里根据ack=true/false决定是否重发。注意回调是异步线程,不要在里面做重业务。 - 批量 confirm:一次发一批,等一批的 confirm,吞吐更高,但要维护 哪些消息还没确认 的映射。
- 必须处理 nack 和超时:只处理 ack 等于掩耳盗铃;nack 要重发或落库补偿,超时要靠定时任务扫 未确认消息表。
注意,confirm 只保证 Broker
接管,不保证消费成功,消费成功要靠消费端 ack。而且
confirm 不保证路由成功,路由失败要靠 mandatory + Return
mandatory
和 publish confirm
是一回事吗?消息路由不到队列时,你怎么知道?
不是一回事
confirm 回答 Broker 收没收到 ,mandatory 回答
路由到没到队列;路由失败要靠 mandatory=true +
ReturnsCallback或备用交换机才能感知。
- 备用交换机:给 Exchange 设置
alternate-exchange参数,路由失败的消息会被转发到备用交换机,再由它路由到 未路由消息队列 ,这样失败消息不会丢,可以被统一消费和告警。
怎么配(Spring AMQP)
1 | spring: |
1 | rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { |
死信队列(DLX)的触发条件有哪些?死信队列通常用来做什么?
死信队列是可观测、可重放、可告警的失败出口。死信也是一个需要被消费和治理的业务队列
消息变成死信只有三条,被拒绝且不重回队列;TTL 过期;队列超长被丢弃;
| 触发条件 | 具体行为 | 常见原因 |
|---|---|---|
reject / nack 且 requeue=false |
消费者明确拒绝,且不让它回队列 | 业务校验失败、消息格式错误、重试次数耗尽 |
| 消息 TTL 过期 | 消息在队列里超过 x-message-ttl 或消息级 expiration |
延迟队列(TTL + DLX 的核心机制) |
| 队列超长 | 达到 x-max-length / x-max-length-bytes,按
overflow 策略丢弃 |
生产远大于消费、消费者长时间挂掉 |
requeue=true的 nack 不会进死信,而是回到队列头或尾,这是毒消息无限循环的根源。要配合重试次数上限(Quorum Queue 的delivery-limit默认 20)使用。
死信队列的典型用途
- 失败隔离:处理失败 N 次的消息进死信,避免卡住主队列、拖垮整个分区。
- 延迟重试:消息 TTL 到期后经 DLX 转到 重试队列,实现分级延迟(1s/5s/30s/5min)。
- 人工补偿:死信队列配管理后台,支持查看、重放、丢弃。
- 告警:死信数量突增是最灵敏的故障信号之一。
注意
- 死信队列要持久化 + 有消费者,否则死信会堆积到把 Broker 撑爆。
- 死信消息要保留原始 exchange、routingKey、失败原因、重试次数
- 死信设计重放工具,支持按条件批量重放回原队列。
RabbitMQ 实现延迟队列的两种方式?TTL + DLX 的「队头阻塞」是什么?
RabbitMQ 原生不支持延迟消息,必须靠两种方式模拟:TTL + DLX 或 延迟插件。
TTL + DLX
TTL 是消息存活时间,可以在队列级设置
x-message-ttl,也可以在消息级设置;DLX 是死信交换机。把两者接起来,消息先进一个等待队列,设 TTL + 指向 DLX,等 TTL 到期变成死信,经 DLX 转到真正的消费队列。1
2
3
4Producer ──▶ delay.queue(x-message-ttl=30min, x-dead-letter-exchange=real.exchange)
│ 消息在队列里等待 30 分钟
▼ TTL 到期
DLX ──▶ real.queue ──▶ Consumer只用 RabbitMQ 原生能力,但是会涉及到队头阻塞问题,而且不同延迟要拆多个队列,因为队列级 TTL 是统一的
队头阻塞是 RabbitMQ 检查 TTL 过期是从队头开始、顺序扫描的,只有队头消息过期被移除后,才会继续检查下一条。它不会跳过队头去检查后面的消息。
1
2队头:消息 A,TTL = 60s ← 先进队,长延迟
消息 B,TTL = 5s ← 后进队,短延迟B 必须等 A 过期(60 秒)之后,才有机会被检查到。因为 A 卡在队头没走,扫描就停在 A 这里。后进队的短延迟消息,被先进队的长延迟消息挡住了。延迟越长、消息越多,阻塞越严重。只有 队列内所有消息 TTL 相同 时,队头阻塞才不会发生。这也就是为什么不同延迟要拆多个队列
延迟插件
装
rabbitmq_delayed_message_exchange插件,声明一种新类型交换机x-delayed-message,发送时在 header 里设x-delay1
2
3
4
5
6
7
8Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
channel.exchangeDeclare("delay.exchange", "x-delayed-message", true, false, args);
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.headers(Map.of("x-delay", 30 * 60 * 1000)) // 30 分钟
.build();
channel.basicPublish("delay.exchange", "order.delay", props, body);- 延迟消息存在单节点的 Mnesia 表里,不跨集群复制(消息到达 Exchange 后暂存在 Exchange 内部)
Kafka 原生不支持延迟消息。常见做法是 多级延迟 Topic + 定时任务把到期消息搬到目标 Topic。本质和 RabbitMQ 拆队列思路类似,但要自己写搬运逻辑。
RocketMQ 原生支持延迟等级
为什么补偿任务用 @Scheduled 而不用延迟消息?
- 延迟消息适合确定时间点的单次触发,补偿任务是对已失败操作的兜底重试,触发时机不确定、可能多次重试、需要幂等和审计,用 补偿任务表 + @Scheduled 扫表 + 状态机 能够完全控制
RabbitMQ 能保证顺序吗?多个消费者时怎么保证同一业务的消息有序?
RabbitMQ 只保证单队列、单消费者、不 requeue 时的顺序;多消费者并发消费同一队列必然乱序,要按业务键拆队列或消费端串行化。
顺序在哪些环节会被破坏
| 环节 | 破坏原因 |
|---|---|
| 生产端 | 多线程并发发同一业务消息;失败重试导致后发的先到 |
| Broker 端 | 同一业务的消息被路由到多个队列;队列在集群节点间迁移 |
| 消费端 | 多消费者/多线程并发消费;处理耗时不同;nack 重回队列 |
RabbitMQ 的顺序保证边界
- 单个 Queue、单个 Consumer、消息不 requeue:FIFO 有序。
- 多个 Consumer 消费同一个 Queue:无序,因为消息会被轮询分给不同消费者,谁先处理完谁先 ack。
x-single-active-consumer=true:队列同时只有一个活跃消费者,能保序但牺牲并发。
多消费者下保序的方案常用 按业务键拆队列,每个队列配一个消费者。同一订单的消息进同一队列,天然有序;不同订单并行。
Spring
AMQP 里 @RabbitListener 的 ack 模式怎么配?AUTO 和 MANUAL
的区别?
在 Spring AMQP 中,@RabbitListener
的确认模式由容器工厂的 AcknowledgeMode 控制
全局配置
1
2
3
4
5spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual # 可选 none / auto / manual代码中自定义容器工厂
1
2
3
4
5
6
7
8
9
public SimpleRabbitListenerContainerFactory myFactory(
SimpleRabbitListenerContainerFactoryConfigurer configurer,
ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
configurer.configure(factory, connectionFactory);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
return factory;
}然后在
@RabbitListener(queues = "xxx", containerFactory = "myFactory")中指定使用。
AUTO 与 MANUAL 的核心区别
| AUTO(默认) | MANUAL | |
|---|---|---|
| 谁负责 ack/nack | 容器(Spring) | 开发者(你的代码) |
| 触发时机 | 监听方法正常返回 → 容器自动 ack;抛出异常 → 容器自动 nack | 你在业务逻辑中显式调用
channel.basicAck() 或 basicNack() |
| 代码侵入性 | 无,业务方法不用关心 Channel | 需要方法签名注入 Channel 和
deliveryTag,手动写确认代码 |
| 适用场景 | 绝大多数常规消费,异常处理交给容器默认策略 | 需要异步处理、批量确认,或希望精细控制确认时机(如等数据库事务提交后再 ack) |
Spring 的
AUTO不等于 RabbitMQ 原生意义上的autoAck。Spring 的AUTO是 容器帮你判断业务是否成功;而真正的 投递即删除、不管消费结果 在 Spring 里叫NONE(对应Channel.basicConsume的autoAck=true)。不要混淆。
开启 MANUAL 后,监听方法需要接收 Channel
和消息属性中的
deliveryTag,然后在业务处理完成后自行决定确认策略:
1 |
|
- 在
MANUAL模式下,如果业务方法正常返回但你没有调用 ack,消息会一直处于 Unacked 状态,直到连接断开后被重新投递 basicAck的第二个参数multiple通常传false(只确认当前这一条);传true会批量确认该 tag 及之前所有未确认的消息。- 如果你使用了异步返回类型,如
CompletableFuture,Spring 会自动将确认模式调整为MANUAL,由异步操作的完成情况来触发 ack/nack,避免异步任务还没跑完,消息就被容器 ack 掉了。
消息体怎么序列化?为什么推荐 JSON 而不是 Java 原生序列化?消费端 DTO 变更怎么兼容?
在 Spring AMQP 中,消息体默认会走 JDK 序列化,但这在生产环境基本不可取。要换成 JSON,只需在配置类里注册一个转换器 Bean:
1 |
|
这个 Bean 会被 RabbitTemplate
和监听容器工厂自动识别并使用。
JDK 序列化有很多硬伤,在消息队列场景下会被放大:
- 体积大:生成的字节流远大于等价的 JSON,浪费网络带宽和 Broker 存储。
- 可读性差:二进制流无法在 RabbitMQ 控制台直接查看,出问题时排查困难。
- 安全风险:反序列化不可信来源的 Java 对象可能触发远程代码执行漏洞。Spring AMQP 文档也明确建议不要依赖 Java 序列化。
AMQP 本身是线路级协议,用 Java 私有序列化等于把消息绑死在 Java 生态里,失去了跨平台的优势。
那么消费端 DTO 变更怎么兼容,因为生产者和消费者通常不同步升级,DTO 的字段增删不可避免,需要从两个层面处理。
让 Jackson 容忍变化
在 DTO 类上加
@JsonIgnoreProperties(ignoreUnknown = true),或者全局配置ObjectMapper:1
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
遵循 只加不改不删 的约定
prefetch(QoS)是什么?设大了/设小了分别有什么问题?Quorum Queue 为什么不支持 global QoS?
Prefetch(QoS)是 RabbitMQ
的消费端流量控制机制,用来限制单个消费者同时持有 未确认消息
的上限,basic.qos(prefetch_count=N) 表示 Broker 最多推 N
条未确认消息;设大了消费者内存高、负载不均,设小了吞吐低。Quorum Queue
只支持每消费者的 prefetch,不支持 channel 级 global
QoS。
RabbitMQ 的 prefetch 在行为上有一个重要特点:它默认是 每消费者 级别,而不是 每信道 级别。虽然 AMQP 规范里
basic.qos的global参数本意是信道级,但 RabbitMQ 做了偏离规范的解释,global=false(默认)时,限制独立作用于该信道上的每一个新消费者。你调用basicQos(10),意思是“这个信道上的每个消费者,各自最多同时持有 10 条未 ack 的消息”RabbitMQ 官方已经将 global QoS 整体废弃并移除了,Quorum Queue 只是率先严格执行了这个限制
Kafka
Kafka 为什么弃用 ZooKeeper?KRaft 是什么算法,能讲解一下吗?KRaft 解决了什么问题?
ZooKeeper 在旧架构中负责 Controller 选举、Broker 注册、Topic 元数据管理等关键任务。但这带来了几个难以回避的问题,但是它必须独立维护一套 ZooKeeper 集群,而且 ZooKeeper 的写性能会随节点数增加而下降,限制了 Kafka 集群的规模。最重要的是,元数据分散在 ZooKeeper 和 Kafka 两个系统里,它们之间的状态同步并非强一致,在故障切换时容易产生不一致问题。
KRaft 是 Raft 共识协议的一个变种
- 集群中一部分节点被指定为 Controller,专门负责管理元数据;其余节点作为 Broker 处理数据读写。Controller 之间通过 Raft 协议选举出一个 Leader,只有 Leader 处理元数据写入请求
- 所有元数据变更都被写入一个内部 Topic,这个 Topic 只有一个分区。Controller Leader 将变更作为 事件 追加到日志中,只有被多数派 Controller 确认后,才认为提交成功。
KRaft 简化架构与运维,突破扩展性瓶颈,提升一致性与恢复速度。
从 Kafka 4.0 开始,KRaft 已成为唯一模式,ZooKeeper 被正式移除。
画一下 Kafka 的架构:Broker、Topic、Partition、Replica、Leader/Follower、ISR、HW、LEO、Offset 的关系。讲解一下 Kafka 的架构内容。
层级关系可以这样大概概括
1 | Cluster |
| 概念 | 含义 | 关键点 |
|---|---|---|
| Broker | 一个 Kafka 服务节点 | 存分区副本、处理生产和消费请求 |
| Topic | 逻辑上的消息类别 | 只是名字,真正的存储和并行单位是 Partition |
| Partition | Topic 的物理分片,一个有序、追加写的日志 | 并行和有序的单位;分区数决定消费并发上限 |
| Replica | Partition 的副本,分布在不同 Broker | 副本数决定容错能力,生产建议 3 |
| Leader | 副本中的主,处理所有读写 | 生产者和消费者只和 Leader 交互(老版本) |
| Follower | 副本中的从,主动向 Leader 拉数据 | 不处理读写,只做同步和故障接管 |
| ISR | In-Sync Replicas,与 Leader 保持同步的副本集合 | acks=all 要求 ISR 全部写入;Leader 挂了从 ISR 选新
Leader |
| HW | High Watermark,所有 ISR 都复制到的位置 | 消费者只能读到 HW 之前,代表大家都写到哪了,保证不读到未提交的数据,取 ISR 的最小 LEO。 |
| LEO | Log End Offset,日志末端位置 | 每个副本都有自己的 LEO,代表我写到哪了;Leader 的 LEO 是下一条消息的写入位置 |
| Offset | 消息在 Partition 内的唯一递增编号 | 消费者用 offset 记录消费进度;offset 只在分区内有意义 |
消费者和副本的关系
- 消费者读 Leader,一个 Partition 分片在同一个 消费者组 内只能被一个 Consumer 消费,这是并行度的上限
一条消息的写入流程
1 | Producer |
Kafka 为什么吞吐高?消息在磁盘上怎么存?怎么根据 offset 找到消息?
为什么吞吐高
- 顺序写磁盘:磁盘顺序写接近内存随机写的速度;Kafka 只追加,不随机改
- 页缓存:读写都先经过操作系统页缓存,避免 JVM 堆内缓存 GC 和 double-buffer
- 零拷贝:消费时数据从页缓存直接送到网卡,不经过用户态,省两次拷贝和上下文切换
- 批量 + 压缩:Producer 攒批发送、Broker 批量落盘、Consumer 批量拉取;
- 分区并行:一个 Topic 多分区,分布在多 Broker,读写天然水平扩展
这样,虽然单条消息延迟不低,但是批量再加上分摊,吞吐就上来了,这也是为什么 Kafka 适合高吞吐流处理,而不适合低延迟的单条业务消息
磁盘存储结构大概如下
1 | kafka-logs/ |
| 文件 | 作用 |
|---|---|
.log |
实际消息,按 offset 顺序追加 |
.index |
稀疏索引,记录「每 N 条消息的 offset → 文件物理位置」,不是每条都记 |
.timeindex |
时间戳 → offset,支持按时间查找消息(如
offsetsForTimes) |
leader-epoch-checkpoint |
Leader 任期信息,用于副本截断 |
根据 offset 找消息
- 用
offset二分查找.index,找到 不大于目标 offset 的最大索引项 - 拿到该索引项对应的物理位置
- 从 .log 的该位置开始顺序扫描,直到找到目标 offset
Kafka
怎么保证消息不丢?acks、retries、min.insync.replicas、replication.factor
怎么配?
| 环节 | 配置 | 作用 |
|---|---|---|
| Producer | acks=all |
要求 ISR 全部副本写入才返回 |
| Producer | retries=Integer.MAX_VALUE(或较大值) |
瞬时故障自动重试 |
| Producer | enable.idempotence=true |
重试不产生重复(PID + sequence) |
| Producer | delivery.timeout.ms |
重试总超时,避免无限重试 |
| Broker | replication.factor=3 |
每个分区 3 副本,容忍 1 个 Broker 挂 |
| Broker | min.insync.replicas=2 |
至少 2 个副本确认才算成功,防止 ISR 缩到 1 时仍写入 |
| Broker | unclean.leader.election.enable=false |
不允许非 ISR 副本当 Leader,避免丢数据 |
| Consumer | enable.auto.commit=false |
手动提交,处理成功后再提交 |
ISR 缩到 1 时会发生什么
min.insync.replicas=2时,ISR 只剩 Leader,生产者会收到NotEnoughReplicasException,写入失败。- 这是故意的,宁可写失败,也不接受可能丢数据的写入。
- 如果业务允许丢数据换可用性,才考虑
min.insync.replicas=1或开启 unclean 选举。
消费端不丢
- 关闭自动提交,处理成功后再
commitSync。 - 如果先提交后处理,处理失败就丢了(offset 已前移)。
- 处理失败要重试或进死信。
Kafka
的幂等生产者怎么实现的?PID、epoch、sequence number
各是什么?它的边界在哪?
幂等生产者用「PID + epoch + 每个分区的 sequence number」让 Broker 能识别并丢弃重复写入;它只保证单分区、单会话内的不重,跨分区跨会话要靠事务。
| 概念 | 含义 | 作用 |
|---|---|---|
| PID(Producer ID) | Broker 分配给 Producer 的唯一 ID | 标识「哪个生产者」 |
| Epoch | PID 的版本号,Producer 重启/重新初始化时递增 | 防止旧会话的延迟请求污染新会话 |
| Sequence Number | 每个 <PID, Partition> 维度独立递增的序号 |
Broker 用它判断「这条消息是新的还是重试的」 |
开启方式
1 | enable.idempotence=true # 开启幂等(默认 true in 3.0+) |
工作流程
1 | Producer 初始化 → 向 Broker 申请 PID(+ epoch) |
为什么需要 epoch
假设 Producer 重启后复用了同一个 PID,旧会话还有一条延迟的重试请求到达 Broker。如果没有 epoch,Broker 会把它当成新消息;有了 epoch,Broker 发现 epoch 是旧的,直接拒绝。epoch 是「会话代际」的隔离。
边界就是
- 不能替代消费幂等,因为生产端不重 ≠ 消费端不重
- 而且不跨系统,只保证 Kafka 内部不重
- 它的幂等是单分区,单会话的,幂等只在单个 Partition 内生效,跨分区不保证,Producer 重启后 PID 会变,旧会话的幂等状态失效
Kafka
事务消息怎么实现?isolation.level=read_committed
的作用?
Kafka 事务由「Transaction Coordinator + transactional.id +
事务标记(commit/abort marker)」实现,保证 多分区写入 + offset
提交 的原子性;消费者设 isolation.level=read_committed
后,只能读到已提交事务的消息,未提交/已中止的消息会被过滤。
| 组件 | 作用 |
|---|---|
| Transaction Coordinator | 每个 transactional.id 对应一个
Coordinator,管理事务状态(Ongoing/PrepareCommit/CompleteCommit
等) |
| transactional.id | 生产者配置的稳定 ID,跨会话标识同一个事务生产者;重启后用它恢复/中止未完成事务 |
| 事务标记(marker) | 写入分区日志的特殊控制消息:COMMIT /
ABORT,告诉消费者这个事务的边界 |
__transaction_state |
内部 Topic,存事务状态 |
消费者端的隔离级别如下
| 级别 | 行为 |
|---|---|
| read_uncommitted(默认) | 能读到未提交事务的消息(可能读到最终被 abort 的数据) |
| read_committed | 只读到 已 commit 的事务消息;遇到未完成事务会阻塞到事务结束,abort 的消息被过滤 |
边界
- Kafka 事务只保证 Kafka 内部,不保证「业务数据库 + Kafka」的原子性
消费者组的分配机制?一个 Partition 能被同组几个消费者消费?Rebalance 的触发条件和危害?
同一个 消费者组 内,一个 Partition 只能被一个 消费者 消费,这是并行度上限;Rebalance 在成员变化、订阅变化、分区数变化时触发,老协议是 Stop-The-World,会造成消费暂停、重复消费和延迟抖动;新协议(KIP-848)改为服务端协调 + 增量 rebalance。
消费者组的分配机制
- Consumer Group 里的 Consumer 通过 Group Coordinator(某个 Broker)协调。
- 分配策略(
partition.assignment.strategy):- RangeAssignor:按 Topic 维度平均分,可能出现倾斜。
- RoundRobinAssignor:所有 Topic 的分区轮询分配,更均匀。
- StickyAssignor:尽量均匀,同时尽量保留上次分配,减少 rebalance 抖动。
- CooperativeStickyAssignor:配合增量 rebalance,避免全量撤销。
- 一个 Partition 在同一 Group 内只会分给一个 Consumer;不同 Group 可以各自消费同一个 Partition(广播语义)。
Rebalance 的触发条件
- Consumer 加入(启动、扩容)。
- Consumer 离开(宕机、主动 close、心跳超时)。
- 订阅的 Topic 变化(正则订阅匹配到新 Topic)。
- Topic 的分区数变化(扩容分区)。
Offset
自动提交和手动提交的区别?commitSync 和
commitAsync 怎么选?怎么避免重复消费?
auto.offset.reset 的 earliest/latest/none
分别什么行为?消费者组第一次启动、offset 过期时会怎样?
自动提交是「定时批量提交」,可能丢也可能重;手动提交是「处理成功后自己提交」
| 自动提交 | 手动提交 |
|---|---|
enable.auto.commit=true |
enable.auto.commit=false |
auto.commit.interval.ms 定时提交(默认 5s) |
自己调 commitSync / commitAsync |
| 可能:还没处理完就提交了,宕机后消息不再投递 | 不会(处理成功才提交) |
| 可能:处理完还没到提交时间就宕机 | 可能:处理成功、提交前宕机 |
| 允许丢的日志/监控场景 | 业务消息 |
commitSync 可靠但阻塞、commitAsync
吞吐好但要处理失败;
| 维度 | commitSync | commitAsync |
|---|---|---|
| 阻塞 | 阻塞,等 Broker 返回 | 不阻塞,回调通知 |
| 可靠性 | 高,失败会重试(可重试异常) | 失败不会自动重试,要自己处理 |
| 吞吐 | 低 | 高 |
| 适用 | 关闭前、关键 offset | 常规消费循环 |
auto.offset.reset 只在「没有 offset 或 offset
越界」时生效:earliest 从头、latest
从尾、none 直接抛异常。
| 值 | 行为 |
|---|---|
| earliest | 没有 offset 或 offset 越界时,从最早的消息开始读 |
| latest | 从最新(队尾)开始读,只消费新消息 |
| none | 没有 offset
时抛异常(NoOffsetForPartitionException),由应用自己处理 |
避免重复消费的需要 at-least-once + 消费端幂等。
Kafka 怎么保证顺序?分区数增加会怎样?消费端多线程怎么保序?
Kafka 只保证单 Partition 内有序;生产端用 key
把同一业务的消息路由到同一 Partition;增加分区会改变
hash(key) % partitionNum 的结果,导致同一 key
漂到新分区、顺序断裂;消费端多线程要按 key 串行化。
生产端保序
1 | // 同一个 orderId 永远进同一个 partition |
- Kafka 默认用
murmur2(key) % numPartitions选分区;key 相同 → 分区相同 → 分区内有序。 - 不指定 key 时轮询/粘性分区,消息会散到不同分区,无序。
生产端还会破坏顺序的两个地方
- 重试:消息 A 发送失败进入重试,消息 B 成功,B
先落盘。解决:
max.in.flight.requests.per.connection=1,或开启幂等生产者(允许最多 5 个 in-flight 且保序)。 - 多线程发送:多个线程同时发同一 key 的消息,顺序取决于谁先拿到锁。解决:同一 key 固定到同一发送线程。
分区数增加的影响
1 | 原来:partition = hash(orderId) % 4 |
- 扩容分区会破坏 key → partition 的映射,这是 Kafka 顺序性的一个硬伤。
- 缓解:扩容时暂停生产/消费,等积压消费完再扩;或业务上接受扩容窗口内的乱序。
- 更好就是一开始就把分区数规划够,避免频繁扩容。
消费端多线程保序
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 单线程消费 | 一个消费者线程处理所有分区 | 简单,但吞吐低 |
| 按分区单线程 | 每个分区一个消费线程 | 分区内有序,分区间并行;线程数 = 分区数 |
| 按 key 哈希到固定线程 | 多线程消费,但同一 key 的消息进同一个内存队列/线程 | 并行度高,实现复杂,要处理队列满、重启丢内存 |
最常见的做法
- 一个 Consumer 实例内,每个分区一个处理线程或按 key 哈希到固定线程池。
- 配合幂等,即使乱序也能保证最终结果正确。
消息队列实践、开发场景相关
MQ 和 线程池异步 / @Async / CompletableFuture 的边界在哪?你项目里已经用了线程池,为什么还需要 MQ?什么情况下你会在有线程池的基础上考虑使用 MQ?
线程池解决的是 进程内、不要求可靠、不跨重启 的异步
MQ 解决的是 跨进程、要可靠投递、要削峰、要重放 的异步
所以说,进程内用线程池,跨服务高可靠才上 MQ。
例如对于,外部 API 调用慢,但调用结果不影响主流程的强一致,的情况,可以引入线程池,它们的任务在同一个 JVM 内、可以接受丢失、不需要跨服务。线程池是最轻的异步解法。
如果涉及到跨服务同时高可靠,削峰重放等流量控制,多消费者等,就需要上 MQ 了


































