第 33 章 消息队列:可靠性三件套
本章解决"消息会不会丢、会不会重、会不会乱"三个问题,并给出积压、延迟消息与选型的判据。
学习目标
读完本章,你应当能够:
- 说清 MQ 的四个核心价值与它引入的三类新问题,并判断哪些场景不该引入 MQ;
- 沿"生产者 → Broker → 消费者"三段逐段列出丢失原因与对应防线,给出每段"最多能丢多少"的量化判断;
- 用
LEO、HW、ISR、leader epoch解释 Kafka 的持久化与副本机制,推导HW截断为什么造成数据不一致; - 论证"至少一次语义下重复不可避免",在给定业务下选出幂等方案、设计幂等键与去重表治理策略;
- 区分全局有序、分区有序、业务键有序三种层次,指出 Kafka 分区内有序被破坏的两个具体原因;
- 用"生产速率 vs 消费速率 +
Lag曲线"定位积压性质并给出按代价排序的处理手段,同时画出时间轮结构、比较延迟消息的四种实现与事务消息的两阶段回查,最后按四个维度完成 MQ 选型。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| 主从复制的通用模型与"丢数据窗口" | 见第 26 章 26.1、26.2 节(Broker 副本机制是同一模型的特化) |
| 两阶段提交(2PC)的时序与崩溃点 | 见第 21 章 21.5 节(事务消息是它的消息版变体) |
唯一索引与 INSERT ... ON DUPLICATE KEY | 见第 23 章 23.2 节、第 20 章 20.4 节 |
| 线程池、锁竞争造成的吞吐下降 | 见第 31 章 31.2 节(消费慢的根因之一在此) |
33.1 为什么引入 MQ:四个收益与三类代价
| 价值 | 派生机理 | 典型量化 |
|---|---|---|
| 解耦 | 上游只依赖消息格式,不依赖下游的接口、可用性与发版节奏 | 下游新增消费方,上游零改动 |
| 异步 | 把非关键路径移出请求响应链,接口 RT 只含"发消息" | 发消息约 1~5 ms(同机房 RTT 量级),下游 200 ms 的处理被移出 |
| 削峰 | Broker 可堆积,把峰值流量摊到更长时间窗口消费 | 峰值 10 万 QPS、消费能力 1 万 QPS,需容忍 9 万条/秒的堆积 |
| 最终一致 | 借"消息必达 + 消费幂等 + 重试"把跨系统写操作串起来 | 与本地消息表、事务消息同源(见 33.8 节) |
三类新代价必须一起答,只答收益是半个答案(三件套这一章存在的理由就是这三类代价):
- 可用性下降:同步调用只有两个依赖方,引入 MQ 后有生产者、Broker、消费者三段加存储集群,链路可用性是各段之积,
0.999 × 0.999 × 0.999 ≈ 0.997,比单跳低一个数量级;生产者若同步阻塞式发送,Broker 故障会直接挂住上游请求。 - 一致性变复杂:同步调用里"下游成功"是调用栈里的返回值;异步化后它变成未来才发生的状态,于是必须处理丢失、重复、乱序三种偏差 —— 这就是"三件套"的来源。
- 系统复杂度上升:多了一个必须监控、扩容、演练的中间件;链路从一次 RPC 变成长达数秒的异步流程,排障要跨三个系统看
TraceID(见第 35 章 35.6 节)。 - 不该用 MQ 的四类场景:要求强一致且下游必须立刻返回结果(如扣款与入账要同一个事务结论);调用链只此一次、无扇出、下游稳定,没有解耦收益却白增一跳网络与新故障点;需要同步的查询结果(查询是"读",MQ 是"写后通知",语义不匹配);消息量极小且偶发,运维一个集群的成本远超收益。
重要
面试开场就问"为什么要用 MQ"时,正确答法是"收益 + 代价 + 我为什么判断这个业务的收益大于代价"。只背四个价值会被追问"引入 MQ 后你的系统可用性怎么变的",答不出乘积关系就露了。
33.2 消息丢失的三个环节与各自的防线(第一大必考:必须逐段拆开,因为三段防线不同、责任方也不同)
| |
33.2.1 环节① 生产者 → Broker
根因是发送方使用"发后即忘"语义:send() 立刻返回,内核缓冲区里的数据在连接断开或 Broker 未持久化时直接消失。
| 发送方式 | 何时算成功 | 能丢多少 | 适用 |
|---|---|---|---|
发后即忘(acks=0 或忽略 future) | 写进本地发送缓冲区即算成功 | 连接抖动、Broker 重启、缓冲区满都静默丢;丢失率取决于故障频率,不是常数 | 日志、埋点等可丢场景 |
| 同步确认 | 收到 Broker 的 ack | 只丢"Broker 收到但没落稳"的部分(见 33.2.2) | 订单、支付等关键消息 |
| 异步回调 | 回调触发时才知道成败 | 与同步确认同,成本是回调失败必须重试,否则等价于发后即忘 | 高吞吐 + 需要可靠 |
生产者还必须在回调失败时重试、重试仍失败时落库补偿 —— 只配 acks=all 而不处理回调失败,等于把丢消息的责任推给 Broker,这是最常见的错答。
33.2.2 环节② Broker 内部
Broker 收到后有两道关卡:刷盘与副本。异步刷盘让消息先进页缓存(page cache)就返回 ack,吞吐是同步刷盘的数倍到十倍,但机器掉电(断电、内核 panic)会丢掉未刷盘的数据;进程崩溃不丢 —— 数据已在内核页缓存里,进程死而内核存活,这是常被答错的一点。同步刷盘等落盘才 ack,每次写加一次磁盘写延迟(SSD 约 100 μs ~ 1 ms,机械盘约 5~10 ms),吞吐掉一个数量级。Kafka 不做同步刷盘,把可靠性交给副本;RocketMQ 提供 SYNC_FLUSH/ASYNC_FLUSH。副本参数是一组,必须同时说:replication.factor(副本因子)决定存几份,配 3 表示能容忍 2 台同时挂而不丢数据;min.insync.replicas(最小同步副本数)要求 acks=all 时至少这么多个副本确认写入才算成功,默认 1,必须改成 2 或更多,否则 acks=all 退化成"只要 leader 自己写成功";unclean.leader.election.enable 默认 false,设为 true 后 ISR 全挂时允许落后很多的副本直接当 leader,用可用性换数据丢失,因为它缺失的那段日志会被永久截断。
量化判断(副本因子 3、min.insync.replicas=2):
| 故障情形 | 结果与丢数据量 |
|---|---|
| 1 台 Broker 掉电 | ISR 还剩 2 个,写继续 → 0 丢失(前提是被确认那份已进 2 个副本的页缓存且进程未死) |
| 2 台同时掉电 | ISR 缩到 1 < 2,收不到 ack、写入失败 → 0 丢失,但服务不可写(刻意取舍:宁可不可写也不写少副本) |
| 全部掉电 + 机器同时断电 | 已 ack 但未刷盘的数据丢失,量与"上次刷盘时间点"成正比 |
min.insync.replicas=1 + acks=all | leader 写完即 ack、随后掉电 → 丢掉自上次刷盘以来的全部数据,“配了 all 却仍然丢"的经典现场 |
小心
acks=all 不等于不丢消息,它只保证"ISR 里所有副本都收到了”。会不会丢取决于 min.insync.replicas 与 unclean.leader.election。答"配了 acks=all 就万无一失"会被立刻追问"那 min.insync.replicas 默认是几?"
33.2.3 环节③ Broker → 消费者
| 提交方式 | 语义 | 后果 |
|---|---|---|
自动提交(enable.auto.commit=true) | 后台每 auto.commit.interval.ms(默认 5000 ms)提交一次已拉取的最大位点 | 位点可能跑在业务处理前面:拉了一批 500 条、处理到第 100 条时崩溃,重启后从第 500 条之后消费,中间 400 条静默丢失 |
| 手动提交(同步 / 异步) | 业务代码显式提交,可控制时机 | 正确用法的前提是顺序不能反 |
“先处理业务再提交位点"的顺序为什么不能反 —— 只要提交与处理之间有间隙,故障就必然落在间隙里:
| |
只要提交与处理之间有间隙,故障就必然落在间隙里 —— 选择"先处理后提交"就是把偏差定向到重复(可被幂等消除,见 33.4 节),而不是丢失(无法事后恢复),可靠性工程的通用手法即"把误差往可修的那一侧推”。消费失败的重试与死信:消费抛异常时不提交位点、按退避策略重试,超过阈值的消息投入死信队列(dead letter queue, DLQ)并告警;注意死信不等于丢弃(要有补偿入口),且重试必须限次且退避,否则一条毒消息(poison message)会永久阻塞该分区 —— RocketMQ 用 %RETRY% 主题与延迟等级实现重试,失败 16 次后进 %DLQ%。
警告
只在本地 for 循环里重试而不做退避与限次,会让单分区吞吐归零,并表现为"消费者 CPU 很低但 Lag 一直涨",这是 33.6 节排查表里最容易被误判成"消费慢"的一种。
33.3 Kafka 的持久化与副本机制
33.3.1 分区、副本与 ISR
| |
分区(partition)是 Kafka 的并行单位与有序单位:同一分区内消息有确定顺序,跨分区没有。副本只复制 leader 的日志,不承担生产与消费流量(Kafka 2.4+ 起支持有限度的 follower 读用于就近消费)。
**ISR(in-sync replica,同步副本集合)**是"与 leader 保持同步的副本集合",含 leader 自己,判定标准由 replica.lag.time.max.ms(默认 30 s)决定:follower 只要在该时间内追平过 leader 的 LEO 就留在 ISR 里 —— 这是"时间"标准而非"条数"标准,短暂抖动不会立刻把副本踢出,持续追不上才会。收缩时若 ISR 大小 < min.insync.replicas,acks=all 的写入被拒(NotEnoughReplicasException),代价是可用性下降而数据不丢;扩张时 follower 追平到 LEO 后重新加入,加入前它落后于 HW 的部分必须先截断再重新拉取。
33.3.2 LEO 与 HW:为什么消费者只能读到 HW 之前的消息
| |
- LEO(log end offset):某副本下一条要写入的消息的偏移量,即该副本日志的末尾;**HW(high watermark,高水位)**是
min(ISR 中所有副本的 LEO),代表"即使 leader 立刻挂掉、换成任意 ISR 副本,这段数据也一定存在"。 - 消费者只能读到
HW之前:若允许读HW之后,消费者可能读到只有 leader 有的m6,而此时 leader 崩溃、follower2 当选(其 LEO=5),m6会被截断消失 —— 消费者"看到"过一条历史里不存在的消息。把可见性边界压到HW,等于把"读到的数据一定会被保留"变成硬保证。
33.3.3 HW 截断的不一致与 leader epoch 的解法
Kafka 官方文档明确承认 HW 机制的缺陷:HW 的传播是异步的,副本恢复时可能发生"日志被错误截断"。
| |
leader epoch(leader 纪元)的解法:给每次 leader 换届分配单调递增编号,并在日志里记录"每个 epoch 的起始偏移量"(epoch cache)。副本恢复时不再用"当前 LEO"这种易失的瞬时值判断截断点,而是先比较 epoch:follower 把"自己的最新 epoch + 该 epoch 的起始偏移量"发给 leader;leader 若发现对方 epoch 更旧,就告知该 epoch 的结束偏移量;follower 截断到那个位置后重新拉取。截断点由日志自身的元数据决定,而不是由对端当前写到哪里决定,避免了"用一个已变化的量去判断历史的终点" —— 这与 Raft 用 (term, index) 判定日志新旧同源(见第 34 章 34.3 节)。
注意
追问的下一层通常是"Kafka 为什么不用 Raft 选主而用 ISR 机制":ISR 只要求副本集合内多数一致(且 leader 由控制器指定),换得更短的选举时间与更高的写入可用性,代价就是 HW 截断这类需要 leader epoch 补丁的问题;用于管理元数据的 KRaft 用 Raft,但数据面的副本复制仍是 ISR 模型。
33.3.4 acks 三种取值的可靠性对比
acks | 成功条件 | 吞吐 | 丢数据情形 | 丢多少(rf=3, min.isr=2) |
|---|---|---|---|---|
0 | 数据写入本地发送缓冲区即返回 | 最高 | leader 崩溃、网络中断、Broker 未落盘 | 任何一次故障都可能丢,无上限,只能按故障频率估期望 |
1 | leader 写入本地日志(进页缓存)成功 | 高 | leader 掉电(未刷盘)、leader 崩溃且被无数据副本接管 | 未落盘的部分;若允许 unclean 选举,可能丢掉整个未复制段 |
all(-1) | ISR 中所有副本都确认接收 | 低(等最慢副本) | 全部 ISR 同时掉电且未刷盘;或 min.insync.replicas=1 时 leader 独立掉电 | min.isr=2、rf=3 下:容忍 1 台任意故障为 0 丢失;2 台故障时停止服务而非丢数据 |
记忆锚点:acks 决定"等谁",min.insync.replicas 决定"至少等几个",unclean.leader.election 决定"不够时要不要用脏副本顶上"—— 三者是一组。
33.4 消息重复的根因与幂等实现
33.4.1 重复为什么不可避免
MQ 只提供三种投递语义:至多一次(可能丢、不重复)、至少一次(不丢、可能重复)、精确一次(不丢不重)。前两者是"二选一",因为网络超时无法区分"消息没到"和"对方的确认丢了" —— 这是分布式系统的固有性质,不是实现缺陷。至少一次下重复有两条独立来源:① 生产者重试 —— Broker 写入成功但 ack 在网络上丢失,生产者超时重试,同一条消息在日志里出现两次;② 消费者提交失败 —— 业务处理完成、提交位点时崩溃或超时,重启后从旧位点重新消费。“重复不可避免,幂等必须做”:只要没有跨生产者与消费者的分布式事务(事务消息也只是把窗口缩小,见 33.8 节),就永远存在"业务已执行但位点未推进"的窗口。既然重复无法在设计上消除,就只能消除它带来的业务影响 —— 这就是幂等(idempotence)的定位:它不是优化,而是使用 MQ 的前置条件。
重要
精确一次在 Kafka 里指"生产者用幂等序号 + 事务把消费位点与输出写在同一事务内",它约束的边界是 Kafka 内部的读写。一旦要写外部数据库或调外部接口,端到端仍是至少一次,业务幂等不可省。把"Kafka 支持精确一次"答成"所以我不用做幂等"是最典型的错答。
33.4.2 四种幂等实现方案对比
| 方案 | 机制 | 优点 | 缺陷 | 适用 |
|---|---|---|---|---|
| 唯一键去重表 | 业务库建表,幂等键上唯一索引,INSERT 冲突即视为重复 | 最简单可靠;与业务写在同一本地事务里,天然原子 | 每次消费多一次写;表持续增长 | 绝大多数场景的首选 |
Redis SETNX + 过期 | SET key 1 NX PX ttl 返回 0 即重复 | 性能好(约 100 μs 级 RTT)、不占数据库 | 与业务处理不同事务:SETNX 成功后业务失败,重试会被误判为重复而漏处理;主从异步复制下切换可能丢 key | 可作前置快速过滤,或允许极少量漏处理的场景 |
| 状态机 | 让重复请求收敛到同一状态,如订单只允许 待支付 → 已支付 一次 | 无额外存储、业务语义天然正确 | 只适用于有明确状态的业务;覆盖不了"发短信"这类无状态动作 | 订单、工单、审批流 |
| 业务天然幂等 | 用赋值代替自增:SET count = 100 而非 count = count + 1 | 零额外成本 | 只对"可覆盖写"成立,累加、扣减、插入天生不幂等 | 状态同步、配置下推 |
状态机的 SQL 写法(用 WHERE 兜住状态跃迁,靠影响行数判定是否重复):
| |
去重表(INSERT 冲突与业务写在同一事务):
| |
33.4.3 幂等键怎么生成:为什么业务单据号优于 UUID
| 生成方式 | 同一业务操作重发时 | 结论 |
|---|---|---|
| 生产者侧 UUID | 重发时生成新的 UUID → 键不同 → 去重表失效 | 错:UUID 的唯一性反而使去重失效 |
消息自带 msgId(Broker 生成) | 跨重试是否保留取决于实现 | 不可靠,不能作为最后防线 |
业务单据号(订单号、支付流水号、requestId) | 无论重发多少次都是同一个值 | 对:业务自身的唯一标识天然满足需求 |
业务键 + 操作类型(如 order-123:PAY) | 同上,且能区分同一单据的不同操作 | 更好:同一单据的"支付"与"退款"是两条不同记录 |
去重表的容量治理:① 按时间归档 —— 只保留"最大重试窗口"内的记录;若最长重试链路是 24 小时(含死信重投),保留 3~7 天即有充分冗余,用 DELETE ... WHERE consumed_at < NOW() - INTERVAL 7 DAY LIMIT 5000 分批删除,避免长事务(见第 22 章 22.7 节);② 分区表 —— 按 consumed_at 范围分区,删除退化为 DROP PARTITION,瞬间完成且不产生 undo 膨胀;③ 换存储 —— 下沉到 Redis(带 TTL)或布隆过滤器(Bloom filter),但布隆过滤器有假阳性且不能删元素、只能重建,假阳性会被误判为重复 → 造成漏处理,故只适合"重复代价远低于漏处理代价"的场景。
33.5 顺序消息的三种保证层次
| 层次 | 含义 | 代价 | 何时需要 |
|---|---|---|---|
| 全局有序 | 整个主题内严格按发送顺序被消费 | 并行度退化为 1,吞吐=单机单线程;与削峰价值直接冲突 | 几乎不需要。只有"因果链跨越所有业务键"时才有意义,如 binlog 同步(单分区天然满足) |
| 分区有序 | 同一分区内有序,跨分区无序 | 需保证同一业务键落同一分区;并行度 = 分区数 | 大部分"需要有顺序"的需求实际只需到这一层 |
| 业务键有序 | 按业务键(订单 ID、用户 ID)哈希到同一分区 | 分区数变化时映射会变(见 33.6 节) | 最常见的正确答案:把"要什么顺序"精确到业务实体粒度 |
“分区有序就够了"的推理:绝大多数顺序需求是"同一个订单的状态变更不能乱”,不同订单之间互不相关;把粒度收窄到订单后,并行度从 1 恢复到分区数 —— 用"业务维度隔离"换回并行度。
33.5.1 Kafka 分区内有序被破坏的两个原因(必考)
原因一:生产者重试导致的乱序。
| |
原因二:消费者多线程消费破坏处理顺序。
| |
警告
“多线程消费后加锁就能保序"是错答:锁让两个线程不同时执行,不保证执行次序与到达次序一致。要保序,必须让同一业务键的消息在同一时刻只有一条在途,即"串行化 + 顺序投递”,加锁只是必要条件而非充分条件。
RocketMQ 的做法:生产者用 MessageQueueSelector 显式选队列(业务键取模选 MessageQueue),替代 Kafka 的隐式哈希;消费者用 MessageListenerOrderly,其内部对每个 MessageQueue 加本地锁,确保一个队列同一时刻只被一个线程消费,并向 Broker 申请队列的分布式锁,避免 rebalance 后两个实例同时消费同一队列。
| |
33.6 消息积压的排查与处理(第三大必考:第一步不是扩容而是定性)
积压是"生产者发得太快"还是"消费者消费得太慢"?两者方向相反。
| 观测项 | 生产过快 | 消费过慢 |
|---|---|---|
| 生产速率 vs 消费速率 | 生产速率显著大于消费速率且持续 | 消费速率明显低于历史正常值或远低于并发上限 |
Lag 曲线 | 线性增长(斜率 = 速率差) | 阶跃式抬升后走平(如某时刻突涨 100 万然后维持) |
| 消费者 CPU / 线程栈 | 高(打满) | 低(等下游、等锁、被限流);线程栈多在 SocketRead(等下游)、park(等锁)、sleep(重试退避) |
| 分区消费分布 | 各分区均匀增长 | 可能只有一个分区在涨 = 该分区有毒消息或热点键 |
处理手段按代价从低到高排序:① 增加消费者实例(前提是实例数 < 分区数)—— 上限就是分区数:第 p+1 个消费者会完全空闲(Kafka 的分配粒度是分区),还要触发 rebalance 让全体停顿,“加机器就能解决积压"只在当前实例数少于分区数时成立;② 提高单条消费速度 —— 批量消费(max.poll.records 调大、批量写库)、把同步调用改异步/并行、去掉无谓的重试退避、优化下游慢 SQL 或慢接口,这是收益最高且无副作用的一档;③ 增加分区数 —— 三个硬约束:Kafka 分区数只能增不能减;增加分区会改变 key % 分区数 的映射,导致同一业务键的新老消息落到不同分区,破坏业务键有序;分区越多,故障时的 leader 选举与 rebalance 越慢、文件句柄与内存开销越大;迁移比例:分区数从 N 增到 N+1 时旧键 k 的落点由 k mod N 变为 k mod (N+1),会发生迁移的键占比约 N/(N+1)(只有满足 k mod N == k mod (N+1) 的键不动),N=10 时约 10/11 ≈ 90.9%,一次翻倍(N → 2N)约 1/2 的键迁移 —— 所以扩容必须选在低峰、且最好一次性翻倍;④ 临时扩容后紧急消费(“转存”) —— 积压量超过业务容忍窗口时:临时写一个只转发不处理的消费者(转到分区数大得多的临时主题),再用大量消费者并行处理,同时另起一路按正常速率消费原主题,双写去重由幂等层保证;但转存会重新分配分区、丢失顺序,只适用于无序消息;⑤ 丢弃或降级 —— 对可丢消息(埋点、非关键通知)用 seekToEnd() 跳过积压区间并记录丢弃量,把资源让给关键消息。
消费慢的三大根因:① 单条处理有重 I/O —— 逐条查库、逐条调远程接口;一次 RPC 同机房 RTT 约 0.10.5 ms,算上服务端处理往往 10100 ms,逐条处理 1000 条就是数十秒;② 同步调用串行 —— 消费逻辑里 .get() 阻塞等待,吞吐 = 1/单次耗时,解法是批量化或异步编排(见第 31 章 31.6 节);③ 锁竞争 —— 为保序加了粗粒度锁、去重表唯一索引上热点键冲突频繁、或与在线业务争同一行的记录锁,解法是缩小锁粒度到业务键(见 33.5.1 的二次分发)、把去重放到内存 + 异步落库。
提示
答题模板:先定性(生产快 / 消费慢)→ 再定位(Lag 曲线、CPU、线程栈)→ 最后按代价排序出手(先提消费速度,再考虑加实例到分区数上限,慎动分区数)。这个顺序本身体现工程判断,比直接答"加机器"高一个档次。
33.7 延迟消息与定时消息
延迟消息的共同结构是”消息在未来的某个时刻才可被消费"(下单 30 分钟未支付关单、支付成功后 15 秒推送、失败后按 1s/5s/30s 退避重试)。
| 方案 | 机制 | 精度 | 缺陷 |
|---|---|---|---|
| RocketMQ 固定延时等级 | 消息投到 SCHEDULE_TOPIC_XXXX 的对应等级队列,定时器到期后回投原主题。默认 18 级:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h | 只能取等级值,其余向上取整 | 任意延时(如 47 分钟)无法精确表达;每级一个队列,高等级队列有堆积风险 |
RabbitMQ TTL + 死信队列 | 消息设 x-message-ttl,到期成为死信(dead letter)被路由到 DLX 对应交换机再投递 | 秒级 | 队列级 TTL 有队头阻塞:队列是 FIFO,只有队头消息到期才会被投出,后面的短 TTL 消息被前面的长 TTL 消息堵住;用"每档一个队列"可绕开,但档位一多就难维护 |
| 定时轮询扫描 | 定时任务扫数据库"到期未执行"的表 | 取决于扫描周期,秒级~分钟级 | 精度受周期限制;表大时扫描成本高;多实例并发扫需加锁 |
| 时间轮(timing wheel) | 内存里的环状槽位,指针按 tickMs 推进,到期的槽位整体取出执行 | tickMs 级 | 纯内存,宕机丢任务,需配合持久化 |
33.7.1 时间轮的结构与多层设计
单层时间轮的问题是:要支持 24 小时、精度 1 秒,需要 24 × 3600 = 86400 个槽位,内存与空推进成本都不可接受。多层时间轮的解法是把"精度"与"跨度"分离 —— 低层管精度、高层管跨度,只在低层空转。
| |
Kafka 的 TimingWheel:位于 kafka.utils.timer 包,用于延迟生产(delayedProduce)、延迟拉取、延迟删除等内部定时任务。实现特征是单层 + 按需扩容 —— tickMs 与 wheelSize 由 SystemTimer 给定(如 tickMs=1 ms、wheelSize=20,即每格 1 ms、一轮 20 ms),当任务超时时间超过一轮时不加层,而是把任务放到溢出轮(overflow wheel)里,用更大的 tickMs 重新组织;每个 TimerTaskList(槽位)是双向链表并挂在一个 DelayQueue 上,只有到期槽位才被取出执行,避免每秒扫描所有任务。
精确延时的通用工程方案(追问"如果是 47 分钟后提醒呢"):① Redis ZSet + 轮询 —— ZADD delay_queue <到期时间戳> <消息ID>,另起线程每秒 ZRANGEBYSCORE delay_queue 0 now LIMIT 0 100 取到期任务并 ZREM 删除;持久、精度秒级,多实例靠 ZREM 返回值做"抢到才执行"的竞争控制,缺点是轮询决定秒级精度上限;② 定时扫描数据库 —— 索引建在 (status, execute_at),用 SELECT ... WHERE status='PENDING' AND execute_at <= NOW() LIMIT 200 FOR UPDATE SKIP LOCKED 让多实例并行取任务(SKIP LOCKED 避免实例间互等),适合分钟级;③ 组合方案 —— ZSet 做短期秒级精确触发,数据库或 MQ 定时消息做长期持久化,重启后由数据库重建 ZSet。
注意
追问的下一层是"时间轮和优先队列(最小堆)比,优势在哪":最小堆插入与取最小都是 O(log n),而时间轮两项都是 O(1),任务量级越大(每毫秒成千上万个延迟请求)常数优势越明显。代价是空转成本、“轮数"记账,以及跨度由 tickMs × wheelSize 限定。
33.8 事务消息
“下单成功后发一条扣库存消息” —— 若先执行本地事务再发消息,发消息失败就破坏了业务;若先发消息再执行本地事务,本地事务回滚就产生了一条不该存在的消息(下游会扣掉不存在的库存)。这与 binlog 与 redo log 必须一致是同一类问题(见第 21 章 21.5 节),只是两个资源从"日志与数据页"变成了"数据库与 MQ”。
33.8.1 RocketMQ 两阶段流程与回查
| |
为什么必须有回查:第 ③ 步的提交指令本身是一个网络请求,它可能丢失,此时生产者不知道 Broker 收没收到、Broker 不知道本地事务的结论 —— 若 Broker 保守地直接回滚,本地事务已提交但消息被删,下游永远收不到;若保守地直接投递,本地事务可能回滚了消息却发了。回查把"猜"变成了"问":Broker 反查生产者"这笔本地事务到底成没成",生产者查本地事务表或业务表的状态字段给出确定答案。这就是 2PC 里协调者宕机后需要"反查参与者"的同构问题,本质是"决议的持久化不能只存在于内存或单次网络消息里"。代价与边界:回查需要生产者实现 TransactionCheckListener,回查次数有限(超限默认回滚);半消息期间消息不可见,消费延迟等于本地事务耗时,因此事务消息不能承载"必须立刻被消费"的消息。
33.8.3 三种"跨系统写"的方案对比
| 方案 | 一致性保证 | 侵入性 | 消费延迟 | 适用 |
|---|---|---|---|---|
| 本地消息表 | 业务写与消息记录写在同一本地事务(同一库)→ 保证不丢;定时任务扫表补发 | 需在业务库加表、加补偿任务 | 补发周期级(秒~分钟) | 通用、技术栈无关,最稳的兜底方案 |
| 事务消息(RocketMQ) | Broker 半消息 + 回查 | 需 MQ 支持事务消息、生产者实现回查 | 本地事务耗时级 | 使用 RocketMQ 且希望少写表的场景 |
| Kafka 事务 | transactional.id + read_committed,把"消费位点提交"与"输出消息发送"放进同一事务 | 只约束 Kafka 内读写 | 无额外延迟 | 流处理内"读-处理-写"闭环 |
| 最大努力通知 | 只重试有限次 + 对账补偿 | 低 | 不确定 | 通知类、可容忍最终不一致 |
Kafka 事务的适用边界必须说清:transactional.id 用于跨会话标识同一生产者(结合 epoch 实现"僵尸生产者隔离" —— 旧会话的写入会被 Broker 拒绝),isolation.level=read_committed 让消费者忽略未提交或被中止事务的消息。但它不覆盖对外部数据库的写入,所以"消费-写库"这条链路仍要靠 33.4 节的幂等实现端到端正确。
33.9 主流 MQ 选型对比
| 维度 | Kafka | RocketMQ | RabbitMQ | Pulsar |
|---|---|---|---|---|
| 模型 | 分区日志(pull) | 队列 + 主题(pull) | AMQP 队列(push) | 存算分离,支持队列与流两种订阅 |
| 单机吞吐 | 最高(百万级 msg/s 量级,顺序写 + 零拷贝) | 高(十万级) | 中低(万级) | 高(与 Kafka 同量级) |
| 延迟 | 毫秒级(批量为吞吐让路) | 毫秒级,延迟消息与事务消息原生支持 | 微秒~毫秒级(低堆积时最优) | 毫秒级 |
| 顺序 | 分区内有序,强 | 队列内有序,MessageQueueSelector 显式选队列 | 队列内有序 | 分区/键有序 |
| 延迟消息 | 无原生(需自建或时间轮) | 原生,固定 18 级 | 靠 TTL + 死信队列间接实现 | 原生(定时/延迟投递) |
| 事务消息 | 事务仅限 Kafka 内部读写 | 原生两阶段 + 回查 | 不支持 | 支持事务 |
| 消息回溯/重放 | 强项:按 offset 或时间戳重置位点 | 支持按时间回查 | 弱(消费即删) | 强 |
| 堆积能力 | 强项:堆积是设计前提,磁盘顺序写 | 强(CommitLog 顺序写) | 弱项:堆积导致内存与页缓存压力、流控甚至阻塞 | 强(多级存储可卸载到对象存储) |
| 生态与语言 | Scala/Java,流处理生态最全 | Java,国内中间件生态成熟 | Erlang,AMQP 协议丰富、路由灵活 | Java,多租户与云原生 |
| 运维成本 | 中高(分区数、rebalance、controller 调优) | 中(NameServer 轻量、无外部依赖) | 低(单队列内建,集群镜像队列配置复杂) | 最高(Broker + BookKeeper + 协调组件三套) |
按四个维度给结论:吞吐优先(日志采集、埋点、流处理、数据管道)选 Kafka —— 它把"磁盘顺序写 + sendfile 零拷贝 + 批量压缩 + 分区并行"做到极致,且堆积能力是设计前提;延迟优先(在线业务异步解耦、需秒级以内端到端响应)选 RocketMQ 或 RabbitMQ —— RabbitMQ 低堆积时延迟最低但堆积一多就崩,RocketMQ 在"高吞吐 + 低延迟 + 原生延迟/事务消息"之间最均衡;功能丰富度(事务消息、延迟消息、消息轨迹、死信与重试策略、广播/集群消费)选 RocketMQ,若还需要多租户、跨地域复制、队列与流两种模型、存算分离弹性则选 Pulsar;运维成本优先(小团队、无需深度定制)选 RabbitMQ(上手最快,但必须接受堆积性能差的硬约束),若团队已有 Kafka 经验,继续用 Kafka 的大规模运维成本反而低于引入一套新中间件。
重要
选型题的标准答法是先问清四个约束:峰值吞吐与消息大小、端到端延迟要求、是否需要事务/延迟消息/回溯、团队已有运维能力。四项定下来,答案基本唯一。上来就说"Kafka 最好用"而没有约束,是这类题的典型失分方式。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 丢失三段论 | 生产者(确认语义)、Broker(刷盘与副本)、消费者(提交时机)各有独立防线,缺一不可 | 只答"配 acks=all 就不丢" |
acks=all 的前提 | 必须同时保证 min.insync.replicas ≥ 2 且 unclean.leader.election=false | 答"默认就是安全的"(min.insync.replicas 默认 1) |
HW 与 LEO | LEO 是本副本日志末尾,HW 是 ISR 中最小 LEO;消费者只能读到 HW 之前 | 把两者混为一谈,或说不出"为什么不能读 HW 之后" |
leader epoch | 用不可变的纪元元数据确定截断点,替代易变的"对端当前 LEO" | 认为它只是优化选举速度 |
| 提交顺序 | 必须"先处理业务再提交位点",把误差推向可修的重复一侧 | 认为先提交再处理"性能更好" |
| 幂等键 | 用业务单据号,不用 UUID;UUID 让去重失效 | 用 msgId 或 UUID 当幂等键 |
| 顺序层次 | 绝大多数需求只需"业务键有序",全局有序几乎不需要 | 一上来要求全局有序,并行度退化为 1 |
| 分区内有序的破坏源 | 生产者重试(需 enable.idempotence)与消费者多线程 | 认为"发了就一定有顺序" |
| 积压与扩容 | 实例数超过分区数即无效;分区只能增不能减,N → N+1 约 N/(N+1) 的键要迁移 | 答"加机器就能解决积压"、“分区数随便加” |
| 时间轮 | 多层时间轮把精度与跨度分离,插入与到期均为 O(1) | 说成"就是定时任务轮询" |
| 事务消息回查 | 提交指令可能丢失,必须回查本地事务结论 | 认为回查是"重复投递的补救" |
自测题
Q1(推导)Kafka 集群 replication.factor=3、min.insync.replicas=1、acks=all、unclean.leader.election.enable=true。请指出这套配置下"消息可能丢失"的所有路径,并给出最小改动方案。
答案与解析
两条路径,都来自"参数组合被破除",而非 acks=all 本身失效。路径一:min.insync.replicas=1 让 acks=all 退化。 leader 写成功即算 ISR 全部确认,ack 之后、数据复制到 follower 之前 leader 掉电,该段就只存在于已死的 leader 上;若它未及重启就发生选主,这段就丢了 —— 这是"配了 all 却仍然丢"的最常见现场。 路径二:unclean.leader.election=true 允许脏副本上位。 ISR 全挂后落后很多的副本直接当选,它缺少的那段日志被永久截断。最小改动:min.insync.replicas=2 且 unclean.leader.election.enable=false —— 1 台故障可写且 0 丢失;2 台故障时拒绝写入(不可用但一致),这正是应接受的取舍。错误答案形态:① 答"acks=all 就不会丢",说不出另外两个参数;② 只把 replication.factor 调大 —— 副本因子只决定冗余份数,不决定"至少几份才算成功"。追问路径:min.insync.replicas=3 会怎样?(任一副本下线即不可写)→ “怎么在可用性与一致性之间选?"(按业务定:支付类宁可不可写,埋点类可以 acks=1)。
Q2(推导)消费者采用"先提交位点、再处理业务"的顺序。请分析故障发生时的一致性后果,并说明为什么工程上普遍选择相反的次序。
答案与解析
先提交后处理 = 至多一次(at-most-once)语义,偏差是"丢失”。 位点一旦提交,重启后从该位点之后开始消费,而"提交成功"与"处理完成"之间存在一个必然的时间窗口;落到窗口里的故障(进程被杀、OOM、宕机、rebalance)会让这批消息永远不被处理,且没有任何机制能发现 —— 位点已前进,Broker 认为它被消费过了。相反次序(先处理后提交)= 至少一次(at-least-once),偏差是"重复";重复可检测、可消除:幂等键去重让重复只多一次无效果的写,而丢失不可恢复,只能靠对账补救。工程选择的本质是"把误差推向可修的一侧"。错误答案形态:① 说"两种顺序效果一样,只影响性能";② 说"先提交再处理不会丢,因为 Kafka 保证不丢" —— 混淆了 Broker 侧的持久性保证与消费侧的处理保证。追问方向:"auto.commit 在什么位置提交?"(后台定时提交已拉取的最大位点,完全不可控,等同"先提交后处理")。
Q3(设计)订单支付成功后发消息通知积分服务。请设计幂等方案,说明幂等键怎么生成、去重表怎么治理,并解释为什么不让 Redis SETNX 单独承担。
答案与解析
幂等键:用支付流水号(或 orderId + "PAY"),由上游在发起支付时确定并写入消息载荷。不能用 UUID —— 生产者重试会生成新 UUID,去重直接失效;也不宜用 Broker 生成的 msgId,其跨重试是否稳定取决于发送实现。方案:在业务库建 consumed_msg(idempotent_key PK, topic, consumed_at),把"INSERT 去重记录"与"更新积分"放在同一个本地数据库事务里:INSERT 因唯一键冲突抛异常 → 说明重复 → 回滚并 ACK 消息,于是"去重判定"与"业务生效"是原子的,不存在"判定成功但业务失败"的中间态。治理:按 consumed_at 范围分区,保留 7 天(远大于最长重试链路 24 小时),清理用 DROP PARTITION 或分批 DELETE,避免大事务与 undo 膨胀。
为什么不让 SETNX 单独承担:它与业务处理不同事务 —— 若 SETNX 成功但随后业务写库失败,重试消息会因 key 已存在被判定为"重复"而跳过,漏处理(“去重"误伤了"重试”)。它可作为前置快速过滤(挡住绝大多数重复、减轻数据库压力),但最终保证必须由与业务同事务的唯一索引提供;另外 Redis 主从为异步复制,故障切换可能丢掉刚写入的 key。错误答案形态:① 用 UUID 当幂等键;② 只答"用 Redis 去重"而不提与业务的事务边界;③ 说"去重表不删,磁盘便宜" —— 忽略表会无限增长到影响写入性能与备份恢复时间。追问方向:SETNX 的 key 过期时间设多长?(要大于最长重试链路,否则过期后重复消息会被重新处理。)
Q4(计算/设计)某主题有 8 个分区、8 个消费者实例,Lag 持续线性增长,生产速率 8 万条/秒、消费速率 5 万条/秒,已积压 4000 万条。你按什么顺序处理?请给出数量级推算。
答案与解析
先定性:Lag 线性增长且斜率 = 速率差 = 8 - 5 = 3 万条/秒,说明是生产持续大于消费(不是故障导致的阶跃,那种曲线是抬升后走平)。当前每实例消费 5万 / 8 ≈ 6250 条/秒。处理顺序:① 先看单条消费耗时是否有优化空间(收益最高、无副作用)—— 若单条耗时 1.6 ms、瓶颈是逐条查库,改成批量写(每次 500 条)后单条均摊可能降到 0.5 ms 以下,消费能力提升 2~3 倍;若能到 8 万以上,积压自然消化,不必动分区。② 单条已无优化空间时加实例 —— 但上限是 8(等于分区数),当前已是 8,这一步无效;这是本题关键陷阱,看到"积压了就加机器"会得到零收益。③ 增加分区数 8 → 16 —— 约 8/9 ≈ 88.9% 的键会迁移(k mod 8 与 k mod 9 不同的比例),若按 key 哈希则几乎所有键的归属都变,业务键有序会被破坏,必须选在允许乱序的低峰窗口;分区数只能增不能减,更多分区还会拉长 rebalance 与选举时间。④ 消化存量 —— 扩容到 16 实例后若消费速率达 10 万条/秒,净消化 10 - 8 = 2 万条/秒,清空 4000 万条需 4000万 / 2万 = 2000 秒 ≈ 33 分钟。⑤ 若业务不能等待 —— 用转存:临时消费者只做转发(约 10 万条/秒足够),转到 64 分区的临时主题,再用 64 个消费者并行处理;代价是丢失顺序,只适用于无序消息,且转存本身是一次额外的全量读写。
错误答案形态:① 第一步就答"加机器"(已是分区数上限,无效);② 答"把分区数减少以降低开销"(根本不支持);③ 只答扩容不提 88.9% 键迁移与顺序破坏的代价。追问方向:扩容后如何验证顺序没被破坏?(对同一业务键抽样,检查新分区上的消息序号是否单调)。
Q5(辨析)判断下列说法:① “Kafka 支持精确一次语义,所以消费端不用做幂等”;② “时间轮就是用定时任务每秒扫一次所有延迟消息”;③ “RabbitMQ 的 TTL 加死信队列能实现任意精度的延迟消息,效果和 RocketMQ 的 18 级延时一样”。
答案与解析
① 错。Kafka 的精确一次约束范围是"生产写入 + 消费位点提交"在 Kafka 内部的一致性(靠 transactional.id、幂等序号与 read_committed),一旦消费者要写外部数据库,端到端仍是至少一次;“Kafka 内部精确一次"不能推出"业务不需要幂等”。② 错。时间轮是 O(1) 插入、O(1) 到期取出:任务落点由 到期时间 mod 轮跨度 直接算出,只有到期槽位才被处理(Kafka 用 DelayQueue 管理槽位本身以避免空扫);说成"每秒扫所有消息"就退化成了 O(n) 轮询,失去时间轮的全部意义,多层的作用是把精度与跨度分离。
③ 错,两个层面都不对:一是队列级 TTL 存在队头阻塞 —— 队列 FIFO,队头消息未到期时后面的消息都不会被投出,因此不能在一个队列里混放不同 TTL,必须"一档一个队列",档位一多运维成本陡增;二是 RocketMQ 的 18 级延时只能表达等级值,47 分钟会被向上取整到 1 小时。若要任意精度,正确方案是 Redis ZSet + 轮询、定时扫描数据库,或自建多层时间轮 + 持久化存储。错误答案形态:三个都答"对";或对③只答"能实现"而不提队头阻塞与等级取整两个硬限制。追问方向:Redis ZSet 方案怎么保证多实例不重复执行同一个到期任务?(ZREM 的返回值:返回 1 的实例才算抢到,返回 0 说明已被别的实例取走。)
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 你怎么保证消息不丢? | 三段论完整性 | 错误回答:“用 acks=all 就行”,或只答生产端。必须逐段说:生产端同步确认/回调重试 + Broker 端副本与刷盘 + 消费端"先处理再提交" | 33.2 |
已经用 acks=all 了,为什么还会丢消息? | 参数组合 | 错误回答:“不可能丢”。正确:min.insync.replicas 默认 1 会让 acks=all 退化;unclean.leader.election=true 允许脏副本上位截断日志;全 ISR 同时掉电且未刷盘 | 33.2.2、33.3.4 |
HW 和 LEO 有什么区别?消费者为什么只能读到 HW 之前的消息? | 副本可见性 | 错误回答:“两者都是日志末尾,差不多”。必须说 LEO 是本副本末尾、HW = min(ISR 所有 LEO),并解释若允许读 HW 之后,该消息可能被新 leader 截断而"已消费的数据消失" | 33.3.2 |
leader epoch 是干什么的?没有它会怎样? | 副本恢复的截断点 | 错误回答:“用来加快选主”。正确:HW 传播异步,恢复时用"对端当前 LEO"判断截断点会误删数据;leader epoch 用日志自身的纪元元数据确定截断点 | 33.3.3 |
| 消息为什么会重复?怎么保证幂等? | 语义边界 | 错误回答:“重复是 bug,应该避免”。正确:至少一次语义下重复来自生产者重试与位点提交失败,无法消除;方案要按"去重表 / Redis / 状态机 / 天然幂等"对比,并说明是否与业务同事务 | 33.4 |
| 幂等键用什么生成?用 UUID 行不行? | 幂等键设计 | 错误回答:“用 UUID 保证唯一”。正确:重发会生成新 UUID,去重失效;应用业务单据号(或"单据号 + 操作类型")并随消息传递 | 33.4.3 |
| 去重表会一直增长,你怎么处理? | 容量治理 | 错误回答:“不删"或"定期全表 DELETE"。正确:按时间范围分区,保留最长重试窗口的冗余(如 7 天),用 DROP PARTITION 或分批删除 | 33.4.3 |
| 怎么保证消息的顺序? | 保证层次与代价 | 错误回答:“用单分区就全局有序"却说不出吞吐代价。正确:先区分全局/分区/业务键三层,指出绝大多数只要业务键有序,再讲分区内乱序的两个来源 | 33.5 |
| 我配了顺序发送,为什么消费端还是乱序? | 乱序根因 | 错误回答:“MQ 的 bug”。正确:生产者重试 + max.in.flight.requests.per.connection>1 导致追加乱序(需 enable.idempotence);消费者多线程并发处理破坏顺序(需单线程或按业务键分发) | 33.5.1 |
| 线上消息积压了,你怎么处理? | 排查与出手顺序 | 错误回答:“加消费者实例”。必须先用生产/消费速率与 Lag 曲线定性,再说明实例数上限是分区数、分区只能增不能减、N → N+1 约 N/(N+1) 的键要迁移、扩容会破坏顺序 | 33.6 |
| 消费端很慢,可能是什么原因? | 消费慢的根因 | 错误回答:“消费逻辑写得不好”(等于没答)。要落到三类:单条重 I/O、同步调用串行、锁竞争;并给出批量、异步、缩锁粒度的具体手段 | 33.6 |
| 延迟消息怎么实现?为什么 RocketMQ 只能延时到固定档位? | 延迟消息谱系 | 错误回答:“用 Thread.sleep"。要讲清固定 18 级的设计取舍、RabbitMQ 队列级 TTL 的队头阻塞、时间轮的多层结构与 O(1) 复杂度、ZSet + 轮询的通用方案 | 33.7 |
| 时间轮的原理是什么?和最小堆比有什么优势? | 数据结构 | 错误回答:“就是定时轮询扫描”。正确:槽位 + tickMs + 轮数记账,插入与到期均 O(1),多层是为了把精度与跨度分离;最小堆是 O(log n) | 33.7.1 |
| RocketMQ 的事务消息流程是怎样的?为什么需要回查? | 两阶段与决议持久化 | 错误回答:“半消息先发、本地事务成功再提交”(只背流程)。必须说清提交指令本身可能丢失,回滚与投递都会造成不一致,因此反查生产者以取得确定决议 | 33.8.2 |
| Kafka 的精确一次和业务幂等是一回事吗? | 边界认知 | 错误回答:“Kafka 支持 EOS,所以不需要幂等”。正确:EOS 只约束 Kafka 内部读写,写外部库仍需业务幂等 | 33.8.3 |
| 你们为什么选 Kafka 而不是 RocketMQ? | 选型判据 | 错误回答:“Kafka 性能最好”。必须先给约束(吞吐、延迟、是否需要事务/延迟消息/回溯、运维能力)再给结论 | 33.9 |
延伸阅读
- Apache Kafka 官方文档:Replication、Design — Message Delivery Semantics、Exactly-Once Semantics 章节,以及
KIP-101(用leader epoch替代HW做日志截断)与KIP-98(事务与幂等生产者); - Apache RocketMQ 官方文档:Transaction Message、Scheduled Message(18 级延时等级)、Message Queue Selector 与顺序消费章节;RabbitMQ 官方文档:Time-To-Live and Expiration、Dead Letter Exchanges;
- Kafka 源码:
core/src/main/scala/kafka/log/Log.scala(HW/LEO推进与截断)、clients/.../producer/internals/Sender.java(在途请求与enable.idempotence)、server-common/.../timer/TimingWheel.java(时间轮实现); - 另见第 26 章 26.2 节(主从复制的丢数据窗口)、第 21 章 21.5 节(两阶段提交的崩溃点分析)、第 34 章 34.3 节(Raft 用
(term, index)判定日志新旧的同源思想)、第 35 章 35.9 节(接口幂等的落地)。