ProducerConfig
ProducerConfig 继承 AbstractConfig,核心是一个 静态 ConfigDef,在 static { } 块中用建造者模式逐一定义所有配置项。构造时把用户的 Properties/Map 传入父类解析,随后postProcessParsedConfig() 做二次校验和修正。CONFIG 定义涵盖 约 40 个配置项(含 SSL/SASL 继承的),下面按 8 个大类逐一拆解:
一、核心必填配置(无默认值,缺失直接抛异常)
以下3个配置无默认值,初始化 ConfigDef 时缺失会直接抛出 ConfigException。存在特殊绕过逻辑:若业务代码直接传入 Serializer 实例到生产者构造函数,appendSerializerToConfig() 会自动将实例Class注入配置Map,绕过该校验。
|
配置项 |
类型 |
默认值 |
说明 |
|---|---|---|---|
|
bootstrap.servers |
LIST |
无默认值 |
broker 地址列表,示例: |
|
key.serializer |
CLASS |
无默认值 |
key 序列化器全限定类名 |
|
value.serializer |
CLASS |
无默认值 |
value 序列化器全限定类名 |
二、攒批+背压配置(直接决定吞吐量)
该组配置为生产者吞吐核心,控制消息攒批规则、内存缓冲、阻塞阈值,直接影响QPS与延迟,Kafka 4.0 优化了默认攒批逻辑,类比TCP Nagle算法。
|
配置项 |
类型 |
默认值 |
说明 |
|---|---|---|---|
|
batch.size |
INT |
16384 (16KB) |
单个partition的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) |
|
|
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 |
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 双重约束:
-
幂等开启:必须≤5,否则直接报错
-
无幂等、重试开启、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 阻塞循环
关键细节:
- metadata.add () 被调用了两次:循环外的第一次用 nowMs,循环内的第二次用 nowMs + elapsed 来重置 topic 过期时间。
- metadata.awaitUpdate (version, remainingWaitMs) 是真正的阻塞点,底层使用 CountDownLatch 或条件变量等待。
- metadata.maybeThrowExceptionForTopic (topic):检查 topic 是否有 fatal 异常(如 AuthorizationException、AuthenticationException),有则直接抛出。
- 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 () 内部逻辑:
- 加锁
- 尝试从 free list 获取相同大小的 buffer(poolable 路径)
- 如果 nonPooledAvailableMemory + freeListSize >= size:立即满足,无需阻塞
- 否则:创建 Condition moreMemory,加入 waiters 队列
- moreMemory.await (remainingTimeToBlockNs) — 阻塞等待
- 被唤醒后循环检查:从 free list 取 buffer,或从 nonPooledAvailableMemory 中积累
- 超时 → BufferExhaustedException
- 成功 → 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 都满了 | true | producedBytes >= stickyBatchSize 时切换 |
| 有未满的 batch(linger.ms 较大) | false | 先不切换,直到 producedBytes >= stickyBatchSize * 2 强制切换 |
*2 上限的设计意图:防止因 batch 始终不满而导致永远不切换的病态情况。
7.6 producedBytes 使用 AtomicInteger
多线程同时 append 到同一分区时,producedBytes 的累加是原子的。
八、异常处理体系
异常处理对比
表格
| 异常类型 | 是否通知 callback | 是否返回 Future | 是否影响事务 | 是否记录 metrics |
|---|---|---|---|---|
| ApiException | 是(nullMetadata) | FutureFailure | maybeTransitionToErrorState | errors.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;
}
}
四级决策树
| 优先级 | 条件 | 行为 | 结果 |
|---|---|---|---|
| 1 | record.partition() != null | 用户显式指定分区 | 直接返回指定分区 |
| 2 | 自定义 Partitioner 已配置 | 委托自定义分区器 | 自定义分区数 |
| 3 | 有 key && !partitionerIgnoreKeys | murmur2(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)
}
核心字段:
stickyPartitionInfo:AtomicReference<StickyPartitionInfo>,当前"粘住"的分区及其已生产字节数partitionLoadStats:volatile 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_PARTITION | peekCurrentPartitionInfo() 选一个 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;
}
逻辑:
- 取 deque 的最后一个 batch(
peekLast)——这是当前正在填充的活跃 batch - 调用
ProducerBatch.tryAppend()尝试写入 - 成功 → 计算
appendedBytes(差量),返回结果(newBatchCreated = false) - 失败(return null,说明 batch 满了)→ 调用
closeForRecordAppends()关闭该 batch 的追加通道,释放压缩中间缓冲区 - 返回
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..."
正常创建路径:
MemoryRecords.builder(buffer, ...)用分配的 buffer 创建MemoryRecordsBuildernew ProducerBatch(...)构造 batchbatch.tryAppend(...)写入第一条 record(这里requireNonNull因为新 batch 一定能写入)dq.addLast(batch)加入队列末尾incomplete.add(batch):注册到未完成 batch 集合,Sender 后续会从中取出并发送- 返回结果:
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() 是消费者,通过以下机制协同:
- Deque 作为有界缓冲区:用户线程
addLast(batch),Sender 线程pollFirst(batch) incomplete集合:记录所有未完成的 batch,Sender 发送完成后deallocate释放muted集合:当分区没有 leader 或正在重试时,Sender 会 mute 该分区,停止 drainnextBatchExpiryTimeMs: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 = totalMemory,free 为空——所有内存都是"未分配额度"。
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 字节,而是每被唤醒一次就收集一点:
- 检查
free队列是否有可复用的 buffer(仅第一次迭代且 size == poolableSize) - 调用
freeUp(size - accumulated)释放池中 buffer - 从
nonPooledAvailableMemory中尽力扣减min(缺口, 可用额度) accumulated累加扣减量- 如果
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 的设计精妙之处有三:
-
对象池 + 额度池双层模型:
free队列缓存poolableSize大小的ByteBuffer避免频繁分配/GC,nonPooledAvailableMemory计数器管理非标准大小的内存额度。两者通过freeUp()互相转换。 -
公平的 FIFO 等待队列:使用
ReentrantLock+ 多个Condition实现精确的线程级等待/唤醒,waiters为 FIFO 队列保证先到先得,杜绝饥饿。 -
渐进式内存收集:等待线程不要求一次性满足全部需求,每被唤醒一次就收集一点可用内存("蚂蚁搬家"),配合级联唤醒机制和 OOM 安全网,在有限的 32MB 预算中实现了高吞吐、低延迟的内存复用。
幂等与事务概述
问题的起源
没有幂等和事务时,Kafka 提供的是 at-least-once 语义:
Producer 发送 batch → Broker 写入成功 → ACK 网络丢失 → Producer 重试 → 重复消息!
同一个 batch 被写入了两次。对于幂等和事务,需要通过三个关键标识符来消除重复。
三大核心标识符
| 标识符 | 类型 | 谁分配 | 生命周期 | 作用 |
|---|---|---|---|---|
| Producer ID (PID) | long | Broker | Producer 重启重新分配 | 唯一标识一个 Producer 实例 |
| Producer Epoch | short | Broker,每次 InitProducerId 递增 | 随 PID 一起分配 | 隔离不同"代"的 producer,旧 epoch 的请求被拒绝 |
| Sequence Number | int | Producer 本地,每分区每 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;
}
初始状态 NONE:producerId = -1, epoch = -1,表示还没从 Broker 获取过 PID。
幂等生产者(Idempotent Producer)
enable.idempotence 从 Kafka 3.0 开始默认为 true。如果 transactional.id 为空,则创建幂等生产者(不创建事务)。
启用幂等时强制要求:
acks = all(确保所有 ISR 副本确认)max.in.flight.requests.per.connection ≤ 5retries > 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.id | null | 事务 ID,是跨 session 幂等的标识 |
transaction.timeout.ms | 60000 | Broker 侧事务超时 |
transaction.two.phase.commit.enable | false | 是否开启 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);
...
关键步骤:
transitionTo(INITIALIZING)- 发送
InitProducerIdRequest到 Broker - → Broker 查找
transactionalId:- 第一次 → 分配新的
(PID=1001, Epoch=0) - 之前有未完成事务 → 自动 abort 旧事务(保证 clean slate)
- 之前的事务已 commit/abort → 递增 Epoch:
(PID=1001, Epoch=1)
- 第一次 → 分配新的
- 接收到响应后
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
);
...
- 如果有尚未注册的分区 → 先发送
AddPartitionsToTxnRequest - 然后发送
EndTxnRequest(committed=true)到事务协调器 - 事务协调器写入 PrepareCommit 或 PrepareAbort 到内部
__transaction_statetopic - 协调器返回响应
- 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;
...
}
| 特性 | TV1 | TV2 |
|---|---|---|
| 添加分区到事务 | 显式 AddPartitionsToTxnRequest | 隐式:分区首次使用时自动注册 |
sendOffsetsToTransaction | 先 AddOffsetsToTxn 再 TxnOffsetCommit | 直接 TxnOffsetCommit(版本 4+),跳过 AddOffsetsToTxn |
| 网络开销 | 每新分区一个 round-trip | 零额外开销 |
| Epoch Bump | EndTxn 后需单独 bump | EndTxn 响应中直接返回新 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 异步发送模型的调度中枢:
- 生产者-消费者模型:用户线程是多生产者(并发
doSend),Sender 是单消费者(唯一 drain 线程) - batch 生命周期管理:从 deque 取出 → 压缩 → 分配 seq → 发送 → 响应处理 → 重试/完成/失败 → 归还内存
- 三阶段关闭:正常接受 → 排空剩余 → 事务清理,保证 at-least-once 语义
- 事务优先:事务请求优先于 produce 请求,且事务进行中时 produce 暂停
- 分布式重试:支持退避重试、leader 变更跳过退避、batch 拆分、元数据刷新
- 零拷贝指标:每个 topic 独立统计,sender metrics 在发送端记录而非接收端

869

被折叠的 条评论
为什么被折叠?



