Kafka深入刨析-Producer

ProducerConfig

ProducerConfig 继承 AbstractConfig,核心是一个 静态 ConfigDef,在 static { } 块中用建造者模式逐一定义所有配置项。构造时把用户的 Properties/Map 传入父类解析,随后postProcessParsedConfig() 做二次校验和修正。CONFIG 定义涵盖 约 40 个配置项(含 SSL/SASL 继承的),下面按 8 个大类逐一拆解:

一、核心必填配置(无默认值,缺失直接抛异常)

以下3个配置无默认值,初始化 ConfigDef 时缺失会直接抛出 ConfigException。存在特殊绕过逻辑:若业务代码直接传入 Serializer 实例到生产者构造函数,appendSerializerToConfig() 会自动将实例Class注入配置Map,绕过该校验。

配置项

类型

默认值

说明

bootstrap.servers

LIST

无默认值

broker 地址列表,示例:localhost:9092,localhost:9093

key.serializer

CLASS

无默认值

key 序列化器全限定类名

value.serializer

CLASS

无默认值

value 序列化器全限定类名

二、攒批+背压配置(直接决定吞吐量)

该组配置为生产者吞吐核心,控制消息攒批规则、内存缓冲、阻塞阈值,直接影响QPS与延迟,Kafka 4.0 优化了默认攒批逻辑,类比TCP Nagle算法。

配置项

类型

默认值

说明

batch.size

INT

16384 (16KB)

单个partition的batch内存上限,非硬固定值,若linger.ms时间先到期,未满batch也会触发发送

linger.ms

LONG

5

batch未满时的最大等待发送时间,Kafka 4.0 从0改为5,牺牲极小延迟换取更高吞吐

buffer.memory

LONG

33554432 (32MB)

Producer全局待发送消息缓冲内存池大小,不包含压缩临时内存、in-flight请求内存,非绝对硬上限

max.block.ms

LONG

60000 (60s)

send()partitionsFor()、事务初始化等方法的最大阻塞超时时间

max.request.size

INT

1048576 (1MB)

单次ProduceRequest最大字节数,限制一个网络请求可承载的batch总量

背压阻塞链路(核心原理)

生产者发送过快触发的完整阻塞、重试、释放链路:

用户 send() 过快 → buffer.memory 内存池吃满 → allocate() 阻塞 → max.block.ms 倒计时
    超时抛 TimeoutException ↓                ↑ 内存释放唤醒等待线程
Sender 异步发送成功 → BufferPool 内存回收 → 继续接收新消息投递

三、可靠性核心配置(幂等、重试、应答、超时)

该组配置决定消息投递可靠性、是否重复、是否丢失,包含幂等生产者、重试机制、应答级别、多级超时约束,存在严格的源码校验与自动降级逻辑。

配置项

类型

默认值

说明

acks

STRING

all

消息应答级别:0=无需确认、1=leader写入成功即返回、all=-1=等待所有ISR副本同步完成

enable.idempotence

BOOLEAN

true

开启幂等生产者,Kafka 3.0+ 默认开启,杜绝单生产者重复消息

retries

INT

MAX_INT(2147483647)

可重试异常的最大重试次数,实际受delivery.timeout.ms全局超时约束

delivery.timeout.ms

INT

120000(120s)

消息从send调用到callback执行完成的全局最大超时时间

request.timeout.ms

INT

30000(30s)

单次网络请求等待broker响应的超时时间

retry.backoff.ms

LONG

100

重试间隔初始值

retry.backoff.max.ms

LONG

1000

重试间隔最大值,指数退避(100→200→400...),附带20%抖动避免重试雪崩

max.in.flight.requests.per.connection

INT

5

单TCP连接最大未响应请求数,幂等场景强制≤5

3.1 acks 源码解析(parseAcks)

统一兼容字符串与数字入参,最终统一转为数字逻辑处理:

return acksString.trim().equalsIgnoreCase("all") ? "-1" : Short.parseShort(acksString.trim()) + "";
3.2 超时强制约束规则

源码强制校验:delivery.timeout.ms >= linger.ms + request.timeout.ms

  • 用户手动配置不满足:直接抛出 ConfigException

  • 默认值组合不合理:自动修正并打印WARN日志

3.3 幂等生产者降级/报错核心逻辑

源码方法:postProcessAndValidateIdempotenceConfigs

// 核心源码片段
if (idempotenceEnabled) {
    if (retries == 0) {
        if (userConfiguredIdempotence) throw ConfigException;    // 用户显式开启幂等,禁止关闭重试
        shouldDisableIdempotence = true;                          // 默认值冲突,静默降级
    }
    if (acks != -1) {
        if (userConfiguredIdempotence) throw ConfigException;
        shouldDisableIdempotence = true;
    }
    if (max.in.flight > 5) {
        throw ConfigException;  // 最强约束,直接报错,不允许降级
    }
}
if (shouldDisableIdempotence) {
    configs.put(ENABLE_IDEMPOTENCE_CONFIG, false);  // 悄悄关闭幂等
}
3.4 幂等冲突场景处理对照表

冲突场景

用户显式开启idempotence

最终处理结果

retries=0

抛出 ConfigException

retries=0

否(默认true)

静默关闭幂等,打印INFO日志

acks=1/0

抛出 ConfigException

acks=1/0

否(默认true)

静默关闭幂等

max.in.flight > 5

是/否

无条件抛异常(最强约束)

核心原理:broker端 ProducerStateEntry 仅保留最近5个batch的序列号,in-flight超过5会覆盖旧批次去重信息,彻底失效幂等,因此禁止任何降级。

默认语义:Kafka3.0+ 默认 idempotence=true + acks=all + retries=MAX_INT,开箱即用可靠幂等生产者。

四、分区策略配置

控制消息分区分配规则,支持默认策略、轮询策略、自适应动态分区策略,自适应策略存在前置生效条件。

配置项

类型

默认值

说明

partitioner.class

CLASS

null

自定义分区器类名,null则使用内置默认分区器

partitioner.adaptive.partitioning.enable

BOOLEAN

true

KIP-794:开启broker延迟动态权重分区策略

partitioner.availability.timeout.ms

LONG

0(禁用)

分区不可用检测阈值,0=完全禁用,超时未响应则标记分区不可用

partitioner.ignore.keys

BOOLEAN

false

是否忽略key哈希,强制走粘性分区策略

4.1 内置分区策略说明

  • 默认策略(无自定义分区器):有key=key哈希固定分区;无key=粘性分区,batch写满后切换分区

  • RoundRobinPartitioner轮询策略:逐条消息轮询分发,存在已知bug KAFKA-9965:新建batch时分区分布不均

4.2 自适应分区生效条件
boolean enableAdaptivePartitioning = partitionerPlugin.get() == null &&
    config.getBoolean(PARTITIONER_ADAPTIVE_PARTITIONING_ENABLE_CONFIG);

结论:存在自定义Partitioner时,强制关闭自适应分区(依赖内置分区器的延迟统计能力)。

补充partitioner.availability.timeout.ms 源码默认值为0,仅自适应分区开启、无自定义分区器时生效。

五、消息压缩配置

Kafka生产者压缩为batch级别压缩:先批量写入消息到内存缓冲区,batch关闭时整体压缩,batch攒得越满,压缩率越高。

配置项

类型

默认值

说明

compression.type

STRING

none

压缩算法:none/gzip/snappy/lz4/zstd

compression.gzip.level

INT

默认级别

仅type=gzip时生效,自定义压缩等级

compression.lz4.level

INT

默认级别

仅type=lz4时生效

compression.zstd.level

INT

默认级别

仅type=zstd时生效

六、事务配置

开启事务后自动启用幂等生产者,支持批量消息原子提交/回滚,包含2PC高级特性与严格参数校验。

配置项

类型

默认值

说明

transactional.id

STRING

null

非空则开启事务模式,自动激活幂等生产者

transaction.timeout.ms

INT

60000(60s)

broker事务最大超时,超时自动abort事务

transaction.two.phase.commit.enable

BOOLEAN

false

KIP-939:开启两阶段提交,事务永久不超时

事务强制校验规则
  • transactional.id 非空但幂等关闭:抛 ConfigException

  • 开启2PC(two.phase.commit=true)且手动配置事务超时:抛异常(2PC模式事务不允许超时)

七、网络TCP/Socket配置

控制生产者TCP连接、重连、缓冲区、空闲回收策略,规避连接断开、端口占用、请求乱序等问题。

配置项

类型

默认值

说明

send.buffer.bytes

INT

131072(128KB)

Socket SO_SNDBUF 发送缓冲区大小

receive.buffer.bytes

INT

32768(32KB)

Socket SO_RCVBUF 接收缓冲区大小

connections.max.idle.ms

LONG

540000(9min)

连接最大空闲时间,比broker10min短1min,避免两端同时断连

socket.connection.setup.timeout.ms

LONG

默认值

TCP连接建立初始超时时间

socket.connection.setup.timeout.max.ms

LONG

默认值

TCP连接超时最大值(指数退避)

reconnect.backoff.ms

LONG

50

断器重连初始间隔

reconnect.backoff.max.ms

LONG

1000

断器重连最大间隔

client.dns.lookup

STRING

USE_ALL_DNS_IPS

DNS解析策略

7.1 核心细节说明

空闲连接9min设计原理:客户端主动提前关闭空闲连接,避免服务端10min断连导致在途请求异常失败。

max.in.flight 双重约束

  1. 幂等开启:必须≤5,否则直接报错

  2. 无幂等、重试开启、in-flight>1:存在消息乱序风险(后批次先成功、前批次重试追上)

八、元数据/指标/日志/安全/拦截器配置

分组

配置项

类型

默认值

说明

元数据

metadata.max.age.ms

LONG

300000(5min)

强制刷新元数据间隔

metadata.max.idle.ms

LONG

300000(5min)

topic空闲后缓存清除阈值,最小5000ms

metadata.recovery.strategy

STRING

NONE

KIP-899元数据恢复策略

metadata.recovery.rebootstrap.trigger.ms

LONG

默认值

触发重新拉取元数据阈值

指标

metrics.sample.window.ms

LONG

30000(30s)

指标采样窗口大小

metrics.num.samples

INT

2

保留的采样样本数

metrics.recording.level

STRING

INFO

指标采集级别

enable.metrics.push

BOOLEAN

true

是否推送指标到broker

日志

client.id

STRING

自动生成

生产者客户端ID,影响日志、JMX、配额统计

拦截器

interceptor.classes

LIST

[]

自定义Producer拦截器集合

安全

security.protocol

STRING

PLAINTEXT

通信安全协议

security.providers

STRING

null

JCA安全提供者

config.providers

LIST

[]

外部配置源(Vault/AWS密钥等,KIP-738)

client.id 自动生成源码逻辑

// 源码核心逻辑
if (userConfiguredClientId) {
    refinedClientId = this.getString(CLIENT_ID_CONFIG);                       // 优先使用用户自定义ID
} else {
    String transactionalId = this.getString(TRANSACTIONAL_ID_CONFIG);
    refinedClientId = "producer-" + (transactionalId != null
        ? transactionalId                                                    // 事务生产者:producer-事务ID
        : PRODUCER_CLIENT_ID_SEQUENCE.getAndIncrement());                    // 普通生产者:JVM全局自增ID
}

补充PRODUCER_CLIENT_ID_SEQUENCE 为JVM全局原子自增变量,影响日志前缀、JMX监控路径、broker端流量配额统计。

send() 入口

KafkaProducer.send () 是生产者发送消息的入口方法。虽然是 "异步发送",但它在某些情况下会阻塞。整个调用链涉及拦截器、元数据等待、序列化、分区计算、RecordAccumulator 追加、以及与 Sender 线程的协作。

两个 send () 入口

关键点:在进入 doSend 之前,ProducerInterceptors.onSend () 会被调用,拦截器可能修改 record 内容(如修改 topic、partition、headers 等),且此方法不会抛出异常。

doSend () 详细步骤(共 16 步)

步骤 1:创建 AppendCallbacks 包装器

AppendCallbacks 是 KafkaProducer 的内部类,实现了 RecordAccumulator.AppendCallbacks 接口(该接口继承 Callback)。职责包括:

  • 包装用户 callback 和拦截器
  • 在 onCompletion 中先调用 interceptors.onAcknowledgement (),再调用用户 callback
  • 保存最终确定的分区号(setPartition 由 RecordAccumulator 调用)
步骤 2:关闭状态检查
步骤 3:事务 Prepared 状态检查(容易被遗漏!)

这是 2PC(两阶段提交)流程中的保护机制。一旦事务进入 prepared 状态,任何 send 都会抛出 IllegalStateException。

步骤 4:等待元数据(第一个阻塞点)

ClusterAndWaitTime 是一个简单的值对象,包含 Cluster cluster 和 long waitedOnMetadataMs。

步骤 5:计算剩余等待时间

重要:nowMs 被累加了 metadata 等待耗时,这样后续 record 的 timestamp 才能反映真实时间。remainingWaitMs 将传给 accumulator.append (),意味着元数据等待和 buffer 分配共享同一个 max.block.ms 配额。

步骤 6:序列化 Key

serializedKey 可能为 null(当 key 为 null 且序列化器返回 null)。

步骤 7:序列化 Value
步骤 8:分区计算

分区策略优先级(partition () 方法):

注意:返回 UNKNOWN_PARTITION 并不意味着 "无分区",而是告诉 RecordAccumulator 使用内置分区器自行决定。

步骤 9:冻结 Headers

此后 headers 不可再修改(防止并发安全问题)。

步骤 10:估算序列化后大小

使用 compression.type () 进行上限估算,这是一个带压缩因子的预估。

步骤 11:大小校验

两个校验条件:

  • size > maxRequestSize → RecordTooLargeException(超过单次请求上限)
  • size > totalMemorySize → RecordTooLargeException(超过 buffer.memory 总量)
步骤 12:确定 Timestamp

如果用户没有显式设置 timestamp,使用 nowMs(注意:此 nowMs 已经累加了 metadata 等待时间)。

步骤 13:追加到 RecordAccumulator(第二个阻塞点)

返回值 RecordAppendResult 包含四个字段:

  • FutureRecordMetadata future:返回给用户的 Future
  • boolean batchIsFull:当前 batch 是否已满
  • boolean newBatchCreated:是否创建了新 batch
  • int appendedBytes:追加的字节数(用于 BuiltInPartitioner 统计)
步骤 14:断言分区已确定

经过 accumulator.append () 之后,分区必须已经被确定。这是由 RecordAccumulator 的 while-true 循环和 setPartition 调用保证的。

步骤 15:事务分区注册

必须在 append 成功之后才能注册分区到事务。因为在此之前分区号可能是 UNKNOWN_PARTITION。Sender 线程会拒绝从尚未注册到事务的分区中取出 batch 进行发送。

步骤 16:唤醒 Sender 并返回 Future

唤醒 Sender 的条件是 batch 已满或新 batch 被创建。如果只是追加到一个未满的已有 batch,则不需要唤醒。

waitOnMetadata () 详解(第一个阻塞点)

4.1 快速路径

两个快速返回条件:

  • partitionsCount != null — metadata 中已有该 topic 的分区信息
  • partition == null || partition < partitionsCount — 用户未指定分区,或指定的分区在有效范围内
4.2 阻塞循环

关键细节:

  1. metadata.add () 被调用了两次:循环外的第一次用 nowMs,循环内的第二次用 nowMs + elapsed 来重置 topic 过期时间。
  2. metadata.awaitUpdate (version, remainingWaitMs) 是真正的阻塞点,底层使用 CountDownLatch 或条件变量等待。
  3. metadata.maybeThrowExceptionForTopic (topic):检查 topic 是否有 fatal 异常(如 AuthorizationException、AuthenticationException),有则直接抛出。
  4. TimeoutException 会尽可能附加 metadata.getError (topic).exception () 作为 cause,帮助诊断。
4.3 阻塞示意图

RecordAccumulator.append () 详解(第二个阻塞点)

5.1 初始化
  • topicInfoMap:ConcurrentHashMap<String, TopicInfo>,每个 topic 一个 TopicInfo(包含 BuiltInPartitioner 和 Map<Integer, Deque<ProducerBatch>>)
  • appendsInProgress:AtomicInteger,用于并发保护
5.2 主循环(while‑true 重试)

整个逻辑在一个 while (true) 循环中,因为分区可能被并发切换,需要重试。

5.3 确定有效分区
  • UNKNOWN_PARTITION(‑1):使用内置 sticky 分区器选择分区
  • 其他值:直接使用用户指定分区,partitionInfo 为 null
5.4 Phase 1:尝试追加到已有 batch(加锁)

tryAppend () 逻辑:

5.5 Phase 2:分配 Buffer(可能阻塞)

只有当 Phase 1 的 tryAppend 返回 null(没有可用的 batch)时才会进入 Phase 2。

BufferPool.allocate () 内部逻辑:

  1. 加锁
  2. 尝试从 free list 获取相同大小的 buffer(poolable 路径)
  3. 如果 nonPooledAvailableMemory + freeListSize >= size:立即满足,无需阻塞
  4. 否则:创建 Condition moreMemory,加入 waiters 队列
  5. moreMemory.await (remainingTimeToBlockNs) — 阻塞等待
  6. 被唤醒后循环检查:从 free list 取 buffer,或从 nonPooledAvailableMemory 中积累
  7. 超时 → BufferExhaustedException
  8. 成功 → safeAllocateByteBuffer () 实际分配
5.6 Phase 3:创建新 Batch(再次加锁)
5.7 appendNewBatch () 的双重检查机制
5.8 Finally 块:Buffer 生命周期管理

Buffer 生命周期总结:

partitionChanged () 的两层逻辑

这个方法在 append 流程中被调用了三次(每次加锁之前各一次),确保分区切换的正确性和最终一致性。

BuiltInPartitioner 详解

7.1 类定位

BuiltInPartitioner 不实现 Partitioner 接口,而是作为 RecordAccumulator 的内部工具类使用。每个 topic 有一个独立的 BuiltInPartitioner 实例。

7.2 核心字段
7.3 peekCurrentPartitionInfo () — 获取当前 sticky 分区

使用 AtomicReference + compareAndSet 实现无锁的首次初始化。

7.4 nextPartition () — 选择下一个分区

自适应算法核心思想:将队列大小取反(队列越小 → 权重越大),构建累计频率表,通过二分查找实现加权随机选择。

7.5 updatePartitionInfo () — 切换条件

切换条件解读:

表格

场景enableSwitch切换时机
所有 batch 都满了trueproducedBytes >= stickyBatchSize 时切换
有未满的 batch(linger.ms 较大)false先不切换,直到 producedBytes >= stickyBatchSize * 2 强制切换

*2 上限的设计意图:防止因 batch 始终不满而导致永远不切换的病态情况。

7.6 producedBytes 使用 AtomicInteger

多线程同时 append 到同一分区时,producedBytes 的累加是原子的。

八、异常处理体系

异常处理对比

表格

异常类型是否通知 callback是否返回 Future是否影响事务是否记录 metrics
ApiException是(nullMetadata)FutureFailuremaybeTransitionToErrorStateerrors.record()
InterruptedException否(抛 InterruptException)errors.record()
KafkaException否(直接抛出)errors.record()
其他 Exception否(直接抛出)仅 onSendError

FutureFailure

FutureFailure 的 get () 直接抛出异常,isDone () 始终返回 true。

九、两个阻塞点总结

两个阻塞点共享同一个 max.block.ms 配额,总阻塞时间不超过该配置值。

拦截器链

责任链就是让多个"关注点"(监控、转换、校验、日志……)在不修改业务代码的情况下,像水管一样串联起来,每条消息从水管一端流入,依次经过每个"过滤器"处理后流出。

三件事,三个时机

整条链只做三件事,发生在消息生命周期的三个关键节点

① 发送前 → 可以"改"
用户 send(record)
  ↓
[拦截器A] → [拦截器B] → [拦截器C]  ← 每个都可以修改 record
  ↓
序列化 → 分区 → 发往 Broker

典型用途:

  • 给消息自动加 traceId / 时间戳
  • 敏感字段脱敏
  • 按规则自动改写 topic 名称(如 topic 路由)
② 发送后 → 可以"看"
Broker 确认消息
  ↓
[拦截器A] → [拦截器B] → [拦截器C]  ← 每个都能收到通知
  ↓
用户 callback

典型用途:

  • 记录发送成功率 / 延迟到监控系统
  • 失败时发告警
  • 审计日志
③ 发送失败 → 可以"感知"
序列化失败 / 内存不足 / 其他异常
  ↓
[拦截器A] → [拦截器B] → [拦截器C]  ← 每个都能感知到失败

任何一个拦截器出问题,不影响其他拦截器,更不影响消息正常发送。 一个监控插件崩了,不会拖垮整个生产链路。

分区策略

partition() —— 分区策略决策

int partition = partition(record, serializedKey, serializedValue, cluster);

partition() 是实现分区策略的四级决策树

private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
    if (record.partition() != null)
        return record.partition();

    if (partitionerPlugin.get() != null) {
        int customPartition = partitionerPlugin.get().partition(
            record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
        if (customPartition < 0) {
            throw new IllegalArgumentException(String.format(
                "The partitioner generated an invalid partition number: %d. Partition number should always be non-negative.", customPartition));
        }
        return customPartition;
    }

    if (serializedKey != null && !partitionerIgnoreKeys) {
        // hash the keyBytes to choose a partition
        return BuiltInPartitioner.partitionForKey(serializedKey, cluster.partitionsForTopic(record.topic()).size());
    } else {
        return RecordMetadata.UNKNOWN_PARTITION;
    }
}
四级决策树
优先级条件行为结果
1record.partition() != null用户显式指定分区直接返回指定分区
2自定义 Partitioner 已配置委托自定义分区器自定义分区数
3有 key && !partitionerIgnoreKeysmurmur2(key) % numPartitions 取模hash 分区
4以上全不满足返回 UNKNOWN_PARTITION交给 BuiltInPartitioner 做 sticky

关键洞见:第 4 级返回 UNKNOWN_PARTITION 是设计精髓——它告诉后续的 accumulator.append()"我没有分区偏好,你用内置的 sticky 分区分区逻辑来帮我选"。这实现了分区策略的两阶段设计:partition() 负责决策,BuiltInPartitioner 负责无 key 消息的优化分配。

深度剖析 BuiltInPartitioner —— 自适应粘性分区(KIP-794)

BuiltInPartitioner 是默认的无 key 消息分配器,每个 topic 一个实例。它是独立工具类,不实现 Partitioner 接口,直接内嵌在 RecordAccumulator 中使用:

public BuiltInPartitioner(LogContext logContext, String topic, int stickyBatchSize) {
    this.topic = topic;
    this.stickyBatchSize = stickyBatchSize;  // 默认 = batch.size (16KB)
}

核心字段:

  • stickyPartitionInfoAtomicReference<StickyPartitionInfo>,当前"粘住"的分区及其已生产字节数
  • partitionLoadStatsvolatile PartitionLoadStats,累计频率表(CFT),由 Sender 线程定期更新

BuiltInPartitioner 的核心思想:无 key 消息不应该"随机散弹"到各个分区造成碎片 batch,而是"粘"在一个分区上攒满一个 batch 再切走,同时根据各分区队列积压情况自适应选择下一个目标

RecordAccumulator.append()

这是 Kafka Producer 最核心的方法之一——它位于用户线程与 Sender 线程的交汇点,负责将序列化后的记录追加到内存缓冲区,同时内置了粘性分区器的完整集成。

数据结构全景

在深入方法之前,先理解 Accumulator 的数据组织:

RecordAccumulator
 └── topicInfoMap: ConcurrentMap<String, TopicInfo>    // 每 topic 一个
      └── TopicInfo
           ├── batches: ConcurrentMap<Integer, Deque<ProducerBatch>>  // 每分区一个 Deque
           └── builtInPartitioner: BuiltInPartitioner              // 每 topic 一个
public class RecordAccumulator {
    private final int batchSize;                      // 默认 16KB
    private final Compression compression;            // 压缩类型
    private final int lingerMs;                       // 延迟发送
    private final ExponentialBackoff retryBackoff;    // 重试退避
    private final int deliveryTimeoutMs;              // 投递超时
    private final BufferPool free;                    // 内存池
    private final ConcurrentMap<String, TopicInfo> topicInfoMap;  // topic → partitions
    private final IncompleteBatches incomplete;       // 未完成 batch 追踪
    private final Set<TopicPartition> muted;          // Sender 线程使用的 mute 集合
    private long nextBatchExpiryTimeMs = Long.MAX_VALUE;
while 循环 + 分区选择
    try {
        // Loop to retry in case we encounter partitioner's race conditions.
        while (true) {
            final BuiltInPartitioner.StickyPartitionInfo partitionInfo;
            final int effectivePartition;
            if (partition == RecordMetadata.UNKNOWN_PARTITION) {
                partitionInfo = topicInfo.builtInPartitioner.peekCurrentPartitionInfo(cluster);
                effectivePartition = partitionInfo.partition();
            } else {
                partitionInfo = null;
                effectivePartition = partition;
            }

            setPartition(callbacks, effectivePartition);

            // check if we have an in-progress batch
            Deque<ProducerBatch> dq = topicInfo.batches.computeIfAbsent(effectivePartition, k -> new ArrayDeque<>());
            synchronized (dq) {
                if (partitionChanged(topic, topicInfo, partitionInfo, dq, nowMs, cluster))
                    continue;

                RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
                if (appendResult != null) {
                    boolean enableSwitch = allBatchesFull(dq);
                    topicInfo.builtInPartitioner.updatePartitionInfo(partitionInfo, appendResult.appendedBytes, cluster, enableSwitch);
                    return appendResult;
                }
            }

while(true) 循环的必要性:当多线程并发 append 到同一 topic 时,BuiltInPartitioner 可能在 peek 和加锁之间被另一个线程切换了 sticky 分区。partitionChanged 检测到 race 后 continue 回到循环起点,重新 peek 选择分区。

effectivePartition 的确定

输入 partition处理结果
UNKNOWN_PARTITIONpeekCurrentPartitionInfo() 选一个 sticky 分区sticky 分区
具体分区号直接使用输入的分区

setPartition(callbacks, effectivePartition):将实际的分区号回写到 AppendCallbacks 中,后续 callback 和 interceptor 需要知道实际发到哪个分区。

tryAppend() —— 尝试追加到已有 batch
private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers,
                                     Callback callback, Deque<ProducerBatch> deque, long nowMs) {
    if (closed)
        throw new KafkaException("Producer closed while send in progress");
    ProducerBatch last = deque.peekLast();
    if (last != null) {
        int initialBytes = last.estimatedSizeInBytes();
        FutureRecordMetadata future = last.tryAppend(timestamp, key, value, headers, callback, nowMs);
        if (future == null) {
            last.closeForRecordAppends();
        } else {
            int appendedBytes = last.estimatedSizeInBytes() - initialBytes;
            return new RecordAppendResult(future, deque.size() > 1 || last.isFull(), false, appendedBytes);
        }
    }
    return null;
}

逻辑

  1. 取 deque 的最后一个 batch(peekLast)——这是当前正在填充的活跃 batch
  2. 调用 ProducerBatch.tryAppend() 尝试写入
  3. 成功 → 计算 appendedBytes(差量),返回结果(newBatchCreated = false
  4. 失败(return null,说明 batch 满了)→ 调用 closeForRecordAppends() 关闭该 batch 的追加通道,释放压缩中间缓冲区
  5. 返回 null → 上层需要创建新 batch

batchIsFull 判断deque.size() > 1 || last.isFull()——注意 > 1 而非 > 0,因为队列中只有当前正在写的 batch,需要多一个满 batch 才算有"满 batch 待发送"。

Buffer 分配 —— 阻塞点
            if (buffer == null) {
                int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(
                        RecordBatch.CURRENT_MAGIC_VALUE, compression.type(), key, value, headers));
                // This call may block if we exhausted buffer space.
                buffer = free.allocate(size, maxTimeToBlock);
                // Update the current time in case the buffer allocation blocked above.
                nowMs = time.milliseconds();
            }

关键点

  • 初始大小:取 max(batchSize, record估计值)。如果单条 record 就超过 batchSize(比如 64KB 的大消息),buffer 按 record 实际需求分配
  • free.allocate(size, maxTimeToBlock):从 BufferPool 申请内存,可能阻塞
    • maxTimeToBlock 是 max.block.ms(默认 60s)扣掉 metadata 等待时间后的剩余额度
    • 如果 buffer pool 的可用内存不足,线程会在此等待,直到有内存被 deallocate 释放或超时
  • 时间更新:阻塞后 nowMs = time.milliseconds() 重新获取当前时间——因为可能已经阻塞了很久
  • buffer 只分配一次if (buffer == null) 保证一次 while 循环中只分配一次 buffer;如果 race retry 了,复用之前分配的 buffer
appendNewBatch() —— 创建新 batch
private RecordAppendResult appendNewBatch(String topic,
                                          int partition,
                                          Deque<ProducerBatch> dq,
                                          long timestamp,
                                          byte[] key,
                                          byte[] value,
                                          Header[] headers,
                                          AppendCallbacks callbacks,
                                          ByteBuffer buffer,
                                          long nowMs) {
    assert partition != RecordMetadata.UNKNOWN_PARTITION;

    RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
    if (appendResult != null) {
        // Somebody else found us a batch, return the one we waited for! Hopefully this doesn't happen often...
        return appendResult;
    }

    MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer);
    ProducerBatch batch = new ProducerBatch(new TopicPartition(topic, partition), recordsBuilder, nowMs);
    FutureRecordMetadata future = Objects.requireNonNull(batch.tryAppend(timestamp, key, value, headers, callbacks, nowMs));

    dq.addLast(batch);
    incomplete.add(batch);

    return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true, batch.estimatedSizeInBytes());
}

双重防御:进入 appendNewBatch 后再次 tryAppend 一次——因为在分配 buffer 时(阻塞期间),其他线程可能已经为该分区创建了新 batch 并在其中追加了数据。如果 tryAppend 成功了,注释写得很好:"Hopefully this doesn't happen often..."

正常创建路径

  1. MemoryRecords.builder(buffer, ...) 用分配的 buffer 创建 MemoryRecordsBuilder
  2. new ProducerBatch(...) 构造 batch
  3. batch.tryAppend(...) 写入第一条 record(这里 requireNonNull 因为新 batch 一定能写入)
  4. dq.addLast(batch) 加入队列末尾
  5. incomplete.add(batch):注册到未完成 batch 集合,Sender 后续会从中取出并发送
  6. 返回结果:newBatchCreated = true
finally 清理
  • free.deallocate(buffer):如果 buffer 不为 null 且未被 batch 使用(比如 race retry 后不再需要),归还给 BufferPool。如果 buffer 已经被 appendNewBatch 中的 ProducerBatch 持有了,buffer 变量在 while 循环中的 if (appendResult.newBatchCreated) buffer = null; 已被置 null,deallocate(null) 是空操作
  • appendsInProgress.decrementAndGet():递减活跃 append 计数

三条可能的执行路径

路径tryAppend 结果buffer 分配返回
快速路径(最佳)成功追加到已有 batch不分配newBatchCreated=false
普通路径batch 已满,关闭后分配新 buffer分配newBatchCreated=true
慢速路径batch 满 + buffer pool 耗尽分配时阻塞等待newBatchCreated=true

快速路径是无锁竞争、无内存分配的最优路径——只持一次 deque 锁,tryAppend 成功后就返回了。

与 Sender 线程的协作

RecordAccumulator.append() 是生产者,Sender 线程的 drain() 是消费者,通过以下机制协同:

  1. Deque 作为有界缓冲区:用户线程 addLast(batch),Sender 线程 pollFirst(batch)
  2. incomplete 集合:记录所有未完成的 batch,Sender 发送完成后 deallocate 释放
  3. muted 集合:当分区没有 leader 或正在重试时,Sender 会 mute 该分区,停止 drain
  4. nextBatchExpiryTimeMs:Sender 线程用来判断是否有 batch 超时(delivery.timeout.ms

一句话总结

RecordAccumulator.append() 实现了一个多生产者-单消费者的有界队列:用户线程通过三步锁协议(peek→lock→validate)在无锁 peek 和持锁追加之间取得平衡,在 buffer pool 耗尽时可以阻塞等待;同时深度集成了 BuiltInPartitioner 的粘性分区和自适应负载均衡逻辑,使得无 key 消息的分区分配与 batch 构建在同一层次完成。

BufferPool

BufferPool 是 Kafka Producer 内存管理的核心组件,位于 RecordAccumulator 之下,管理着 buffer.memory(默认 32MB)的堆外/堆内内存。它的设计非常精巧:公平调度 + 对象池复用 + 精确超时控制

public class BufferPool {
    private final long totalMemory;              // 总内存上限 = buffer.memory (默认 32MB)
    private final int poolableSize;              // 可池化的 buffer 大小 = batch.size (默认 16KB)
    private final ReentrantLock lock;            // 全局锁
    private final Deque<ByteBuffer> free;        // 空闲 buffer 对象池
    private final Deque<Condition> waiters;      // 等待队列 (FIFO,公平)
    private long nonPooledAvailableMemory;       // 非池化的可用内存(字节粒度)
    private boolean closed;                      // 是否已关闭

关键概念:内存分为两种形态:

形态存储位置粒度含义
池化内存free 双端队列poolableSize(如 16KB)的整数倍已分配但空闲的 ByteBuffer,可被直接复用
非池化内存nonPooledAvailableMemory 计数器字节尚未分配或归还的非标准大小内存额度

availableMemory() = nonPooledAvailableMemory + free.size() * poolableSize,两者之和才是真正的可用内存总额。

初始状态:nonPooledAvailableMemory = totalMemoryfree 为空——所有内存都是"未分配额度"。

allocate() —— 分配内存

这是 BufferPool 的核心方法,RecordAccumulator.append() 中调用 free.allocate(size, maxTimeToBlock) 即进入此方法。

三条路径
allocate(size, maxTimeToBlockMs)
│
├─ size > totalMemory? → IllegalArgumentException ❌
│
├─ [快速路径] size == poolableSize && free 非空?
│   └─ return free.pollFirst()  ← 直接复用对象池中的 buffer,零分配开销
│
├─ [直接满足] nonPooledAvailableMemory + freeSize*poolableSize >= size?
│   ├─ freeUp(size)         ← 把 free 中的 buffer 释放,换回 nonPooledAvailableMemory 额度
│   ├─ nonPooledAvailableMemory -= size
│   └─ return safeAllocateByteBuffer(size)  ← 用额度新分配 ByteBuffer
│
└─ [阻塞等待] 内存不足,进入等待循环
    ├─ 创建 Condition 加入 waiters 队列
    └─ while (accumulated < size) 循环等待

快速路径:对象池复用

if (size == poolableSize && !this.free.isEmpty())
    return this.free.pollFirst();

这是最高频的路径。当请求的 buffer 大小恰好等于 batch.size(16KB)时,直接从 free 队列头部取出一个已分配好的 ByteBuffer。这就是为什么 RecordAccumulator.append() 调用后无需再 ByteBuffer.allocate()——buffer 早在之前的某次发送完成时已被回收进对象池。

直接满足路径:freeUp() + 扣减额度

当 nonPooledAvailableMemory 不够扣减时,从 free 队列尾部取出 buffer(pollLast),将其容量加到 nonPooledAvailableMemory 中。注意:free 弹出的 buffer 只是被"销毁"——其占用的内存额度转换为了 nonPooledAvailableMemory 中的数值。

为什么从尾部取free 是双端队列,allocate 从头部取(pollFirst),freeUp 从尾部回收(pollLast),近似 LRU 语义——保留最近归还的 buffer,销毁较久未用的。

阻塞等待路径:精准公平调度

等待唤醒循环的渐进式内存收集

这是一个累加式的收集过程——线程不需要一次性拿到全部 size 字节,而是每被唤醒一次就收集一点:

  1. 检查 free 队列是否有可复用的 buffer(仅第一次迭代且 size == poolableSize)
  2. 调用 freeUp(size - accumulated) 释放池中 buffer
  3. 从 nonPooledAvailableMemory 中尽力扣减 min(缺口, 可用额度)
  4. accumulated 累加扣减量
  5. 如果 accumulated < size,继续 await() 等待下次唤醒
公平性保证

java

Copy

Insert

New File

Save

Apply

this.waiters.addLast(moreMemory);   // 入队尾
this.waiters.peekFirst().signal();  // 唤醒队首

waiters 是 FIFO 队列,等待最久的线程最先被唤醒——绝对公平

与之对比,Java 内置的 synchronized + wait/notify 的唤醒顺序是不确定的,这正是为什么 Kafka 选择 ReentrantLock + Condition 而非 synchronized


deallocate() —— 归还内存

BufferPool.javaL260-L280

public void deallocate(ByteBuffer buffer, int size) {
    lock.lock();
    try {
        if (size == this.poolableSize && size == buffer.capacity()) {
            buffer.clear();
            this.free.add(buffer);
        } else {
            this.nonPooledAvailableMemory += size;
        }
        Condition moreMem = this.waiters.peekFirst();
        if (moreMem != null)
            moreMem.signal();
    } finally {
        lock.unlock();
    }
}

两种归还策略

条件行为含义
size == poolableSize && size == buffer.capacity()buffer.clear() 后加入 free 队列复用:下次 allocate 可直接取出
否则(大消息、或压缩后大小变化)nonPooledAvailableMemory += size释放额度:buffer 被 GC

为什么需要两个相等条件size 参数可能小于 buffer.capacity()——当 batch 经历原地压缩后,实际使用的内存减少了,recordBatch.setEncoder 会重新调整 buffer。只有大小完全匹配 poolableSize 的 buffer 才值得缓存。


safeAllocateByteBuffer() —— OOM 保护

BufferPool.java

private ByteBuffer safeAllocateByteBuffer(int size) {
    boolean error = true;
    try {
        ByteBuffer buffer = allocateByteBuffer(size);
        error = false;
        return buffer;
    } finally {
        if (error) {
            this.lock.lock();
            try {
                this.nonPooledAvailableMemory += size;
                if (!this.waiters.isEmpty())
                    this.waiters.peekFirst().signal();
            } finally {
                this.lock.unlock();
            }
        }
    }
}

如果 ByteBuffer.allocate(size) 抛出 OutOfMemoryError,finally 块会将被扣减的 nonPooledAvailableMemory 归还,并唤醒下一个等待者。这是一个典型的 OOM 安全网——防止因一次分配失败导致内存额度永远丢失,阻塞所有等待线程。


close() —— 优雅关闭

BufferPool.java

public void close() {
    this.lock.lock();
    this.closed = true;
    try {
        for (Condition waiter : this.waiters)
            waiter.signal();
    } finally {
        this.lock.unlock();
    }
}

设置 closed = true 并唤醒所有 waiters。被唤醒的线程在 while 循环中检查到 closed == true 后会抛出 KafkaException("Producer closed while allocating memory")


allocate() 的 finally 块:级联唤醒

} finally {
    try {
        if (!(this.nonPooledAvailableMemory == 0 && this.free.isEmpty()) && !this.waiters.isEmpty())
            this.waiters.peekFirst().signal();
    } finally {
        lock.unlock();
    }
}

每次 allocate() 成功返回后,检查是否有剩余内存 + 有等待者,如果有则唤醒队首的等待者。这是级联唤醒的源头——一次 deallocate() 可能只唤醒一个线程,但那个线程的 allocate() 成功后可能只消耗了部分内存,剩余内存再唤醒下一个,以此类推。


生命周期全景

Sender 线程                                用户线程 1                用户线程 2
    │                                         │                        │
    │                                         │ allocate(16KB, 60s)    │
    │                                         ├─ free 非空?           │
    │                                         │  └─ pollFirst() ✅     │
    │                                         │                        │ allocate(16KB, 60s)
    │                                         │                        ├─ 内存不足
    │ 发送完成                                  │                        ├─ waiters.addLast(cond2)
    │ deallocate(buf, 16KB)                    │                        ├─ cond2.await() ⏳
    │ ├─ buffer.clear()                        │                        │
    │ ├─ free.add(buf)   ← 回池                │                        │
    │ └─ signal(cond2) ───────────────────────────────────────►  被唤醒
    │                                         │                        ├─ free.pollFirst() ✅
    │                                         │                        ├─ signal 级联唤醒?
    │                                         │                        │   (waiters 为空, 不唤醒)
    │                                         │                        └─ lock.unlock()
    ▼                                         ▼                        ▼

总结

BufferPool 的设计精妙之处有三:

  1. 对象池 + 额度池双层模型free 队列缓存 poolableSize 大小的 ByteBuffer 避免频繁分配/GC,nonPooledAvailableMemory 计数器管理非标准大小的内存额度。两者通过 freeUp() 互相转换。

  2. 公平的 FIFO 等待队列:使用 ReentrantLock + 多个 Condition 实现精确的线程级等待/唤醒,waiters 为 FIFO 队列保证先到先得,杜绝饥饿。

  3. 渐进式内存收集:等待线程不要求一次性满足全部需求,每被唤醒一次就收集一点可用内存("蚂蚁搬家"),配合级联唤醒机制和 OOM 安全网,在有限的 32MB 预算中实现了高吞吐、低延迟的内存复用。

幂等与事务概述

问题的起源

没有幂等和事务时,Kafka 提供的是 at-least-once 语义:

Producer 发送 batch → Broker 写入成功 → ACK 网络丢失 → Producer 重试 → 重复消息!

同一个 batch 被写入了两次。对于幂等和事务,需要通过三个关键标识符来消除重复。

三大核心标识符

标识符类型谁分配生命周期作用
Producer ID (PID)longBrokerProducer 重启重新分配唯一标识一个 Producer 实例
Producer EpochshortBroker,每次 InitProducerId 递增随 PID 一起分配隔离不同"代"的 producer,旧 epoch 的请求被拒绝
Sequence NumberintProducer 本地,每分区每 batch 递增PID+Epoch 不变期间Broker 检测重复和乱序

数据结构 ProducerIdAndEpoch

ProducerIdAndEpoch.java

public class ProducerIdAndEpoch {
    public static final ProducerIdAndEpoch NONE = new ProducerIdAndEpoch(RecordBatch.NO_PRODUCER_ID, RecordBatch.NO_PRODUCER_EPOCH);

    public final long producerId;
    public final short epoch;

    public ProducerIdAndEpoch(long producerId, short epoch) {
        this.producerId = producerId;
        this.epoch = epoch;
    }

    public boolean isValid() {
        return RecordBatch.NO_PRODUCER_ID < producerId;
    }

初始状态 NONEproducerId = -1, epoch = -1,表示还没从 Broker 获取过 PID。

幂等生产者(Idempotent Producer)

enable.idempotence 从 Kafka 3.0 开始默认为 true。如果 transactional.id 为空,则创建幂等生产者(不创建事务)。

启用幂等时强制要求:

  • acks = all(确保所有 ISR 副本确认)
  • max.in.flight.requests.per.connection ≤ 5
  • retries > 0

工作原理

每个发往分区的 batch 都携带 (PID, Epoch, SequenceNumber)

code

Copy

Insert

New File

Save

Apply

batch1: (pid=1001, epoch=0, seq=0) → Broker 写入,记录 lastSeq=0
batch2: (pid=1001, epoch=0, seq=1) → Broker 写入,记录 lastSeq=1
batch3: (pid=1001, epoch=0, seq=2) → Broker 写入但 ACK 丢失
batch3 重试: (pid=1001, epoch=0, seq=2) → Broker 发现 seq ≤ lastSeq → 忽略! ✅

Broker 端的幂等判断规则

  • seq > lastSeq + 1 → OutOfOrderSequenceException(有消息丢失,不可恢复)
  • seq ≤ lastSeq → 重复消息,直接忽略,返回成功
  • seq == lastSeq + 1 → 正常消息,写入并更新 lastSeq
3.3 幂等的局限性
  • 单分区内保证:跨分区没有顺序保证
  • 单 Producer 会话内保证:Producer 重启后 PID 变化,无法冼重之前的消息
  • 不能原子化跨分区写入:这就是需要事务的地方

四、事务生产者(Transactional Producer)

事务是幂等的超集——在幂等的基础上增加了跨分区、跨 Topic、跨 consume-produce 的原子性保证

4.1 核心配置项
配置默认值含义
transactional.idnull事务 ID,是跨 session 幂等的标识
transaction.timeout.ms60000Broker 侧事务超时
transaction.two.phase.commit.enablefalse是否开启 2PC 模式
4.2 状态机

TransactionManager.javaL151-L189

private enum State {
    UNINITIALIZED,
    INITIALIZING,
    READY,
    IN_TRANSACTION,
    PREPARED_TRANSACTION,    // 2PC 专用
    COMMITTING_TRANSACTION,
    ABORTING_TRANSACTION,
    ABORTABLE_ERROR,
    FATAL_ERROR;
状态转换表
                         ┌──────────────┐
                         │ UNINITIALIZED │
                         └──────┬───────┘
                    initTransactions()
                                │
                         ┌──────▼───────┐
                         │ INITIALIZING │──── InitProducerId 请求 ──── Broker
                         └──────┬───────┘
                                │ Broker 返回 (PID, Epoch)
                         ┌──────▼───────┐
                    ┌────│    READY     │◄──────── 事务完成后 reset ──┐
                    │    └──────┬───────┘                            │
                    │   beginTransaction()                           │
                    │    ┌──────▼───────┐                            │
                    │    │ IN_TRANSACTION│─── prepareTransaction() ──┤(2PC)
                    │    └──┬───────┬───┘       ┌─────────────────┐  │
                    │       │       │           │PREPARED_TRANSACTION│
                    │  commit│  abort│           └───┬─────────┬───┘  │
                    │       │       │          commit│    abort│      │
                    │  ┌────▼──┐ ┌──▼──────┐   ┌────▼──┐ ┌──▼──────┐│
                    │  │COMMIT │ │ ABORT   │   │COMMIT │ │ ABORT   ││
                    │  │ _ING  │ │  _ING   │   │ _ING  │ │  _ING   ││
                    │  └───┬───┘ └───┬─────┘   └───┬───┘ └───┬─────┘│
                    │      │         │             │         │       │
                    │  EndTxn成功   EndTxn成功     │         │       │
                    └──────┴─────────┴─────────────┴─────────┴───────┘
                            返回 READY

异常路径

IN_TRANSACTION ── 某些可重试异常 ──► ABORTABLE_ERROR ── beginAbort() ──► ABORTING_TRANSACTION ──► READY

任意状态 ── 不可恢复异常 ──► FATAL_ERROR ──► 只能 close producer,不能再做任何操作
4.3 生命周期完整流程
步骤 1:initTransactions()

TransactionManager.java

synchronized TransactionalRequestResult initializeTransactions(
    ProducerIdAndEpoch producerIdAndEpoch, boolean keepPreparedTxn) {
    maybeFailWithError();

    boolean isEpochBump = producerIdAndEpoch != ProducerIdAndEpoch.NONE;
    return handleCachedTransactionRequestResult(() -> {
        if (!isEpochBump) {
            transitionTo(State.INITIALIZING);
            log.info("Invoking InitProducerId for the first time in order to acquire a producer ID");
        } else {
            log.info("Invoking InitProducerId with current producer ID and epoch {} in order to bump the epoch", producerIdAndEpoch);
        }
        InitProducerIdRequestData requestData = new InitProducerIdRequestData()
                .setTransactionalId(transactionalId)
                .setTransactionTimeoutMs(transactionTimeoutMs)
                .setProducerId(producerIdAndEpoch.producerId)
                .setProducerEpoch(producerIdAndEpoch.epoch);
        ...

关键步骤

  1. transitionTo(INITIALIZING)
  2. 发送 InitProducerIdRequest 到 Broker
  3. → Broker 查找 transactionalId
    • 第一次 → 分配新的 (PID=1001, Epoch=0)
    • 之前有未完成事务 → 自动 abort 旧事务(保证 clean slate)
    • 之前的事务已 commit/abort → 递增 Epoch:(PID=1001, Epoch=1)
  4. 接收到响应后 transitionTo(READY)

为什么 transactionalId 能跨 session 保序?同一个 transactionalId 永远映射到同一个 PID,但 epoch 每次 initTransactions 递增。这样旧的 epoch 请求会被 Broker 的 ProducerFencedException 拒绝。

步骤 2:beginTransaction()

TransactionManager.javaL332-L337

public synchronized void beginTransaction() {
    ensureTransactional();
    throwIfPendingState("beginTransaction");
    maybeFailWithError();
    transitionTo(State.IN_TRANSACTION);
}

极其轻量——只是状态切换,不产生任何网络请求。事务是懒启动的,第一个分区被 maybeAddPartition 时才真正发起 AddPartitionsToTxnRequest

步骤 3:发送消息 & 分区注册

在 doSend() 中,每条消息 append 到 accumulator 后:

KafkaProducer.java

if (transactionManager != null) {
    transactionManager.maybeAddPartition(appendCallbacks.topicPartition());
}

maybeAddPartition() 将分区注册到事务中。注意此步骤在 accumulator.append 之后——因为此时才确定了最终分区。

Sender 线程在 drain 时检查 isSendToPartitionAllowed(),未注册到事务的分区 batch 不会被发送。

步骤 4:commitTransaction()

KafkaProducer.java

public void commitTransaction() throws ProducerFencedException {
    throwIfNoTransactionManager();
    throwIfProducerClosed();
    TransactionalRequestResult result = transactionManager.beginCommit();
    sender.wakeup();
    result.await(maxBlockTimeMs, TimeUnit.MILLISECONDS);
}

beginCommit() 触发:

TransactionManager.java

private TransactionalRequestResult beginCompletingTransaction(TransactionResult transactionResult) {
    if (!newPartitionsInTransaction.isEmpty())
        enqueueRequest(addPartitionsToTransactionHandler());

    EndTxnRequest.Builder builder = new EndTxnRequest.Builder(
        new EndTxnRequestData()
            .setTransactionalId(transactionalId)
            .setProducerId(producerIdAndEpoch.producerId)
            .setProducerEpoch(producerIdAndEpoch.epoch)
            .setCommitted(transactionResult.id),
        isTransactionV2Enabled
    );
    ...
  1. 如果有尚未注册的分区 → 先发送 AddPartitionsToTxnRequest
  2. 然后发送 EndTxnRequest(committed=true) 到事务协调器
  3. 事务协调器写入 PrepareCommit 或 PrepareAbort 到内部 __transaction_state topic
  4. 协调器返回响应
  5. Sender 线程 reset 事务状态 → READY
步骤 5:abortTransaction()

与 commit 类似,但 EndTxnRequest(committed=false)。Broker 将事务中所有未完成 batch 标记为 abort,consumer 隔离级别为 read_committed 时不会看到这些消息。

步骤 6:sendOffsetsToTransaction() —— 消费-生产原子化
正常流程:
  consumer.poll() → 处理 → producer.beginTransaction()
  → producer.send(outputTopic)      // 生产
  → producer.sendOffsetsToTransaction(inputOffsets)  // 提交消费位移
  → producer.commitTransaction()     // 二者原子提交

这使得消费位移的提交和输出消息的发送成为同一个原子操作。如果 commit 成功,两者都成功;如果 abort,两者都回滚。

4.4 2PC 扩展:prepareTransaction() + completeTransaction()

这是为外部两阶段提交协调器设计的功能:

// Phase 1: Prepare
PreparedTxnState state = producer.prepareTransaction();

// ... 外部系统的操作(如数据库写入)...

// Phase 2: Complete
producer.completeTransaction(state);
// 内部比较 PreparedTxnState 的 (producerId, epoch),匹配则 commit,不匹配则 abort

prepareTransaction() 将状态切换到 PREPARED_TRANSACTION,记录当前的 (producerId, epoch)。此时禁止任何新的 send/beginTransaction——只有 commit/abort/complete 可以调用。

KafkaProducer.java

private void throwIfInPreparedState() {
    if (transactionManager != null &&
        transactionManager.isTransactional() &&
        transactionManager.isPrepared()
    ) {
        throw new IllegalStateException("Cannot perform operation while the transaction is in a prepared state. " +
            "Only commitTransaction(), abortTransaction(), or completeTransaction() are permitted.");
    }
}
4.5 Transaction V1 vs V2

TransactionManager.java

public synchronized void maybeUpdateTransactionV2Enabled(boolean onInitiatialization) {
    ...
    ApiVersions.FinalizedFeaturesInfo info = apiVersions.getFinalizedFeaturesInfo();
    Short transactionVersion = info.finalizedFeatures.get("transaction.version");
    isTransactionV2Enabled = transactionVersion != null && transactionVersion >= 2;
    ...
}
特性TV1TV2
添加分区到事务显式 AddPartitionsToTxnRequest隐式:分区首次使用时自动注册
sendOffsetsToTransaction先 AddOffsetsToTxn 再 TxnOffsetCommit直接 TxnOffsetCommit(版本 4+),跳过 AddOffsetsToTxn
网络开销每新分区一个 round-trip零额外开销
Epoch BumpEndTxn 后需单独 bumpEndTxn 响应中直接返回新 epoch

TV2 通过 KIP-890 引入,大幅减少了事务的网络 RTT。


五、doSend 中的事务集成点

回顾 doSend() 中与事务相关的检查:

KafkaProducer.java

throwIfProducerClosed();
throwIfInPreparedState();    // 2PC: PREPARED 状态下禁止 send

TransactionManager.javaL1137-L1144

// 在 accumulator.append 之后注册分区
if (transactionManager != null) {
    transactionManager.maybeAddPartition(appendCallbacks.topicPartition());
}

// 如果需要,唤醒 sender
if (result.batchIsFull || result.newBatchCreated) {
    this.sender.wakeup();
}

异常处理中,ApiException 下调用 transactionManager.maybeTransitionToErrorState(e),将某些可恢复异常转为 ABORTABLE_ERROR,强制用户必须 abort 当前事务才能继续。


六、错误处理全景

OutOfOrderSequenceException
batch(seq=0) → 成功
batch(seq=1) → 成功
batch(seq=3) → Broker: seq > lastSeq+1 (2) → OutOfOrderSequenceException!

幂等生产者:epoch bump,重置所有分区序列号。不可恢复的数据丢失已发生(seq=2 永久丢失)。

事务生产者:转为 ABORTABLE_ERROR,用户必须 abort 事务。事务保护了原子性——所有消息要么一起成功,要么一起回滚。

ProducerFencedException

当另一个具有相同 transactionalId 的 Producer 实例调用了 initTransactions() 时,旧实例的下一次请求会被 Broker 拒绝:

Producer A: (PID=1001, Epoch=0) ──► Broker
Producer B 以相同 transactionalId initTransactions()
Broker 为 B 分配: (PID=1001, Epoch=1)
Producer A: 发送请求 (Epoch=0) ──► Broker: ProducerFencedException!

这是全局级的 epoch fencing——保证同一时刻只有一个活跃的事务 Producer。


七、完整生命周期时序图

应用线程                Sender 线程              Broker
   │                        │                      │
   ├─ initTransactions()    │                      │
   │  ├─ state → INITIALIZING                      │
   │  ├─ enqueue InitPidReq │                     │
   │  └─ sender.wakeup()───►│                      │
   │                        ├─ InitProducerIdRequest ──►
   │                        │◄── (PID=1001, Epoch=0)
   │                        ├─ state → READY        │
   │                        │                      │
   ├─ beginTransaction()    │                      │
   │  └─ state → IN_TRANSACTION                    │
   │                        │                      │
   ├─ send(record, topic-A) │                      │
   │  ├─ doSend() → accumulator.append()           │
   │  └─ maybeAddPartition(A-0)                    │
   │                        │                      │
   ├─ send(record, topic-B) │                      │
   │  └─ maybeAddPartition(B-3)                    │
   │                        │                      │
   ├─ sendOffsetsToTransaction(offsets)             │
   │  ├─ enqueue TxnOffsetCommitReq                │
   │  ├─ sender.wakeup()───►│                      │
   │  │                     ├─ TxnOffsetCommitRequest ──► Consumer Group Coordinator
   │  └─ result.await() ⏳  │◄── OK                │
   │                        │                      │
   ├─ commitTransaction()   │                      │
   │  ├─ beginCommit()      │                      │
   │  │  ├─ state → COMMITTING                     │
   │  │  ├─ AddPartitionsToTxn (如有)              │
   │  │  └─ enqueue EndTxnReq(COMMIT)              │
   │  └─ sender.wakeup()───►│                      │
   │                        ├─ EndTxnRequest ──────► Transaction Coordinator
   │                        │◄── OK                │
   │                        ├─ state → READY        │
   │  result.await() 返回 ◄─┤                      │
   ▼                        ▼                      ▼

八、总结

特性幂等事务
配置enable.idempotence=true(Kafka 3.0+ 默认)enable.idempotence=true + transactional.id=xxx
保证单分区内 exactly-once跨分区/跨 topic/跨 consume-produce 原子性
PID动态获取(InitProducerId 无 transactionalId,PID 为空字符串映射)固定绑定 transactionalId,跨 session 不变
Epoch每次 InitProducerId 递增每次 initTransactions() 递增,提供 fencing
序列号每分区递增,跨 batch 连续同左,但事务 abort 后重置
故障恢复epoch bump,丢失中断期间的消息事务整体 abort,用户重试整个事务
额外开销每条消息多 2 字节(seq no)多轮网络 RTT(InitPid + AddPartitions + EndTxn)

核心公式:事务 = 幂等 + 全局 PID + epoch fencing + 2PC EndTxn 协议。幂等解决单分区消息不丢不重,事务解决跨分区的原子性问题。

Sender 线程

Sender 是 Kafka Producer 唯一的后台 I/O 线程,继承自 KafkaThread(本质是 daemon 线程),实现 Runnable。它是用户线程(doSend 等)与网络层(NetworkClient)之间的桥梁,也是 RecordAccumulator 中数据的唯一消费者

总结

Sender 线程是 Kafka Producer 异步发送模型的调度中枢

  1. 生产者-消费者模型:用户线程是多生产者(并发 doSend),Sender 是单消费者(唯一 drain 线程)
  2. batch 生命周期管理:从 deque 取出 → 压缩 → 分配 seq → 发送 → 响应处理 → 重试/完成/失败 → 归还内存
  3. 三阶段关闭:正常接受 → 排空剩余 → 事务清理,保证 at-least-once 语义
  4. 事务优先:事务请求优先于 produce 请求,且事务进行中时 produce 暂停
  5. 分布式重试:支持退避重试、leader 变更跳过退避、batch 拆分、元数据刷新
  6. 零拷贝指标:每个 topic 独立统计,sender metrics 在发送端记录而非接收端
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值