# 第 33 章 消息队列：可靠性三件套


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

## 学习目标

读完本章，你应当能够：

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 是"写后通知"，语义不匹配）；消息量极小且偶发，运维一个集群的成本远超收益。

> [!IMPORTANT]
> 面试开场就问"为什么要用 MQ"时，正确答法是"**收益 + 代价 + 我为什么判断这个业务的收益大于代价**"。只背四个价值会被追问"引入 MQ 后你的系统可用性怎么变的"，答不出乘积关系就露了。

## 33.2 消息丢失的三个环节与各自的防线（第一大必考：必须逐段拆开，因为三段防线不同、责任方也不同）

```
  生产者 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=all` | leader 写完即 ack、随后掉电 → 丢掉自上次刷盘以来的全部数据，**"配了 all 却仍然丢"的经典现场** |

> [!CAUTION]
> `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 条静默丢失 |
| 手动提交（同步 / 异步） | 业务代码显式提交，可控制时机 | 正确用法的前提是**顺序不能反** |

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

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

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

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

## 33.3 Kafka 的持久化与副本机制

### 33.3.1 分区、副本与 ISR

```
  分区 = 并行消费上限与有序单位；副本只提供冗余（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` 之前的消息

```
   分区日志（同一份数据在 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` 的传播是异步的**，副本恢复时可能发生"日志被错误截断"。

```
 ① 初始：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 节）。

> [!NOTE]
> 追问的下一层通常是"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 的前置条件**。

> [!IMPORTANT]
> 精确一次在 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` 兜住状态跃迁，靠影响行数判定是否重复**）：

```sql
-- 关键在 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` 冲突与业务写在同一事务**）：

```sql
-- 幂等键上的唯一索引是全部保证的来源：并发重复消息只有一条能插入成功，
-- 另一条在数据库层面被拒，不依赖应用层的"先查再插"（那有竞态窗口）
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 分区内有序被破坏的两个原因（必考）

**原因一：生产者重试导致的乱序。**
```
 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，
       代价是吞吐显著下降，通常不选
```

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

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

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

```java
// 用业务键显式选队列：让"同一订单"必然落到同一队列，顺序保证的边界由此确定
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.1~0.5 ms，算上服务端处理往往 10~100 ms，逐条处理 1000 条就是数十秒；② **同步调用串行** —— 消费逻辑里 `.get()` 阻塞等待，吞吐 = 1/单次耗时，解法是批量化或异步编排（见第 31 章 31.6 节）；③ **锁竞争** —— 为保序加了粗粒度锁、去重表唯一索引上热点键冲突频繁、或与在线业务争同一行的记录锁，解法是缩小锁粒度到业务键（见 33.5.1 的二次分发）、把去重放到内存 + 异步落库。

> [!TIP]
> 答题模板：**先定性（生产快 / 消费慢）→ 再定位（`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` 个槽位，内存与空推进成本都不可接受。**多层时间轮的解法是把"精度"与"跨度"分离** —— 低层管精度、高层管跨度，只在低层空转。

```
 多层时间轮（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`。

> [!NOTE]
> 追问的下一层是"时间轮和优先队列（最小堆）比，优势在哪"：最小堆插入与取最小都是 `O(log n)`，而时间轮两项都是 `O(1)`，任务量级越大（每毫秒成千上万个延迟请求）常数优势越明显。代价是空转成本、"轮数"记账，以及跨度由 `tickMs × wheelSize` 限定。

## 33.8 事务消息

"下单成功后发一条扣库存消息" —— 若先执行本地事务再发消息，**发消息失败**就破坏了业务；若先发消息再执行本地事务，**本地事务回滚**就产生了一条不该存在的消息（下游会扣掉不存在的库存）。这与 binlog 与 redo log 必须一致是同一类问题（见第 21 章 21.5 节），只是两个资源从"日志与数据页"变成了"数据库与 MQ"。

### 33.8.1 RocketMQ 两阶段流程与回查

```
 生产者                     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 选型对比

| 维度 | 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 的大规模运维成本反而低于引入一套新中间件。

> [!IMPORTANT]
> 选型题的标准答法是**先问清四个约束**：峰值吞吐与消息大小、端到端延迟要求、是否需要事务/延迟消息/回溯、团队已有运维能力。四项定下来，答案基本唯一。上来就说"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`。请指出这套配置下"消息可能丢失"的所有路径，并给出最小改动方案。

<details><summary>答案与解析</summary>

两条路径，都来自"参数组合被破除"，而非 `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`）。

</details>

**Q2**（推导）消费者采用"先提交位点、再处理业务"的顺序。请分析故障发生时的一致性后果，并说明为什么工程上普遍选择相反的次序。

<details><summary>答案与解析</summary>

**先提交后处理 = 至多一次（at-most-once）语义，偏差是"丢失"。** 位点一旦提交，重启后从该位点之后开始消费，而"提交成功"与"处理完成"之间存在一个必然的时间窗口；落到窗口里的故障（进程被杀、`OOM`、宕机、`rebalance`）会让这批消息**永远不被处理**，且**没有任何机制能发现** —— 位点已前进，Broker 认为它被消费过了。相反次序（先处理后提交）= 至少一次（at-least-once），偏差是"重复"；重复可检测、可消除：幂等键去重让重复只多一次无效果的写，而丢失不可恢复，只能靠对账补救。**工程选择的本质是"把误差推向可修的一侧"**。错误答案形态：① 说"两种顺序效果一样，只影响性能"；② 说"先提交再处理不会丢，因为 Kafka 保证不丢" —— 混淆了 Broker 侧的持久性保证与消费侧的处理保证。追问方向："`auto.commit` 在什么位置提交？"（后台定时提交**已拉取**的最大位点，完全不可控，等同"先提交后处理"）。

</details>

**Q3**（设计）订单支付成功后发消息通知积分服务。请设计幂等方案，说明幂等键怎么生成、去重表怎么治理，并解释为什么不让 `Redis SETNX` 单独承担。

<details><summary>答案与解析</summary>

**幂等键**：用支付流水号（或 `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 过期时间设多长？（要大于最长重试链路，否则过期后重复消息会被重新处理。）

</details>

**Q4**（计算/设计）某主题有 8 个分区、8 个消费者实例，`Lag` 持续线性增长，生产速率 8 万条/秒、消费速率 5 万条/秒，已积压 4000 万条。你按什么顺序处理？请给出数量级推算。

<details><summary>答案与解析</summary>

**先定性**：`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%` 键迁移与顺序破坏的代价。追问方向：扩容后如何验证顺序没被破坏？（对同一业务键抽样，检查新分区上的消息序号是否单调）。

</details>

**Q5**（辨析）判断下列说法：① "Kafka 支持精确一次语义，所以消费端不用做幂等"；② "时间轮就是用定时任务每秒扫一次所有延迟消息"；③ "RabbitMQ 的 `TTL` 加死信队列能实现任意精度的延迟消息，效果和 RocketMQ 的 18 级延时一样"。

<details><summary>答案与解析</summary>

① **错**。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 说明已被别的实例取走。）

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 你怎么保证消息不丢？ | 三段论完整性 | 错误回答："用 `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 节（接口幂等的落地）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch33/  

