这是一篇面向后端面试的消息队列复习稿。先看每节的知识点,再用 Q/A/W 自测:A 是面试时可以直接说的回答,W 用故障场景解释为什么要这样设计。
一、为什么要用消息队列
知识点
- 基本角色:生产者负责发送消息,Broker 负责接收、存储和投递,消费者负责处理;Topic 用来划分业务主题,队列或分区承载实际消息。
- 消费者组:同一组内的消费者共同分担消息,不同组可以各自消费一份;同组有效并发通常受队列或分区数量限制,消费者更多不代表吞吐一定更高。
- 解耦:上游只发布业务事件,不需要同步调用所有下游;新增积分、短信或风控服务时,可以通过订阅消息接入。
- 异步:下单主链路只保留必须同步完成的操作,发券、积分、通知等任务交给消费者处理,缩短接口等待时间。
- 削峰:突发请求先进入队列,下游按自身处理能力消费;队列只能缓冲流量,长期生产速度大于消费速度仍会积压。
- 引入成本:系统多了一条异步链路,要处理消息丢失、重复、乱序、积压和最终一致性,监控与故障排查也会更复杂。
面试问答
- Q1:为什么要使用消息队列?(高频)
A: 主要解决解耦、异步和削峰。比如下单后要发券、加积分、发短信,订单服务只发一条事件,下游各自消费,主链路会更短,突发流量也能先进入队列。但 MQ 会引入丢失、重复、积压和一致性问题,业务简单、必须同步拿结果时没必要硬上。
W: 假设订单服务同步调用三个下游,其中一个接口变慢,整个下单请求都会被拖住;再加一个下游,还要修改订单服务。改成消息后,订单服务只依赖 Broker,下游可以独立扩容和失败重试。代价是调用结果不再当场确定,必须补上幂等、监控、对账和补偿。
二、消息可靠性:怎样保证不丢
知识点
- 生产端确认:发送后要读取 Broker 的确认结果,超时或失败进入重试;关键业务可以把待发送事件落库,由后台任务持续补发。
- Broker 持久化:消息写入磁盘并复制到其他副本后再确认,可靠性更高,但确认范围越大,写入延迟和不可用概率也会上升。
- Kafka 确认策略:
acks=all会等待当前同步副本集合确认;配合min.insync.replicas,同步副本不足时拒绝写入,用可用性换更强的持久性。 - RabbitMQ 双向确认:Publisher Confirm 只确认发布者到 Broker,Consumer Acknowledgement 只确认 Broker 到消费者,两者负责不同链路,不能互相替代。
- 消费端提交:业务处理成功后再确认消息或提交 offset;若先提交再处理,消费者在中间宕机会让消息被跳过。
- 端到端可靠:生产确认、Broker 持久化和消费确认缺一不可;MQ 接受成功,只代表消息进入中间件,不代表下游业务已经完成。
面试问答
- Q1:如何保证消息不丢失?(高频中的高频)
A: 按生产者、Broker、消费者三段回答。生产者开启发送确认,失败重试,关键消息落库补发;Broker 做持久化和副本复制,Kafka 可用 acks=all 配合最小同步副本数;消费者在业务成功后再确认。最后用监控、对账和补偿兜底。
W: 如果只保证生产者发送成功,Broker 宕机仍可能丢;Broker 保存了消息,消费者先确认再写数据库,中途宕机也会丢。反过来,消费者写库成功后还没确认就宕机,消息会再次投递,这时不会丢,但会重复。所以可靠消息通常选择“至少一次投递 + 消费端幂等”。
- Q2:至少一次和恰好一次有什么区别?
A: 至少一次强调消息不能漏,发生超时或故障就重试,代价是可能重复。恰好一次要求业务效果只出现一次,范围比消息只投递一次更大。Kafka 事务能覆盖 Kafka 内部的读取、处理和写回;如果还要修改数据库或调用外部接口,仍需要对方事务配合或业务幂等。
W: 生产者超时后无法判断消息到底没写入,还是已经写入但确认丢了。为了不漏,只能重发,于是至少一次天然允许重复。即使 Broker 能识别重复发送,消费者写数据库后确认失败仍会再收到消息。只靠 MQ 很难约束外部系统,所以面试中不能把“开启一个参数”说成端到端恰好一次。
三、重复消费与幂等
知识点
- 重复来源:生产者发送超时后重试;消费者完成业务但确认失败;消费者宕机或发生重新分配后,从上一次提交位置继续消费。
- 幂等含义:同一业务消息执行一次或多次,最终业务结果一致。查询和覆盖写通常容易幂等,扣库存、转账、发券等操作需要额外控制。
- 业务唯一键:优先使用订单号、支付单号、流水号等稳定业务标识;某些场景下,同一业务重发可能产生不同的消息 ID,不能只依赖消息系统生成的 ID。
- 常见方案:数据库唯一约束、去重表、业务状态机、带版本条件的更新语句;Redis
SET NX可以挡住并发重复,但还要考虑锁过期和数据库失败。 - 原子性要求:去重记录与业务更新应放在同一个数据库事务中;如果先写“已消费”再执行业务,业务失败后重试会被错误跳过。
面试问答
- Q1:消息为什么会重复消费?
A: 根因是确认和业务处理不是一个原子动作。消费者可能已经写库成功,但返回确认前宕机,Broker 看不到成功结果就会重新投递;生产者发送超时也可能重发同一业务消息。只要系统选择至少一次投递,就要接受重复的可能,把正确性放到消费端幂等来保证。
W: 一次支付成功后,消费者更新账单并准备确认,此时网络断开。Broker 不知道账单已经更新,只能把消息交给另一个消费者。若消费逻辑是直接“余额减一百”,第二次就会多扣一次;若逻辑是“支付单未处理才扣款,并把状态改成已处理”,重复消息只会读到已处理状态并退出。
- Q2:消费者如何保证幂等?(高频)
A: 先选稳定的业务唯一键,再让去重判断和业务修改处于同一原子边界。常用做法是数据库唯一索引、去重表,或者用订单状态和版本号做条件更新。Redis SET NX 适合拦并发,但不能单独证明数据库业务已经成功。转账、库存这类操作还要配合流水和状态机。
W: 用订单号建立唯一约束时,两个消费者同时处理同一订单,只有一个能插入成功,另一个遇到主键冲突后直接跳过。若去重表和业务表分两次提交,第一步成功、第二步失败,重试会把未完成的业务当成已完成。把两张表的修改放进同一数据库事务,才能一起成功或一起回滚。
四、顺序消息
知识点
- 局部有序:全局只有一条队列和一个消费者才能严格串行,吞吐量受限;生产系统通常只保证同一订单、同一用户或同一设备的消息有序。
- 固定路由:生产者用订单号等业务键选择队列或分区,使同一业务实体的消息进入同一条有序日志;不同业务键仍可并行。
- 串行消费:同一队列或分区内按顺序处理,前一条未完成时不能让后一条越过;提高并发应增加业务分组,而不是拆散同一组。
- 失败处理:重试可能长期卡住后续消息,需要设置重试上限、异常转移和人工处理;业务状态机要拒绝“已退款又回到已支付”这类非法跳转。
面试问答
- Q1:如何保证消息顺序消费?(高频)
A: 通常保证局部有序。生产端把同一订单号或用户 ID 的消息路由到同一个队列或分区,消费端对这条队列串行处理。某条消息失败时,后续消息不能直接越过它,要重试或转异常队列。业务层再用状态机校验状态跳转,防止重放和补偿造成乱序。
W: 订单依次产生创建、支付、发货三条消息。如果它们分到不同分区,三个消费者可能先处理发货;即使同分区,支付失败后直接跳过也会让发货先落库。固定业务键解决存储顺序,串行消费解决处理顺序,状态机负责最后校验。三层少一层,都可能出现状态倒退。
五、消息积压与消费排查
知识点
- 核心指标:同时观察生产速率、消费速率、积压量、最老消息延迟、失败率和单条处理耗时;只看队列长度无法判断问题正在扩大还是已经恢复。
- 排查方向:生产流量突增、消费者实例异常、线程池耗尽、数据库慢 SQL、下游 RPC 超时、Broker 磁盘或网络异常都可能让消费变慢。
- 并发上限:同一消费者组内,队列或分区是并行分配的基本单位;消费者数量超过它们后,多出的实例通常拿不到任务。
- 临时止血:先恢复故障消费者,再按下游承载能力增加队列、消费者或处理线程;可批量写库的业务用批处理减少网络往返。
- 大规模分流:积压很大时,可用临时消费者把原消息转发到更多临时队列,再由扩容后的消费者并行清理;必须保留消息键和去重信息。
- 恢复风险:追赶速度太快可能压垮数据库;分流和重试还会引入重复、乱序,需要限流、幂等、状态校验和对账。
面试问答
- Q1:几百万条消息积压了怎么处理?(高频中的高频)
A: 先确认是生产突增还是消费故障,恢复消费者后比较生产和消费速率。短期可以增加队列和消费者、提高合理并发、批量处理;积压特别大时,用临时消费者把消息分流到更多临时队列并行清理。扩容要看数据库和下游容量,不能刚清队列又把下游打挂。
W: 假设每秒新增一万条,消费者只能处理六千条,即使服务没有报错,积压也会每秒增加四千条。只多开消费者但分区数不变,多出的实例拿不到分区;把并发拉满又可能让数据库连接池耗尽。处理积压要同时改“可并行单元、消费能力、下游容量”,并估算多久能追平。
- Q2:消费速度突然下降怎么排查?
A: 先看积压曲线和生产、消费速率,确认从什么时间开始下降;再查消费者存活、重平衡、线程池、失败重试和单条耗时。业务侧重点看数据库慢 SQL、锁等待、连接池和下游 RPC,基础设施侧看 Broker 的磁盘、网络和副本状态。定位前不要盲目扩机器。
W: 消费者 CPU 很低不代表它没问题,它可能全部阻塞在数据库连接池或下游超时上。此时增加线程只会堆更多等待请求。把单条消费拆成拉取、反序列化、业务计算、数据库、RPC、确认几个阶段分别计时,通常能很快判断瓶颈是在消费者内部,还是在它依赖的系统。
六、消费失败:重试、死信与延迟
知识点
- 异常分类:网络抖动、限流和临时不可用可以重试;参数错误、数据缺失、业务状态冲突通常不能靠重复执行恢复。
- 退避与上限:失败后立即无限重试会持续冲击故障下游,应设置重试间隔、最大次数和单独的重试流量限制。
- 死信队列:超过重试上限的消息转入死信队列,记录消息体、业务键、失败原因和重试次数,修复后由受控任务重放。
- 毒消息隔离:一条固定失败的消息不能长期堵住正常队列;顺序业务需要在“保持顺序”和“跳过异常”之间按业务风险取舍。
- 延迟执行:订单超时、延迟通知和失败补偿可以用延迟消息;消息到期后仍要查询当前状态,已支付订单不能因为旧消息到达而被关闭。
面试问答
- Q1:消费失败后,重试和死信怎么设计?(高频)
A: 先区分临时异常和永久业务错误。临时异常按递增间隔重试,并限制次数和流量;一直失败的消息进入死信队列,保存失败原因,修复后人工或定时重放。消费者必须幂等,重放前还要检查业务状态。订单超时这类延迟消息到期后,也不能直接执行旧指令。
W: 库存服务短暂超时,隔几秒再试可能成功;订单号不存在,重试一百次也不会变对。两类错误混在一起会让重试队列被无效消息占满。死信队列要隔离故障、保留现场,并提供可审计的修复入口;修复后重放时,仍要走原来的幂等校验。
七、事务消息与最终一致性
知识点
- 双写问题:数据库事务和 MQ 属于两个系统,先提交数据库再发消息会漏通知;先发消息再提交数据库,数据库回滚后下游可能处理一条不存在的业务数据。
- 本地消息表:业务数据和待发送事件写入同一个数据库事务,后台任务扫描未发送记录并投递;发送状态更新失败可能造成重复,所以消费者仍要幂等。
- Transactional Outbox:Outbox 表保存事件 ID、聚合键、事件类型和内容,可由轮询任务或 CDC 读取;事件 ID 用于去重,聚合键用于保持同一实体的顺序。
- RocketMQ 事务消息:Broker 先保存对消费者不可见的半消息,生产者执行本地事务后提交或回滚;结果未知时,Broker 向生产者回查事务状态。
- 最终兜底:事务消息缩小了故障窗口,但不能替代消费者幂等、业务对账和补偿任务。
面试问答
- Q1:如何保证数据库操作和发消息的一致性?(高频中的高频)
A: 常用本地消息表,也叫 Transactional Outbox。业务数据和待发送事件在同一个数据库事务里提交,后台任务或 CDC 再投递到 MQ,失败就重试。事件要带唯一 ID,消费者必须幂等,并配合对账补偿。
W: 先更新订单再发消息,应用在两步之间宕机,下游永远不知道订单已创建;先发消息再更新订单,数据库回滚后下游却开始发券。本地消息表把“订单”和“待发送事件”变成一次数据库提交。它没有把两个系统变成一个事务,只是把不可恢复的双写窗口改成可持续重试的发送任务。
- Q2:RocketMQ 事务消息怎么实现?
A: 生产者先发送半消息,Broker 保存后暂不投递;随后生产者执行本地事务,并向 Broker 返回提交或回滚。提交后消息才对消费者可见,回滚则删除或终止投递。若第二次确认丢失,Broker 会回查生产者的本地事务状态,再决定提交还是回滚。
W: 半消息先落到 Broker,解决“本地事务完成后应用突然宕机,消息没有任何记录”的问题;事务回查解决“本地事务已经完成,但提交结果没有送到 Broker”的问题。回查依据必须来自可查询的本地业务状态,不能只看进程内变量。下游仍可能重复收到消息,幂等不能省。
八、Kafka 原理与消息队列选型
知识点
- 分区追加日志:Kafka 把 Topic 拆成多个分区,消息追加到分区日志,分区可以分布在不同 Broker 上并行读写;同一消费者组内,一个分区同时只由一个消费者处理。
- 批量与压缩:生产者把同一分区的多条记录组成批次,Broker 按批写入,消费者也批量拉取;批次还能整体压缩,减少网络请求、磁盘操作和传输字节数。
- 页缓存:Kafka 依赖操作系统 page cache 缓存日志页,顺序追加和线性读取减少随机磁盘寻址;服务重启后,页缓存也不受 JVM 堆对象生命周期约束。
- 零拷贝:在适用的非 TLS 传输路径上,
sendfile可以把 page cache 中的数据直接送向网络,减少用户态和内核态之间的数据复制。 - 选型维度:不要只背吞吐排名,要看延迟、可靠性、路由模型、顺序/事务/延迟能力、消息保留与回溯、运维成本和团队经验。
- 常见定位:Kafka 常用于日志、事件流和高吞吐数据管道;RocketMQ 的顺序、事务、延迟和消费重试适合业务消息;RabbitMQ 的 Exchange 与 Routing Key 适合需要灵活路由的场景。
面试问答
- Q1:Kafka 为什么吞吐量高?(高频)
A: 核心是把随机小 IO 变成批量顺序 IO。Kafka 按分区追加日志,生产者批量发送并压缩,Broker 成批写入,消费者批量拉取;page cache 负责缓存,适用的非 TLS 路径可用 sendfile 减少复制。分区还能跨 Broker 并行扩展吞吐。
W: 如果每条消息都单独发请求、单独写磁盘,一万条消息就会产生一万次协议和 IO 开销。Kafka 先把多条消息装成批次,一次网络往返和一次连续写完成更多工作。消费时直接搬运已经存好的批次,不反复把每条消息解包、重组,CPU、磁盘和网络的固定成本都被摊薄。
- Q2:Kafka、RocketMQ、RabbitMQ 怎么选?(高频)
A: 先看业务约束。日志采集、事件流、长期保留和高吞吐,优先看 Kafka;订单、支付等业务消息,重视事务、顺序、延迟和重试,可以看 RocketMQ;需要 Exchange 灵活路由,RabbitMQ 更合适。最后再看现有技术栈和运维能力。
W: 秒杀流水进入实时计算平台,更看重吞吐、分区扩展和消息回放,Kafka 更顺手;订单创建后驱动支付、库存、发券,需要事务消息、同订单顺序和延迟关单,RocketMQ 的模型更贴近;一条消息要按多个 Routing Key 分到不同业务队列,RabbitMQ 的路由能力更直观。选型结论来自约束,不来自固定排名。