本章解决"消息会不会丢、会不会重、会不会乱"三个问题,并给出积压、延迟消息与选型的判据。

学习目标

读完本章,你应当能够:

  1. 说清 MQ 的四个核心价值与它引入的三类新问题,并判断哪些场景不该引入 MQ;
  2. 沿"生产者 → Broker → 消费者"三段逐段列出丢失原因与对应防线,给出每段"最多能丢多少"的量化判断;
  3. 用 LEO、HW、ISR、leader epoch 解释 Kafka 的持久化与副本机制,推导 HW 截断为什么造成数据不一致;
  4. 论证"至少一次语义下重复不可避免",在给定业务下选出幂等方案、设计幂等键与去重表治理策略;
  5. 区分全局有序、分区有序、业务键有序三种层次,指出 Kafka 分区内有序被破坏的两个具体原因;
  6. 用"生产速率 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 消息丢失的三个环节与各自的防线(第一大必考:必须逐段拆开,因为三段防线不同、责任方也不同)

1
2
3
4
  生产者 Producer              Broker(存储)            消费者 Consumer
  send(msg) ──①网络+确认──►  页缓存 → 磁盘   ──③拉取+位点──►  处理业务 → 提交位点
         ◄──── ack/回调 ────  副本复制(ISR)
   丢这↑"发出去了没人收到"   丢这↑"收到了没落稳/没复制"   丢这↑"处理完了位点丢了/跳了"

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=allleader 写完即 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 条静默丢失
手动提交(同步 / 异步)业务代码显式提交,可控制时机正确用法的前提是顺序不能反

“先处理业务再提交位点"的顺序为什么不能反 —— 只要提交与处理之间有间隙,故障就必然落在间隙里:

1
2
3
4
5
顺序A(正确):拉取 → 处理业务 → 提交位点
   崩溃于"处理完、未提交" → 重启后重复消费该批 → 偏差 = 重复(至少一次)
顺序B(错误):拉取 → 提交位点 → 处理业务
   崩溃于"提交后、处理前" → 重启后从位点之后开始 → 这条消息永远不被处理
   偏差 = 丢失(至多一次)

只要提交与处理之间有间隙,故障就必然落在间隙里 —— 选择"先处理后提交"就是把偏差定向到重复(可被幂等消除,见 33.4 节),而不是丢失(无法事后恢复),可靠性工程的通用手法即"把误差往可修的那一侧推”。消费失败的重试与死信:消费抛异常时不提交位点、按退避策略重试,超过阈值的消息投入死信队列(dead letter queue, DLQ)并告警;注意死信不等于丢弃(要有补偿入口),且重试必须限次且退避,否则一条毒消息(poison message)会永久阻塞该分区 —— RocketMQ 用 %RETRY% 主题与延迟等级实现重试,失败 16 次后进 %DLQ%。

警告

只在本地 for 循环里重试而不做退避与限次,会让单分区吞吐归零,并表现为"消费者 CPU 很低但 Lag 一直涨",这是 33.6 节排查表里最容易被误判成"消费慢"的一种。

33.3 Kafka 的持久化与副本机制

33.3.1 分区、副本与 ISR

1
  分区 = 并行消费上限与有序单位;副本只提供冗余(rf=3 时 P0/P1/P2 的 leader 分别为 B1/B2/B3)

分区(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 之前的消息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
   分区日志(同一份数据在 leader 与两个 follower 上的进度)
   偏移量:  0    1    2    3    4    5    6    7
            ┌────┬────┬────┬────┬────┬────┬────┬────┐
   leader   │ m0 │ m1 │ m2 │ m3 │ m4 │ m5 │ m6 │    │  LEO = 7
            └────┴────┴────┴────┴────┴────┴────┴────┘
                                ▲
                                └── HW = 5 ← 消费者可见边界(只能读 [0,5))
   follower1│ m0 │ m1 │ m2 │ m3 │ m4 │ m5 │    │    │  LEO = 6
   follower2│ m0 │ m1 │ m2 │ m3 │ m4 │    │    │    │  LEO = 5 ← 决定 HW 的那个
   规则:HW = min(所有 ISR 副本的 LEO) = "所有副本都已持久化的最大偏移量"
  • 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 的传播是异步的,副本恢复时可能发生"日志被错误截断"。

1
2
3
4
5
6
 ① 初始:leader A、follower B 均为 [m0..m5],HW = 5,两端一致
 ② B 因网络抖动离开 ISR,A 继续写入 m6;此时 A 的 LEO = 7,HW 仍 = 5
 ③ A 崩溃,B 当选新 leader(ISR 只剩它),而 B 不知道 m6 存在
 ④ A 重启作为 follower 向 B 拉取,B 报自己的 LEO = 6 → A 只能把 m6 截断(丢失)
 ⑤ 危险场景:A 的 HW 曾被推到 6(某中间副本短暂进过 ISR),而 B 上其实没有 m5;B 当选后
    A 按 B 的 LEO 截断,把 B 缺失的 m5 一并删掉 → 两个副本各有一半、最终都缺(静默损坏)

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 未落盘任何一次故障都可能丢,无上限,只能按故障频率估期望
1leader 写入本地日志(进页缓存)成功高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 兜住状态跃迁,靠影响行数判定是否重复):

1
2
3
4
5
-- 关键在 WHERE status = 'UNPAID':重复执行时第二次影响 0 行,
-- 于是"是否首次处理"由数据库的行锁与条件判断一次性给出,没有查询-判断竞态
UPDATE orders SET status = 'PAID', paid_at = NOW(), pay_no = #{payNo}
 WHERE order_id = #{orderId} AND status = 'UNPAID';
-- 返回 1 表示首次处理成功;返回 0 表示已处理过或状态不合法,直接 ACK 掉该消息

去重表(INSERT 冲突与业务写在同一事务):

1
2
3
4
5
6
7
8
9
-- 幂等键上的唯一索引是全部保证的来源:并发重复消息只有一条能插入成功,
-- 另一条在数据库层面被拒,不依赖应用层的"先查再插"(那有竞态窗口)
CREATE TABLE consumed_msg (
    idempotent_key VARCHAR(64)  NOT NULL COMMENT '幂等键:业务单据号,天然去重',
    topic          VARCHAR(64)  NOT NULL COMMENT '来源主题,便于容量治理与回溯',
    consumed_at    DATETIME(3)  NOT NULL COMMENT '用于按时间归档清理',
    PRIMARY KEY (idempotent_key),
    KEY idx_topic_time (topic, consumed_at) COMMENT '给归档删除用,避免全表扫'
) ENGINE = InnoDB;

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 分区内有序被破坏的两个原因(必考)

原因一:生产者重试导致的乱序。

1
2
3
4
5
6
 max.in.flight.requests.per.connection = 5(默认,允许 5 个请求在途)
 ① m1 因超时被判失败进入重试队列;② m2 成功写入,日志中 m2 排在前面
 ③ 重试的 m1 随后到达并追加 → 最终顺序 m2, m1  ← 乱序
 解法:enable.idempotence=true 给每条消息附 (producerId, epoch, seq),Broker 按 seq
       去重并拒绝乱序追加;此时 in.flight 最大可到 5 而仍保序。或把 in.flight 降到 1,
       代价是吞吐显著下降,通常不选

原因二:消费者多线程消费破坏处理顺序。

1
2
3
4
5
6
7
8
 拉取线程一次拉到 [m1..m10](同一分区,有序),提交给线程池并发处理
 线程 A 取 m1(慢,下游 200 ms),线程 B 取 m2(快,20 ms)
 → 业务侧先看到 m2 的效果  ← 顺序被破坏,位点提交也变得无法界定
 解法:① 单线程消费该分区(并行度靠分区数提供);
       ② 按业务键二次分发:拉取线程把消息按 order_id 哈希投到 N 个内存队列,
          每队列一线程串行消费 —— 顺序以业务键为界,不牺牲整体吞吐;
       ③ 消费时对业务键加分布式锁(见第 34 章 34.5 节),但锁只保证互斥、
          不保证先后,仍需配合版本号或序列号判定新旧

警告

“多线程消费后加锁就能保序"是错答:锁让两个线程不同时执行,不保证执行次序与到达次序一致。要保序,必须让同一业务键的消息在同一时刻只有一条在途,即"串行化 + 顺序投递”,加锁只是必要条件而非充分条件。

RocketMQ 的做法:生产者用 MessageQueueSelector 显式选队列(业务键取模选 MessageQueue),替代 Kafka 的隐式哈希;消费者用 MessageListenerOrderly,其内部对每个 MessageQueue 加本地锁,确保一个队列同一时刻只被一个线程消费,并向 Broker 申请队列的分布式锁,避免 rebalance 后两个实例同时消费同一队列。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 用业务键显式选队列:让"同一订单"必然落到同一队列,顺序保证的边界由此确定
producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> queues, Message msg, Object arg) {
        String orderId = (String) arg;
        // 取模而不是随机:随机会让同一订单散落到不同队列,顺序保证直接失效
        int index = Math.abs(orderId.hashCode()) % queues.size();
        return queues.get(index);
    }
}, orderId);

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 个槽位,内存与空推进成本都不可接受。多层时间轮的解法是把"精度"与"跨度"分离 —— 低层管精度、高层管跨度,只在低层空转。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 多层时间轮(tickMs = 1 s,wheelSize = 60,层数 = 3)→ 总跨度 60³ = 216000 s = 60 h
  第三层(跨度 1 h)  ┌──┬──┬──┬ ... ──┬──┐  指针每 3600 s 走一格
 第二层(跨度 60 s) ┌──┬──┬──┬ ... ──┬──┐   指针每 60 s 走一格
 第一层(跨度 60 s,tickMs = 1 s)┌──┬──┬──┬ ... ──┬──┐ 指针每 1 s 走一格
                                  ▲ 当前槽位:放入"1 s 内到期"的任务
 加入"90 s 后到期":90 s > 第一层跨度 60 s → 上升;90 s ≤ 第二层跨度 60×60 s
   → 放入第二层第 (90/60)=1 格(配合当前指针的剩余量计算轮数)
 到期路径:第二层指针走到该格 → 整格任务**降级重新插入第一层**(按剩余时间精确定位),
          第一层指针走到对应槽位 → 取出执行
 复杂度:O(1) 插入、O(1) 到期取出;代价是"降级"时的重新插入与每个任务的轮数(round)记账

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 两阶段流程与回查

1
2
3
4
5
6
7
8
9
 生产者                     Broker                     本地数据库
   │ ① 发送半消息(half msg)   │                            │
   │ ────────────────────────►│ 存入 RMQ_SYS_TRANS_HALF_TOPIC
   │ ◄────────────────────────│ 返回成功(消息对消费者不可见)
   │ ② 执行本地事务 ──────────────────────────────────────►│
   │ ◄──────────────────────────────────────────────────────│
   │ ③ commit → 消息转入真实主题(可见);rollback → 删半消息 │
   │     ④ Broker 迟迟没收到 ③(生产者崩溃/网络断)→ 回查     │
   │ ⑤ 生产者查本地事务表/业务表判定 final 状态 → 再次 commit/rollback

为什么必须有回查:第 ③ 步的提交指令本身是一个网络请求,它可能丢失,此时生产者不知道 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 选型对比

维度KafkaRocketMQRabbitMQPulsar
模型分区日志(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 与 LEOLEO 是本副本日志末尾,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 节(接口幂等的落地)。