消息队列基础

为什么系统里要用消息队列?请结合「跨服务最终一致性」说说,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 的状态。发送失败或超时则重试。异步发送需要在回调里检查结果。
  • Broker:持久化 + 多副本

    消息到了 Broker 丢的话一般是 Broker 没来得及落盘就宕机的情况

    • 持久化:消息先写内存,再异步或同步刷到磁盘。RocketMQ 通过 flushDiskType=SYNC_FLUSH 开启同步刷盘。RabbitMQ 需要 Exchange、Queue、Message 三者都设置持久化,缺一不可。
    • 多副本:单机持久化挡不住磁盘损坏。Kafka 依赖分区副本机制。RocketMQ 主也有从同步复制
  • 消费端:处理成功后再确认

    关闭自动确认,并且必须在业务逻辑执行成功之后才确认。

    • Kafka:enable.auto.commit=false,在消息处理完成后手动提交 offset。
    • RabbitMQ:acknowledge-mode=manual,业务成功后调用 basicAck,失败则 basicNack 让消息重回队列或入死信。
    • RocketMQ:消费成功后返回 CONSUME_SUCCESS,失败返回 RECONSUME_LATER 触发重试。

    但这里有一个无法绕开的矛盾:如果消费者处理完业务、还没来得及 ack 就崩溃了,Broker 会认为消息没被消费,重新投递。这就是重复的来源。所以消费端“不丢”的代价,就是接受重复。

三段机制做满,只能保证消息不丢,但代价是可能重复。因此消费端必须做幂等,用业务唯一键或 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
2
3
4
5
6
7
8
9
10
11
Producer
└─ 建立 Connection → 开 Channel
└─ basic.publish(exchange, routingKey, body)
└─ Exchange 按类型 + Binding 匹配
├─ 命中 Queue A → 入队
├─ 命中 Queue B → 入队(fanout/topic 可以一对多)
└─ 一个都没命中 → 丢弃(除非 mandatory=true 触发 basic.return)
Consumer
└─ basic.consume / basic.get
└─ 处理业务 → basic.ack(或 nack/reject)
└─ Broker 删除该消息
  • 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 是在持久化消息落盘后才发出的,这样生产者才能真正确认消息安全了。

  • 仲裁队列(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
2
3
4
5
6
spring:
rabbitmq:
publisher-confirm-type: correlated # 开 confirm,带 correlation data
publisher-returns: true # 开 return
template:
mandatory: true # 路由失败时 return 而不是静默丢弃
1
2
3
4
5
6
7
8
9
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) {
// Broker 没收到:重发 / 落库补偿 / 告警
}
});

rabbitTemplate.setReturnsCallback(returned -> {
// 路由失败:记录 exchange、routingKey、replyText,转人工或备用交换机
});

死信队列(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
    4
    Producer ──▶ 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-delay

    1
    2
    3
    4
    5
    6
    7
    8
    Map<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
    5
    spring:
    rabbitmq:
    listener:
    simple:
    acknowledge-mode: manual # 可选 none / auto / manual
  • 代码中自定义容器工厂

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Bean
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
@RabbitListener(queues = "order.queue")
public void handle(OrderMessage msg, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
try {
// 业务处理(如落库、调外部接口)
processOrder(msg);

// 处理成功,手动 ack
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 处理失败,拒绝消息。第三个参数 true 表示重新入队
channel.basicNack(deliveryTag, false, true);
}
}
  • 在 MANUAL 模式下,如果业务方法正常返回但你没有调用 ack,消息会一直处于 Unacked 状态,直到连接断开后被重新投递
  • basicAck 的第二个参数 multiple 通常传 false(只确认当前这一条);传 true 会批量确认该 tag 及之前所有未确认的消息。
  • 如果你使用了异步返回类型,如 CompletableFuture,Spring 会自动将确认模式调整为 MANUAL,由异步操作的完成情况来触发 ack/nack,避免异步任务还没跑完,消息就被容器 ack 掉了。

消息体怎么序列化?为什么推荐 JSON 而不是 Java 原生序列化?消费端 DTO 变更怎么兼容?

在 Spring AMQP 中,消息体默认会走 JDK 序列化,但这在生产环境基本不可取。要换成 JSON,只需在配置类里注册一个转换器 Bean:

1
2
3
4
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}

这个 Bean 会被 RabbitTemplate 和监听容器工厂自动识别并使用。

JDK 序列化有很多硬伤,在消息队列场景下会被放大:

  1. 体积大:生成的字节流远大于等价的 JSON,浪费网络带宽和 Broker 存储。
  2. 可读性差:二进制流无法在 RabbitMQ 控制台直接查看,出问题时排查困难。
  3. 安全风险:反序列化不可信来源的 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
2
3
4
5
6
7
8
9
10
11
12
Cluster
├─ Broker 0 ← 一个 Kafka 节点
├─ Broker 1
└─ Broker 2
│
└─ Topic: order-events ← 逻辑主题
├─ Partition 0 ← 物理分片,有序、不可变
│ ├─ Leader (Broker 0) ← 读写都走它
│ ├─ Follower (Broker 1) ← 只拉取同步
│ └─ Follower (Broker 2)
├─ Partition 1
└─ Partition 2
概念 含义 关键点
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
2
3
4
5
6
7
Producer
└─ 按 key hash / 轮询选 Partition
└─ 发给该 Partition 的 Leader
├─ Leader 追加到本地日志(LEO+1)
├─ Follower 主动 fetch 拉取
├─ ISR 全部复制到 → HW 前移
└─ 按 acks 配置返回确认(0 / 1 / all)

Kafka 为什么吞吐高?消息在磁盘上怎么存?怎么根据 offset 找到消息?

为什么吞吐高

  • 顺序写磁盘:磁盘顺序写接近内存随机写的速度;Kafka 只追加,不随机改
  • 页缓存:读写都先经过操作系统页缓存,避免 JVM 堆内缓存 GC 和 double-buffer
  • 零拷贝:消费时数据从页缓存直接送到网卡,不经过用户态,省两次拷贝和上下文切换
  • 批量 + 压缩:Producer 攒批发送、Broker 批量落盘、Consumer 批量拉取;
  • 分区并行:一个 Topic 多分区,分布在多 Broker,读写天然水平扩展

这样,虽然单条消息延迟不低,但是批量再加上分摊,吞吐就上来了,这也是为什么 Kafka 适合高吞吐流处理,而不适合低延迟的单条业务消息

磁盘存储结构大概如下

1
2
3
4
5
6
7
kafka-logs/
└─ order-events-0/ ← Topic-Partition 目录
├─ 00000000000000000000.log ← 数据文件(消息本体)
├─ 00000000000000000000.index ← 偏移索引(offset → 物理位置)
├─ 00000000000000000000.timeindex ← 时间索引(timestamp → offset)
├─ 00000000000000012345.log ← 下一个 Segment(按大小/时间滚动)
└─ ...
文件 作用
.log 实际消息,按 offset 顺序追加
.index 稀疏索引,记录「每 N 条消息的 offset → 文件物理位置」,不是每条都记
.timeindex 时间戳 → offset,支持按时间查找消息(如 offsetsForTimes)
leader-epoch-checkpoint Leader 任期信息,用于副本截断

根据 offset 找消息

  1. 用 offset 二分查找 .index,找到 不大于目标 offset 的最大索引项
  2. 拿到该索引项对应的物理位置
  3. 从 .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
2
3
4
enable.idempotence=true          # 开启幂等(默认 true in 3.0+)
acks=all # 幂等要求
retries=Integer.MAX_VALUE # 幂等要求
max.in.flight.requests.per.connection=5 # 幂等允许的最大值

工作流程

1
2
3
4
5
6
Producer 初始化 → 向 Broker 申请 PID(+ epoch)
└─ 发消息时带 (PID, epoch, partition, seq)
└─ Broker 为每个 <PID, Partition> 维护「已收到的最大 seq」
├─ seq == 期望值 → 接收,更新最大 seq
├─ seq < 期望值 → 重复,丢弃(返回成功)
└─ seq > 期望值+1 → 有消息丢失,报 OutOfOrderSequenceException

为什么需要 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 的触发条件

  1. Consumer 加入(启动、扩容)。
  2. Consumer 离开(宕机、主动 close、心跳超时)。
  3. 订阅的 Topic 变化(正则订阅匹配到新 Topic)。
  4. 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
2
3
// 同一个 orderId 永远进同一个 partition
ProducerRecord<String, String> record =
new ProducerRecord<>("order-events", orderId, payload);
  • Kafka 默认用 murmur2(key) % numPartitions 选分区;key 相同 → 分区相同 → 分区内有序。
  • 不指定 key 时轮询/粘性分区,消息会散到不同分区,无序。

生产端还会破坏顺序的两个地方

  1. 重试:消息 A 发送失败进入重试,消息 B 成功,B 先落盘。解决:max.in.flight.requests.per.connection=1,或开启幂等生产者(允许最多 5 个 in-flight 且保序)。
  2. 多线程发送:多个线程同时发同一 key 的消息,顺序取决于谁先拿到锁。解决:同一 key 固定到同一发送线程。

分区数增加的影响

1
2
3
4
原来:partition = hash(orderId) % 4
扩容到 8 个分区后:partition = hash(orderId) % 8
→ 同一个 orderId 可能从 partition 2 漂到 partition 6
→ 扩容前后同一订单的消息落在不同分区,顺序断裂
  • 扩容分区会破坏 key → partition 的映射,这是 Kafka 顺序性的一个硬伤。
  • 缓解:扩容时暂停生产/消费,等积压消费完再扩;或业务上接受扩容窗口内的乱序。
  • 更好就是一开始就把分区数规划够,避免频繁扩容。

消费端多线程保序

方案 做法 优缺点
单线程消费 一个消费者线程处理所有分区 简单,但吞吐低
按分区单线程 每个分区一个消费线程 分区内有序,分区间并行;线程数 = 分区数
按 key 哈希到固定线程 多线程消费,但同一 key 的消息进同一个内存队列/线程 并行度高,实现复杂,要处理队列满、重启丢内存

最常见的做法

  • 一个 Consumer 实例内,每个分区一个处理线程或按 key 哈希到固定线程池。
  • 配合幂等,即使乱序也能保证最终结果正确。

消息队列实践、开发场景相关

MQ 和 线程池异步 / @Async / CompletableFuture 的边界在哪?你项目里已经用了线程池,为什么还需要 MQ?什么情况下你会在有线程池的基础上考虑使用 MQ?

线程池解决的是 进程内、不要求可靠、不跨重启 的异步

MQ 解决的是 跨进程、要可靠投递、要削峰、要重放 的异步

所以说,进程内用线程池,跨服务高可靠才上 MQ。

例如对于,外部 API 调用慢,但调用结果不影响主流程的强一致,的情况,可以引入线程池,它们的任务在同一个 JVM 内、可以接受丢失、不需要跨服务。线程池是最轻的异步解法。

如果涉及到跨服务同时高可靠,削峰重放等流量控制,多消费者等,就需要上 MQ 了

消息队列的 削峰填谷 容量怎么估算?给你一个「百万级数据 + 事件集中上报」的场景,你怎么定分区数/消费者数/队列长度?

消息重复消费的根因有哪些?从 MQ 的角度如何解决幂等问题?业务唯一键方案和 MQ 的幂等是什么关系?本地消息表等方案如果改成 MQ 方案,如何改?

「先写库再发消息」和「先发消息再写库」分别会出什么问题?怎么保证数据库和 MQ 的一致性?

什么是 Transactional Outbox?它和 本地消息表 是什么关系?怎么保证不重复、不丢?

消费端幂等怎么设计?唯一键、去重表、Redis、状态机分别放在哪一层?

消费失败怎么重试?重试次数、退避、死信、人工补偿怎么处理?死信队列的消息最后怎么处理?

MQ 的消息体设计要注意什么?