Kafka 可靠消息投递:核心机制总结

目录

  • [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 分配)
  • 四、幂等性靠哪几个值保证,为什么只保证单分区单会话
    • [4.1 三个核心值](#4.1 三个核心值)
    • [4.2 为什么能保证幂等](#4.2 为什么能保证幂等)
    • [4.3 完整例子](#4.3 完整例子)
    • [4.4 为什么只保证"单分区单会话"](#4.4 为什么只保证"单分区单会话")
  • 五、多批同时发送会产生什么问题
    • [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 条消息)→ 失败 → 整个批次重试
  • retriesdelivery.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 秒。

相关推荐
明快de玄米613 小时前
Kafka:基于 Outbox 的可靠消息投递完整方案
kafka
szephyr1 天前
消息队列入门:RabbitMQ 和 Kafka 到底怎么选,什么时候不该用
后端·架构·kafka·消息队列·rabbitmq
A.说学逗唱的Coke3 天前
【大模型专题】别再用 HTTP 直连 Agent 了:用 Kafka 承载 A2A 协议,从 PoC 走到生产
人工智能·kafka·a2a
用户531397318173 天前
[Kafka源码揭秘] 消息写入全链路:网络IO与请求入队
kafka·消息队列
swordbob3 天前
一个 Partition就是一个年级,一个消费者组占用1年级1班,另一个消费者组占用1年级2班,座位号(offset)可以相同,但班级号不同?
kafka
香吧香3 天前
Kafka 三节点集群:只订阅一个 broker 会丢消息吗?能用 VIP订阅 吗?
kafka
雾隐隐o3 天前
Kafka 生产者消息丢失:ACK、重试与可靠发送
kafka
heimeiyingwang3 天前
【中台·技术篇】消息队列中台:Kafka/RocketMQ 统一管理与多租户隔离
kafka·rocketmq·中台
程序员天天困4 天前
Kafka 接入 AI 的三条路线:MCP 提案、会话记忆与实时上下文
大数据·后端·kafka