目录
- [Kafka 可靠投递原理篇](#Kafka 可靠投递原理篇)
- 一、先看一张大图
- [二、PID 是怎么产生的](#二、PID 是怎么产生的)
-
- [2.1 普通幂等生产者](#2.1 普通幂等生产者)
- [2.2 事务生产者](#2.2 事务生产者)
- [2.3 对比](#2.3 对比)
- [三、Sequence 是怎么产生的](#三、Sequence 是怎么产生的)
-
- [3.1 分配者:Producer 客户端](#3.1 分配者:Producer 客户端)
- [3.2 分配规则](#3.2 分配规则)
- [3.3 Broker 做什么](#3.3 Broker 做什么)
- [3.4 为什么 Sequence 由 Producer 分配](#3.4 为什么 Sequence 由 Producer 分配)
- 四、幂等性靠哪几个值保证,为什么只保证单分区单会话
- 五、多批同时发送会产生什么问题
-
- [5.1 流水线发送](#5.1 流水线发送)
- [5.2 多批同时发送的问题](#5.2 多批同时发送的问题)
- [5.3 Producer 怎么处理](#5.3 Producer 怎么处理)
- [5.4 为什么 `max.in.flight` 不能超过 5](#5.4 为什么
max.in.flight不能超过 5) - [5.5 重试的单位是批次,不是数据条数](#5.5 重试的单位是批次,不是数据条数)
- [5.6 重试的维度是 `(PID, Partition)`](#5.6 重试的维度是
(PID, Partition))
- [六、事务如何保证 Exactly-Once](#六、事务如何保证 Exactly-Once)
-
- [6.1 事务解决什么问题](#6.1 事务解决什么问题)
- [6.2 事务的完整流程](#6.2 事务的完整流程)
-
- [阶段 1:初始化](#阶段 1:初始化)
- [阶段 2:开始事务](#阶段 2:开始事务)
- [阶段 3:发送消息](#阶段 3:发送消息)
- [阶段 4:提交事务(两阶段提交)](#阶段 4:提交事务(两阶段提交))
- [阶段 5:异常回滚](#阶段 5:异常回滚)
- [6.3 事务如何实现跨会话幂等](#6.3 事务如何实现跨会话幂等)
- [6.4 事务如何实现跨分区原子](#6.4 事务如何实现跨分区原子)
- [6.5 事务如何实现"消费位移 + 生产消息"原子](#6.5 事务如何实现"消费位移 + 生产消息"原子)
- [6.6 事务的边界](#6.6 事务的边界)
- [6.7 事务和幂等生产者的关系](#6.7 事务和幂等生产者的关系)
- [6.8 多实例下 transactional.id 必须不同](#6.8 多实例下 transactional.id 必须不同)
-
- [6.8.1 为什么必须不同](#6.8.1 为什么必须不同)
- [6.8.2 正确的命名规则](#6.8.2 正确的命名规则)
- [6.8.3 和跨会话恢复的关系](#6.8.3 和跨会话恢复的关系)
- [6.8.4 三种命名方式对比](#6.8.4 三种命名方式对比)
- [6.8.5 死信搬运场景怎么选](#6.8.5 死信搬运场景怎么选)
- [6.8.6 加锁后其实只有一个实例在跑](#6.8.6 加锁后其实只有一个实例在跑)
- [6.8.7 完整配置](#6.8.7 完整配置)
- [6.8.8 一句话](#6.8.8 一句话)
- [七、Epoch 在事务里发挥什么作用](#七、Epoch 在事务里发挥什么作用)
-
- [7.1 场景:僵尸生产者](#7.1 场景:僵尸生产者)
- [7.2 Epoch 的三个关键作用](#7.2 Epoch 的三个关键作用)
- [7.3 Epoch 和 Sequence 的区别](#7.3 Epoch 和 Sequence 的区别)
- 八、什么时候用事务、什么时候不用
-
- [8.1 一般不用事务的原因](#8.1 一般不用事务的原因)
- [8.2 什么时候要用事务](#8.2 什么时候要用事务)
- [8.3 核心判断](#8.3 核心判断)
- [九、Kafka 自动重试为什么比 RabbitMQ 好](#九、Kafka 自动重试为什么比 RabbitMQ 好)
-
- [9.1 RabbitMQ 没有自动重试](#9.1 RabbitMQ 没有自动重试)
- [9.2 Kafka 有内置自动重试](#9.2 Kafka 有内置自动重试)
- [9.3 对比](#9.3 对比)
- 十、租约时间设置原则
-
- [10.1 核心原则](#10.1 核心原则)
- [10.2 RabbitMQ 的租约](#10.2 RabbitMQ 的租约)
- [10.3 Kafka 的租约](#10.3 Kafka 的租约)
- [10.4 对比](#10.4 对比)
- [10.5 批量抢占的租约](#10.5 批量抢占的租约)
- 十一、配置项和机制的关系
-
- [11.1 一张表看懂](#11.1 一张表看懂)
- [11.2 配置项和机制的对应](#11.2 配置项和机制的对应)
- [11.3 配置项之间的依赖](#11.3 配置项之间的依赖)
- 十二、总结表
- 十三、一句话
Kafka 可靠投递原理篇
面向已经落地了 Outbox 方案的读者,解释 Kafka 可靠投递背后的核心机制:PID、Sequence、幂等、批次、事务、Exactly-Once、租约。读完能回答"为什么这么配置、为什么这么写代码"。
一、先看一张大图
┌─────────────────────────────────────────────────────────────────────────────┐
│ Kafka 生产者可靠投递全景 │
└─────────────────────────────────────────────────────────────────────────────┘
Producer 进程
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ① 业务代码调用 send() │
│ │ │
│ ▼ │
│ ② RecordAccumulator(内存缓冲区) │
│ │ │
│ │ 按 (Topic, Partition) 分组 │
│ │ 每组攒成批次 │
│ │ │
│ ▼ │
│ ③ 批次创建 │
│ │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ Batch 1: sequence 0, 1, 2 │ │
│ │ │ Batch 2: sequence 3, 4 │ │
│ │ │ Batch 3: sequence 5, 6, 7 │ │
│ │ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ④ Sender 线程发送 │
│ │ │
│ │ max.in.flight.requests.per.connection = 5 │
│ │ → 最多 5 个批次同时在途(流水线发送) │
│ │ │
│ ▼ │
│ ⑤ 等待 Broker 响应 │
│ │ │
│ │ delivery.timeout.ms = 30000 ← 单次发送最长等待 30 秒 │
│ │ retries = MAX ← 30 秒内一直重试 │
│ │ │
│ ▼ │
│ ⑥ 回调返回结果 │
│ ├─ 成功 → 调用方改状态 CONFIRMED │
│ └─ 失败 → 调用方改状态 PENDING(重试) │
│ │
└───────────────────────────────────────────────────────────────────────┘
│
│ 消息带 PID + Sequence + Partition
▼
Broker 集群
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ⑦ 按 (PID, Partition) 维护 lastSequence │
│ │
│ (PID=1001, order-0) → lastSequence = 4 │
│ (PID=1001, order-1) → lastSequence = 7 │
│ (PID=1002, order-0) → lastSequence = 1 │
│ │
│ ⑧ 校验 sequence │
│ ├─ sequence == lastSequence + 1 → 接受,更新 │
│ ├─ sequence <= lastSequence → 重复,丢弃,返回成功 │
│ └─ sequence > lastSequence + 1 → 跳跃,拒绝 │
│ │
│ ⑨ 副本机制 │
│ acks=all → 等所有 ISR 副本确认 │
│ min.insync.replicas=2 → 至少 2 个副本确认 │
│ replication.factor=3 → 每个分区 3 个副本 │
│ │
└───────────────────────────────────────────────────────────────────────┘
│
│ 事务场景额外有
▼
TransactionCoordinator
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ⑩ 管理 transactional.id │
│ → 分配 PID + Epoch(每次 initTransactions 递增 Epoch) │
│ → 隔离旧实例(旧 Epoch 的请求被拒绝) │
│ │
│ ⑪ 两阶段提交 │
│ → 写 PREPARE_COMMIT 到 __transaction_state │
│ → 通知分区写 COMMIT 控制消息 │
│ → 写 COMPLETE_COMMIT │
│ │
└───────────────────────────────────────────────────────────────────────┘
二、PID 是怎么产生的
2.1 普通幂等生产者
Producer 启动
→ 发送 InitProducerIdRequest(不带 transactional.id)
→ Broker 生成一个全局唯一的 PID
→ 返回给 Producer
- 每次 Producer 启动都申请新 PID;
- 进程重启后 PID 变,旧 PID 作废;
- 只保证单会话内不重复。
2.2 事务生产者
Producer 启动
→ 调用 initTransactions()
→ 向 TransactionCoordinator 发送 InitProducerIdRequest(带 transactional.id)
→ Coordinator 检查 transactional.id 历史状态:
├─ 首次:分配新 PID + Epoch=0
└─ 已有:递增 Epoch,隔离旧实例
→ 返回 PID + Epoch
Epoch 的作用: 隔离旧实例。旧实例 Epoch=1,新实例 Epoch=2,旧实例再发请求会被 Coordinator 拒绝,抛 ProducerFencedException。
2.3 对比
| 幂等生产者 | 事务生产者 | |
|---|---|---|
| 有 PID | ✅ | ✅ |
| 有 Epoch | ❌ | ✅ |
| 跨会话恢复 | ❌ | ✅(通过 transactional.id) |
| 重启后 PID | 变 | 可恢复(Epoch 递增) |
三、Sequence 是怎么产生的
3.1 分配者:Producer 客户端
Sequence 不是 Broker 分配的,是 Producer 进程内生成的。
producer.send(record)
↓
消息进入 RecordAccumulator
↓
按 (Topic, Partition) 找到当前批次
↓
批次创建时,分配起始 sequence
↓
批次内每条消息按顺序编号
整个过程在 Producer 内存里完成,不涉及 Broker。
3.2 分配规则
Sequence 是 (PID, Topic-Partition) 维度 的,从 0 开始,单调递增。
PID=1001, topic=order, partition=0
Batch 1: sequence 0, 1, 2
Batch 2: sequence 3, 4
Batch 3: sequence 5, 6, 7
注意:
- 同一个 Producer,不同 Partition 的 sequence 是独立计数的;
- Partition 0 从 0 开始,Partition 1 也从 0 开始;
- PID 变了(Producer 重启),sequence 重置为 0;
- 批次创建时分配,重试时 sequence 不变。
3.3 Broker 做什么
Broker 不分配 sequence,只做两件事:
记录 lastSequence:
(PID=1001, order-0) → lastSequence = 4
(PID=1001, order-1) → lastSequence = 7
校验 sequence:
| 收到的 sequence | Broker 动作 |
|---|---|
== lastSequence + 1 |
正常写入,更新 lastSequence |
<= lastSequence |
重复,丢弃,返回成功 |
> lastSequence + 1 |
跳跃,抛 OutOfOrderSequenceException,拒绝 |
3.4 为什么 Sequence 由 Producer 分配
- 只有 Producer 知道消息在本地缓冲区里的顺序;
- 如果让 Broker 分配,每发一条都要问 Broker,多一次网络往返,吞吐暴跌;
- Producer 分配,Broker 只校验,简单高效。
四、幂等性靠哪几个值保证,为什么只保证单分区单会话
4.1 三个核心值
| 值 | 谁分配 | 作用 |
|---|---|---|
| PID | Broker | 标识是哪个 Producer 实例 |
| Sequence | Producer | 标识这个批次是该 PID 在该分区的第几个批次 |
| Partition | Producer 分区器决定 | 标识消息属于哪个分区 |
组合起来:(PID, Partition, Sequence) 全局唯一标识一个批次。
4.2 为什么能保证幂等
Broker 按 (PID, Partition) 维护 lastSequence:
收到批次 → 查 (PID, Partition) 的 lastSequence
├─ sequence == lastSequence + 1 → 接受,更新
├─ sequence <= lastSequence → 重复,丢弃,返回成功
└─ sequence > lastSequence + 1 → 跳跃,拒绝
关键逻辑:
- 重试时 sequence 不变,Broker 一看
<= lastSequence,判定重复,丢弃; - 新批次 sequence 一定是
lastSequence + 1,正常接受; - 跳跃说明中间丢了批次,直接拒绝,不会静默丢消息。
4.3 完整例子
Batch 1: sequence 0, 1, 2 → 发送成功,lastSequence = 2
Batch 1 重试: sequence 0, 1, 2 → 0~2 <= 2 → 丢弃,返回成功
Batch 2: sequence 3, 4 → 3 == 2+1 → 接受,lastSequence = 4
重试不会产生重复,因为 Broker 认识 sequence。
4.4 为什么只保证"单分区单会话"
单分区
lastSequence 是按 (PID, Partition) 维护的,跨分区不共享。
(PID=1001, Partition=0) → lastSequence = 4
(PID=1001, Partition=1) → lastSequence = 7
两个分区独立,各自维护自己的 lastSequence。幂等性只在单分区内有效。
单会话
PID 每次 Producer 重启都变,新会话是新 PID,Broker 不认识旧会话的 sequence。
跨会话场景:
1. Producer(PID=1001)发送 msgX
2. Broker 写入成功,但 ack 丢了
3. Producer 进程崩溃
4. Producer 重启,申请新 PID=1002
5. 业务重发 msgX → Broker 认为是 PID=1002 的新消息
6. msgX 被写入两次
Broker 按 (PID, Partition) 维护 lastSequence,PID 变了就是全新记录,无法识别跨会话的重复。
跨会话的重复只能靠
- 消费端业务幂等 (
consumed_message表); - 或 Kafka 事务 (
transactional.id+ Epoch)。
五、多批同时发送会产生什么问题
5.1 流水线发送
Kafka 是流水线发送,不等上一个批次的响应就发下一个:
Batch 1 发出 → 不等 ack,立刻发 Batch 2
Batch 2 发出 → 不等 ack,立刻发 Batch 3
...
在途(in-flight)批次由 max.in.flight.requests.per.connection 控制:
= 1:严格串行,吞吐低
= 5:最多 5 个批次同时在途,吞吐高
> 5:Kafka 不支持(幂等生产者限制)
5.2 多批同时发送的问题
场景:Batch 1 失败,Batch 2~5 已经发出去了
T1: Batch 1(sequence 0~2)发出
T2: Batch 2(sequence 3~4)发出
T3: Batch 3(sequence 5~7)发出
T4: Batch 4(sequence 8~9)发出
T5: Batch 5(sequence 10~12)发出
Broker 收到 Batch 2:
lastSequence = -1,收到 sequence 3
3 > -1 + 1 = 0 → 跳跃 → 拒绝
Broker 收到 Batch 3、4、5:
同样拒绝
Batch 2~5 全部被拒绝,因为 Batch 1 还没成功。
5.3 Producer 怎么处理
1. Producer 收到 Batch 1 的失败
2. Producer 收到 Batch 2~5 的 OutOfOrderSequenceException
3. Producer 把这些批次全部标记为"需要重试"
4. Producer 优先重试 Batch 1
5. Batch 1 重试成功 → lastSequence = 2
6. Producer 重试 Batch 2 → 成功 → lastSequence = 4
7. Producer 重试 Batch 3 → 成功 → lastSequence = 7
...
最终全部成功,顺序正确,一条不丢。
5.4 为什么 max.in.flight 不能超过 5
- Broker 的 sequence 是按批次顺序检查的;
- 同时太多批次在途,失败时重试逻辑复杂;
- 超过 5 个,幂等生产者无法保证顺序和去重。
5.5 重试的单位是批次,不是数据条数
Batch 1: sequence 0~4(5 条消息)→ 失败 → 整个批次重试
Batch 2: sequence 5~9(5 条消息)→ 失败 → 整个批次重试
retries、delivery.timeout.ms限制的是批次重试;- 批次是原子的:要么全成功,要么全失败;
- 同一批次内的回调结果一致。
5.6 重试的维度是 (PID, Partition)
| 问题 | 答案 |
|---|---|
| 重试单位 | 批次 |
| 批次归属 | (PID, Topic-Partition) |
| 同分区重试顺序 | 按 sequence 顺序,前面失败阻塞后面 |
| 跨分区重试 | 互不影响,独立并行 |
为什么按 (PID, Partition) |
不同 Producer 实例的 sequence 独立,必须按 PID 隔离 |
六、事务如何保证 Exactly-Once
6.1 事务解决什么问题
幂等生产者只能保证"单分区单会话内不重复",跨会话、跨分区、跨系统都不管。
事务在幂等生产者基础上,额外解决三个问题:
| 问题 | 幂等生产者 | 事务 |
|---|---|---|
| 跨会话重复 | ❌ | ✅(Epoch 隔离) |
| 跨分区原子写 | ❌ | ✅ |
| 消费位移 + 生产消息原子 | ❌ | ✅(sendOffsetsToTransaction) |
6.2 事务的完整流程
阶段 1:初始化
Producer 启动
→ 调用 initTransactions()
→ 向 Coordinator 发送 InitProducerIdRequest(带 transactional.id)
→ Coordinator 检查 transactional.id:
├─ 首次:分配 PID + Epoch=0
└─ 已有:递增 Epoch,隔离旧实例
→ 返回 PID + Epoch
阶段 2:开始事务
producer.beginTransaction()
→ 本地标记事务开始
阶段 3:发送消息
producer.send(record1)
producer.send(record2)
→ 消息带 PID + Epoch + Sequence
→ Broker 写入,但标记为"未提交"
→ 消费者配置 read_committed 时看不到
阶段 4:提交事务(两阶段提交)
producer.commitTransaction()
→ 发送 EndTxnRequest(COMMIT) 到 Coordinator
→ Coordinator 执行两阶段提交:
第一阶段:写 PREPARE_COMMIT 到事务日志 __transaction_state
第二阶段:通知所有涉及的分区写 COMMIT 控制消息
→ 写 COMPLETE_COMMIT
→ 消息对 read_committed 消费者可见
阶段 5:异常回滚
producer.abortTransaction()
→ 发送 EndTxnRequest(ABORT) 到 Coordinator
→ Coordinator 写 PREPARE_ABORT
→ 通知分区写 ABORT 控制消息
→ 写 COMPLETE_ABORT
→ 消息被标记为 ABORT,消费者看不到
6.3 事务如何实现跨会话幂等
幂等生产者跨会话失效的原因:PID 变了,Broker 不认识旧会话的 sequence。
事务解决方式:
1. Producer A(PID=1001, Epoch=1)发送 msgX,Broker 写入成功
2. A 崩溃,ack 丢了
3. A 重启,用相同 transactional.id 调 initTransactions()
4. Coordinator 分配 PID=1001,Epoch=2(递增)
5. A 准备重发 msgX
6. Coordinator 先中止 A 的未完成事务(Epoch=1 的事务)
7. 重发 msgX,带 Epoch=2,写入成功
8. 最终只有一条 msgX
关键:
transactional.id是跨会话的"身份标识";- 每次
initTransactions,Coordinator 递增 Epoch; - 旧 Epoch 的未完成事务被中止;
- 新 Epoch 的操作正常执行。
6.4 事务如何实现跨分区原子
普通生产者: 发到 Partition 0 和 Partition 1 的消息,各自独立。
事务生产者:
producer.beginTransaction()
producer.send(Partition 0, msg1)
producer.send(Partition 1, msg2)
producer.commitTransaction()
Coordinator 协调两个分区:
- 要么两个分区都写 COMMIT;
- 要么两个分区都写 ABORT;
- 不会出现"Partition 0 成功、Partition 1 失败"。
6.5 事务如何实现"消费位移 + 生产消息"原子
这是 Exactly-Once 的核心。
producer.beginTransaction()
// 1. 发消息到下游 topic
producer.send(ORDER_TOPIC, record)
// 2. 把上游 topic 的位移也放进事务
producer.sendOffsetsToTransaction(offsets, groupId)
producer.commitTransaction()
保证:
- 要么"下游消息发出 + 上游位移提交"都成功;
- 要么都失败回滚;
- 不会出现"消息发了但位移没提交"或"位移提交了但消息没发"。
这就是 Kafka Streams 和死信搬运用的机制。
6.6 事务的边界
能保证:
- 单分区单会话内不重复(幂等生产者);
- 跨会话不重复(Epoch 隔离);
- 跨分区原子写;
- 消费位移 + 生产消息原子。
不能保证:
- 业务数据库 + Kafka 的原子性(还是得用 Outbox);
- 跨集群的原子性。
6.7 事务和幂等生产者的关系
配置 transactional.id
→ 自动开启 enable.idempotence=true
→ 幂等性 + 事务能力
事务生产者是幂等生产者的超集:
- 幂等生产者:PID + Sequence,单会话内不重复;
- 事务生产者:PID + Sequence + Epoch + Coordinator,跨会话、跨分区、Exactly-Once。
6.8 多实例下 transactional.id 必须不同
6.8.1 为什么必须不同
transactional.id 是"逻辑生产者"的身份标识。Coordinator 对它的规则是:
同一个 transactional.id 在同一时刻,只能被一个 Producer 实例使用。
实例 A 用 transactional.id = "dlq-retry"
实例 B 也用 transactional.id = "dlq-retry"
1. A 先启动,initTransactions() → 分配 PID=1001, Epoch=1
2. B 启动,initTransactions() → Coordinator 递增 Epoch=2
3. Coordinator 认为 B 是"新的 A",把旧的 A fencing 掉
4. A 再发请求 → 抛 ProducerFencedException
5. A 无法工作
结果:A 被踢掉,B 独占。两个实例不能同时用同一个 ID。
6.8.2 正确的命名规则
每个实例的 transactional.id 必须不同:
java
// 方案 1:应用名 + 实例ID(推荐)
String transactionalId = "dlq-retry-" + hostname + "-" + pid;
// 例:dlq-retry-pod-abc123-12345
// 方案 2:应用名 + 随机后缀(重启后变)
String transactionalId = "dlq-retry-" + UUID.randomUUID();
// 方案 3:应用名 + 序号(需要外部协调)
String transactionalId = "dlq-retry-" + instanceIndex;
推荐方案 1: hostname + pid,同一台机器同一个进程重启后 ID 不变,能实现跨会话恢复。
6.8.3 和跨会话恢复的关系
transactional.id 的设计目的之一是跨会话恢复:
实例 A 崩溃 → 重启 → 用同一个 transactional.id 注册
→ Coordinator 递增 Epoch,隔离旧的 A
→ 新的 A 继续工作
要让跨会话恢复生效,transactional.id 必须在重启前后保持一致。
如果用随机 UUID:
实例 A 崩溃 → 重启 → 新 UUID → 新身份
→ Coordinator 认为是全新实例
→ 旧的 A 不会被 fencing(它自己已经死了)
→ 但也没法"恢复"旧 A 的未完成事务
6.8.4 三种命名方式对比
| 命名方式 | 多实例安全 | 跨会话恢复 |
|---|---|---|
| hostname + pid | ✅ | ✅(同机同进程重启) |
| 随机 UUID | ✅ | ❌(每次新身份) |
| 固定字符串 | ❌(互相 fencing) | ✅ |
6.8.5 死信搬运场景怎么选
死信搬运是人工触发的低频操作,实际只有两种选择:
方案 A:按实例固定(推荐)
java
private static final String INSTANCE_ID =
System.getenv().getOrDefault("HOSTNAME", "local") + "-" +
ProcessHandle.current().pid();
producerProps.put("transactional.id", "dlq-retry-" + INSTANCE_ID);
- 同一个实例多次调用共享 ID;
- 实例重启后 ID 变(pid 变了);
- 能实现同实例内的 fencing。
方案 B:每次调用随机
java
producerProps.put("transactional.id", "dlq-retry-" + UUID.randomUUID());
- 每次调用都是新身份;
- 不会 fencing 别人,也不会被别人 fencing;
- 但失去了"恢复旧事务"的能力。
死信搬运用方案 B 也可以,因为:
- 每次调用是独立短事务;
- 事务超时 60 秒,不会跨调用;
- 不需要恢复旧事务。
但方案 A 更规范,推荐方案 A。
6.8.6 加锁后其实只有一个实例在跑
回到之前的设计:
java
@PostMapping("/retry-all")
public String retryAll(@RequestParam String operator) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("dlq:retry:lock", operator, 60, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
return "another retry is running";
}
try {
return doRetryAll(operator);
} finally {
redisTemplate.delete("dlq:retry:lock");
}
}
加了 Redis 锁后,同一时刻只有一个实例在执行搬运。
所以实际上:
- 即使多个实例都部署了
retry-all接口; - 同一时刻只有一个在跑;
transactional.id冲突的概率极低。
但为了规范,还是建议每个实例用不同的 ID。
6.8.7 完整配置
java
/**
* 实例唯一标识,启动时生成一次。
* hostname + pid,同一台机器同一个进程重启后 ID 不变。
*/
private static final String INSTANCE_ID =
System.getenv().getOrDefault("HOSTNAME", "local") + "-" +
ProcessHandle.current().pid();
// 事务 ID 按实例固定
producerProps.put("transactional.id", "dlq-retry-" + INSTANCE_ID);
这样:
- 多实例之间 ID 不同,不会互相 fencing;
- 同一实例重启后 ID 不变(同机同进程),能实现跨会话恢复;
- 配合 Redis 锁,同一时刻只有一个实例在搬运。
6.8.8 一句话
多个实例同时运行时,
transactional.id必须不同,否则会互相 fencing,只有一个能工作。命名规则:
{应用名}-{实例ID},实例 ID 用 hostname + pid,保证多实例不同、同实例重启不变。死信搬运场景加了 Redis 锁后,同一时刻只有一个实例在跑,但为了规范还是建议每个实例用不同的 ID。
如果不需要跨会话恢复,每次用随机 UUID 也可以,但会失去 fencing 旧实例的能力。
七、Epoch 在事务里发挥什么作用
Epoch 是 PID 的"版本号",用于隔离旧实例。
7.1 场景:僵尸生产者
1. Producer A(PID=1001, Epoch=1)正在处理一个事务
2. A 因网络问题与 Coordinator 失联
3. A 重启,用相同 transactional.id 注册
4. Coordinator 分配新 Epoch=2,中止 A 的旧事务
5. 旧的 A 恢复后,尝试提交事务
6. Coordinator 看到 A 的 Epoch=1 < 当前 Epoch=2
7. 拒绝,抛 ProducerFencedException
8. 旧 A 无法写入 → 防止数据错乱
7.2 Epoch 的三个关键作用
| 作用 | 说明 |
|---|---|
| 隔离 | 新实例的 Epoch 一定大于旧实例 |
| 拒绝旧实例 | Coordinator 只接受当前 Epoch 的请求 |
| 保证跨会话 | 每次 initTransactions 都递增 Epoch,旧实例无法再操作 |
7.3 Epoch 和 Sequence 的区别
| Sequence | Epoch | |
|---|---|---|
| 谁分配 | Producer | Coordinator |
| 维度 | (PID, Partition) |
(transactional.id) |
| 递增时机 | 每个批次 | 每次 initTransactions |
| 作用 | 单会话内去重 | 跨会话隔离旧实例 |
| 归零时机 | PID 变时归零 | 永不归零,只递增 |
八、什么时候用事务、什么时候不用
8.1 一般不用事务的原因
| 原因 | 说明 |
|---|---|
| 管不了业务库 | 业务场景是"消费 Kafka → 写数据库",事务管不了数据库 |
| 性能开销 | 两阶段提交、Coordinator 交互,吞吐下降 |
| 复杂度高 | 要配 transactional.id、处理 ProducerFencedException、处理事务超时 |
| 消费端幂等更简单 | 数据库唯一键、consumed_message 表,够用 |
8.2 什么时候要用事务
| 场景 | 为什么用 |
|---|---|
| Kafka Streams | 流处理,Exactly-Once |
| 消费-转换-生产(A→B) | 位移提交和消息发送要原子 |
| 跨分区原子写 | 一次事务写多个分区,要么都成功要么都失败 |
| 死信全量搬运 | 从 dead-topic 消费、发到 order-topic,不能丢 |
8.3 核心判断
如果操作只在 Kafka 内部流转 → 可以用事务
如果涉及业务数据库 → 事务管不了,用 Outbox
如果只是发送消息 → 幂等生产者 + 消费端幂等,不用事务
九、Kafka 自动重试为什么比 RabbitMQ 好
9.1 RabbitMQ 没有自动重试
rabbitTemplate.send(exchange, routingKey, message);
// 发送失败 → 抛异常,没有自动重试
// publisher confirm 回调 → 只告诉你成功/失败,不自动重试
RabbitMQ 的重试要自己写: 失败后落库、定时任务重投、或者用延迟队列 + TTL。
9.2 Kafka 有内置自动重试
properties
retries=Integer.MAX_VALUE
delivery.timeout.ms=30000
enable.idempotence=true
- 网络抖动 → 自动重试;
- Leader 切换 → 自动重试;
- Broker 短暂不可用 → 自动重试;
- 30 秒内一直重试,超时才抛异常;
- 幂等生产者保证重试不重复。
9.3 对比
| RabbitMQ | Kafka | |
|---|---|---|
| 自动重试 | ❌ 自己写 | ✅ retries |
| 重试不重复 | 需要业务幂等 | ✅ 幂等生产者 |
| 顺序保证 | 需要自己控制 | ✅ max.in.flight=5 |
| 代码量 | 多 | 少 |
Kafka 把"网络重试、序列号管理、批次顺序"都内置了,业务代码只需要处理"进程崩溃"这一种极端情况。
十、租约时间设置原则
10.1 核心原则
租约 >= 单次发送最长阻塞时间 × 2~3
为什么是 2~3 倍: 留余量,避免正常发送还没完就被回收。
10.2 RabbitMQ 的租约
rabbitTemplate.send() → 立刻返回(毫秒级)
→ 等 publisher confirm 回调(异步,通常毫秒到秒级)
单次发送最长阻塞时间 ≈ confirm 回调时间(秒级)
| 参数 | 值 |
|---|---|
| 租约 | 30 秒 |
| 回收扫描间隔 | 30 秒 |
30 秒够,因为 confirm 通常秒级返回。
10.3 Kafka 的租约
kafkaTemplate.send() → 立刻返回(异步)
→ Kafka 客户端内部重试(retries=MAX)
→ 在 delivery.timeout.ms 内一直重试
→ 回调返回结果
单次发送最长阻塞时间 = delivery.timeout.ms
| 参数 | 值 |
|---|---|
delivery.timeout.ms |
30 秒 |
| 租约 | 60 秒(= 30 × 2) |
| 回收扫描间隔 | 30 秒 |
租约必须 >= delivery.timeout.ms。 否则:
T0: 抢占,租约 30 秒
T0: Kafka 客户端开始重试(delivery.timeout.ms=30 秒)
T30: 租约过期,回收器改回 PENDING
T31: 另一个实例抢占,又发一遍
T31: 第一个实例的 Kafka 客户端还在重试
T30~T60: 两个实例同时重试 → 大量重复
10.4 对比
| RabbitMQ | Kafka | |
|---|---|---|
| 单次发送最长阻塞 | confirm 回调(秒级) | delivery.timeout.ms(30 秒) |
| 租约 | 30 秒 | 60 秒 |
| 原因 | 没有客户端重试 | 客户端内部一直重试 |
核心区别:RabbitMQ 的 confirm 没回来就是没回来,30 秒够。Kafka 客户端在后台一直重试,租约必须覆盖这个窗口。
10.5 批量抢占的租约
如果一次抢占 20 条:
租约 >= max(单条最长发送时间, 批次串行数 × 单批发送时间) + 余量
因为 max.in.flight.requests.per.connection=5,同分区最多 5 个批次在途,其他批次要等。如果一批 30 秒,10 个批次串行就是 300 秒。
所以 BATCH_SIZE 不要太大,租约按最坏情况设。
十一、配置项和机制的关系
11.1 一张表看懂
| 配置项 | 值 | 关联的机制 |
|---|---|---|
acks |
all |
Broker 副本确认 |
retries |
MAX_VALUE |
客户端自动重试 |
enable.idempotence |
true |
PID + Sequence 去重 |
max.in.flight.requests.per.connection |
5 |
在途批次数量,顺序保证 |
delivery.timeout.ms |
30000 |
单次发送最长阻塞时间,决定租约 |
request.timeout.ms |
10000 |
单次请求超时 |
max.block.ms |
10000 |
缓冲区满时阻塞时间 |
transactional.id |
{应用名}-{hostname}-{pid} |
事务身份标识,多实例必须不同 |
transaction.timeout.ms |
60000 |
事务超时 |
replication.factor |
3 |
Broker 副本数 |
min.insync.replicas |
2 |
最少同步副本 |
unclean.leader.election.enable |
false |
禁止非 ISR 选主 |
| 租约 | 60 秒 |
>= delivery.timeout.ms × 2 |
| 回收扫描间隔 | 30 秒 |
<= 租约 |
11.2 配置项和机制的对应
幂等性:enable.idempotence + PID + Sequence + Partition
↓
顺序保证:max.in.flight.requests.per.connection <= 5
↓
不丢消息:acks=all + min.insync.replicas=2 + retries=MAX
↓
租约设置:delivery.timeout.ms 决定租约时长
↓
事务:transactional.id + Epoch + Coordinator
↓
Exactly-Once:事务 + sendOffsetsToTransaction
11.3 配置项之间的依赖
enable.idempotence=true
→ 隐含 acks=all
→ 隐含 retries > 0
→ 隐含 max.in.flight <= 5
transactional.id=xxx
→ 隐含 enable.idempotence=true
→ 隐含 acks=all
→ 需要 transaction.timeout.ms
acks=all
→ 需要 min.insync.replicas >= 2
→ 需要 replication.factor >= 3
→ 需要 unclean.leader.election.enable=false
十二、总结表
| 问题 | 结论 |
|---|---|
| PID 谁分配 | Broker 分配,幂等生产者每次启动都变,事务生产者通过 transactional.id 跨会话恢复 |
| Epoch 作用 | 隔离旧实例,防止旧实例继续写入;事务靠它实现跨会话幂等 |
| Sequence 谁分配 | Producer 分配,按 (PID, Partition) 维度,从 0 开始单调递增 |
| 幂等靠哪几个值 | (PID, Partition, Sequence),Broker 按 (PID, Partition) 维护 lastSequence |
| 为什么能保证幂等 | 重试时 sequence 不变,Broker 一看 <= lastSequence 就判定重复丢弃 |
| 为什么只保证单分区单会话 | lastSequence 按 (PID, Partition) 维护,跨分区不共享;PID 每次重启都变 |
| 多批同时发送的问题 | 前一批失败时,后一批被 Broker 拒绝(sequence 跳跃),Producer 按顺序重试 |
| 重试单位 | 批次,不是数据条数 |
| 重试维度 | (PID, Partition),同分区按 sequence 顺序,跨分区互不影响 |
| 事务怎么实现精准一次 | PID + Epoch + Sequence + Coordinator + 两阶段提交 + sendOffsetsToTransaction |
| Epoch 在事务里的作用 | 隔离旧实例,跨会话恢复身份,防止僵尸生产者 |
| transactional.id 多实例能一样吗 | 不能。必须每个实例不同,否则互相 fencing |
| 一般用事务吗 | 不用。大多数业务用消费端幂等 |
| 什么时候用事务 | Kafka 内部流转(Streams、A→B、死信搬运) |
| Kafka 比 RabbitMQ 强在哪 | 自动重试 + 幂等生产者,配置即用 |
| RabbitMQ 租约 | 30 秒(confirm 秒级返回) |
| Kafka 租约 | 60 秒(覆盖 delivery.timeout.ms=30s) |
| 租约核心原则 | 租约 >= 单次发送最长阻塞时间 × 2~3 |
十三、一句话
PID 标识 Producer 实例,Sequence 标识批次,Partition 标识分区,三者组合保证单分区单会话幂等。
事务通过 Epoch 隔离旧实例、Coordinator 两阶段提交、
sendOffsetsToTransaction实现跨会话跨分区的 Exactly-Once。多实例部署时
transactional.id必须不同,否则互相 fencing,只有一个能工作。Kafka 的自动重试 + 幂等生产者把生产端不丢不重做成了配置项,RabbitMQ 需要自己写投递器和状态机。
租约必须 >= 客户端最长重试时间:RabbitMQ 30 秒,Kafka 60 秒。