《Java 消息中间件·Kafka 内核与客户端》7.1 KafkaProducer 的线程模型

KafkaProducer 只有两个线程:调用 send() 的应用线程,和跑 Sender 循环的 kafka-producer-network-thread。本节逐行读 4.3.0 的 send() 与 doSend(),实测回调跑在哪个线程、close() 在回调里为什么被降级成零超时,并划出共享状态与线程安全的边界。

一条消息背后其实只有两个线程

打开一个 KafkaProducer,你会发现它没有线程池,没有 reactor 组,也没有一堆 worker。

它只有两个线程:

  1. 调用线程。你的业务代码在哪个线程里调 send(),工作的前半段就在哪个线程里完成。
  2. I/O 线程。名字是 kafka-producer-network-thread | <client.id>,跑的是 Sender.run()。

4.3.0 的构造器里,这一行就是全部的证据:

// KafkaProducer.java:467-469
            String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
            this.ioThread = new Sender.SenderThread(ioThreadName, this.sender, true);
            this.ioThread.start();

第三个参数 true 是 daemon 标志。也就是说,如果应用只关了主线程、没有显式 close() 生产者,这个 I/O 线程不会阻止 JVM 退出——它会随着进程一起消失,缓冲区里没发出去的批次也就一起消失了。

NETWORK_THREAD_PREFIX 常量定义在同文件第 245 行,值就是 "kafka-producer-network-thread"。你在 jstack 里看到的名字,前 28 个字符来自这里,竖线后面是你配的 client.id。

数量上的结论很硬:无论生产者被多少个业务线程共用,I/O 线程永远只有一个。所以生产者侧的并行度不来自线程数,而来自「一次请求里塞了多少个分区的批次」。

send() 在源码里只有两行

send(record, callback) 的方法体一共三条语句:

// KafkaProducer.java:958-963
    @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
        // intercept the record, which can be potentially modified; this method does not throw exceptions
        ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
        return doSend(interceptedRecord, callback);
    }

这两行值得单独拎出来说,因为它纠正了一个很常见的印象:拦截器不在 doSend 里,而在 doSend 之前。

后果有两个:

  • 拦截器能改的是「即将进入发送流程的记录」,包括 key、value、headers、甚至分区号;它改不了元数据等待、序列化之后的结果。
  • 注释写明 onSend 不会抛异常。ProducerInterceptors.onSend 内部会捕获拦截器抛出的异常并记日志,然后把原记录传下去。所以「我的拦截器挂了,消息发不出去」这种情况不会发生;相反,是「我的拦截器挂了,但它没生效」,问题更隐蔽。

doSend 才是真正的实现。它做六件事,顺序不能乱。

doSend 的六个步骤

第一步,确认生产者还活着:

// KafkaProducer.java:967-970
    private void throwIfProducerClosed() {
        if (sender == null || !sender.isRunning())
            throw new IllegalStateException("Cannot perform operation after producer has been closed");
    }

这里的 sender.isRunning() 读的是 Sender 里那个 volatile boolean running。所以「关了没有」这件事在跨线程时是可见的,不需要加锁。

第二步,等元数据:

// KafkaProducer.java:985-996
            long nowMs = time.milliseconds();
            ClusterAndWaitTime clusterAndWaitTime;
            try {
                clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), nowMs, maxBlockTimeMs);
            } catch (KafkaException e) {
                if (metadata.isClosed())
                    throw new KafkaException("Producer closed while send in progress", e);
                throw e;
            }
            nowMs += clusterAndWaitTime.waitedOnMetadataMs;
            long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);
            Cluster cluster = clusterAndWaitTime.cluster;

这一步是调用线程第一次可能被卡住的地方。它要拿到「这个 topic 有几个分区、每个分区的 leader 是谁」。缓存里有就直接返回;没有就得向集群请求,最多等到 max.block.ms。

注意 remainingWaitMs 的算法:元数据等待花掉的时间,会从后面申请缓冲的预算里扣掉。max.block.ms 是整个 send() 的总预算,不是每个阻塞点各给一份。这一点在 7.2 节讲缓冲池时还会用到。

第三步,序列化。key 和 value 分别序列化,ClassCastException 会被包成 SerializationException——因为泛型在运行期擦除,序列化器拿到的类型对不上时,报出来的原始异常是 ClassCastException,直接抛给用户会很难懂。

第四步,定分区:

// KafkaProducer.java:1017
            int partition = partition(record, serializedKey, serializedValue, cluster);

如果记录里显式指定了分区,直接用;如果配了自定义 partitioner.class,调它;否则返回 RecordMetadata.UNKNOWN_PARTITION,交给累积器里的内置分区器(粘性分区)去挑。3.3 节讲过粘性分区的行为,这里只强调一点:分区号可能不是在这一行定下来的,真正定下来的位置在 RecordAccumulator.append 内部。所以后面那行 assert appendCallbacks.getPartition() != RecordMetadata.UNKNOWN_PARTITION 才成立。

第五步,算大小并校验:

// KafkaProducer.java:1019-1025
            setReadOnly(record.headers());
            Header[] headers = record.headers().toArray();

            int serializedSize = AbstractRecords.estimateSizeInBytesUpperBound(RecordBatch.CURRENT_MAGIC_VALUE,
                    compression.type(), serializedKey, serializedValue, headers);
            ensureValidRecordSize(serializedSize);
            long timestamp = record.timestamp() == null ? nowMs : record.timestamp();

ensureValidRecordSize 比的是单条记录估算大小与 max.request.size(默认 1048576 字节)。超了直接抛 RecordTooLargeException。注意它估的是单条,而请求是按批次发的,一批里可以有多条——所以「单条不超」不代表「批次不超」,后者由 broker 的 message.max.bytes 管。

第六步,进累积器并唤醒 I/O 线程:

// KafkaProducer.java:1027-1030
            // Append the record to the accumulator.  Note, that the actual partition may be
            // calculated there and can be accessed via appendCallbacks.topicPartition.
            RecordAccumulator.RecordAppendResult result = accumulator.append(record.topic(), partition, timestamp, serializedKey,
                    serializedValue, headers, appendCallbacks, remainingWaitMs, nowMs, cluster);
// KafkaProducer.java:1041-1045
            if (result.batchIsFull || result.newBatchCreated) {
                log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), appendCallbacks.getPartition());
                this.sender.wakeup();
            }
            return result.future;

wakeup() 的实现只有一行 this.client.wakeup()(Sender.java:963-965)。它的作用是让 I/O 线程从 Selector.poll() 里立刻返回,不用等超时。这是「应用线程 → I/O 线程」之间唯一的信号机制,没有队列、没有锁、没有条件变量。

异常分两路,这是最该记住的一条

doSend 的 catch 块写得很清楚:

// KafkaProducer.java:1046-1061
            // handling exceptions and record the errors;
            // for API exceptions return them in the future,
            // for other exceptions throw directly
        } catch (ApiException e) {
            log.debug("Exception occurred during message send:", e);
            if (callback != null) {
                TopicPartition tp = appendCallbacks.topicPartition();
                RecordMetadata nullMetadata = new RecordMetadata(tp, -1, -1, RecordBatch.NO_TIMESTAMP, -1, -1);
                callback.onCompletion(nullMetadata, e);
            }
            this.errors.record();
            this.interceptors.onSendError(record, appendCallbacks.topicPartition(), e);
            if (transactionManager != null) {
                transactionManager.maybeTransitionToErrorState(e);
            }
            return new FutureFailure(e);

规则是:

异常类型例子表现
ApiException 及其子类TimeoutException、RecordTooLargeException、BufferExhaustedException不抛,包进返回的 Future
KafkaException生产者关闭、内部状态错误当场抛
SerializationException序列化器返回 null 或类型不符当场抛(它直接继承 KafkaException,不是 ApiException)
其它 Exception拦截器之外的 NPE 等当场抛

TimeoutException 属于 ApiException,所以「元数据等不到」不会在 send() 这一行抛出来,而是让你在 future.get() 时收到。7.2 节会实测到同一条规律用在 BufferExhaustedException 上:阻塞发生在 send(),异常出现在 Future。这两个位置分开,是很多人写 let-it-crash 逻辑时踩空的地方。

回调跑在哪个线程

这是本节最重要的实测。四个业务线程并发 send(),在回调里同时打印「调用线程名」和「回调线程名」:

for (int t = 0; t < 4; t++) {
    Thread th = new Thread(() -> {
        String caller = Thread.currentThread().getName();
        producer.send(new ProducerRecord<>(TOPIC, "k", "payload"),
                (md, ex) -> System.out.println("CALLBACK caller=" + caller
                        + " callbackThread=" + Thread.currentThread().getName()
                        + " partition=" + md.partition() + " offset=" + md.offset()));
    }, "app-thread-" + t);
    th.start();
}

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

MAIN calling thread = main
PRODUCER ioThread  = kafka-producer-network-thread | b2-thread-model
CALLBACK caller=app-thread-0 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=1
CALLBACK caller=app-thread-3 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=2
CALLBACK caller=app-thread-0 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=3
CALLBACK caller=app-thread-1 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=4
CALLBACK caller=app-thread-1 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=5
CALLBACK caller=app-thread-3 callbackThread=kafka-producer-network-thread | b2-thread-model partition=0 offset=0
CALLBACK caller=app-thread-2 callbackThread=kafka-producer-network-thread | b2-thread-model partition=0 offset=1
CALLBACK caller=app-thread-2 callbackThread=kafka-producer-network-thread | b2-thread-model partition=2 offset=6
PROBE futureDoneRightAfterSend=false waitForAckMs=159
THREADS kafka-producer-network-thread | b2-thread-model

四个 app-thread-* 全部把回调交给了同一个 kafka-producer-network-thread。整个进程里与生产者相关的线程只有这一个。

顺带两个数据点:

  • futureDoneRightAfterSend=false:send() 返回时 Future 一定没完成。这是「异步」这个词最实在的证明。从 send() 到 ack 用了 159 ms(单次运行,随调度波动,不作基准)。
  • 分区是 2,2,2,2,2,0,0,2 这样成片出现的,不是均匀轮询——粘性分区在起作用,3.3 节讲过。

回调在 I/O 线程上跑,含义很直接:回调里的任何阻塞,都会拖住所有分区的发送。源码的 Javadoc 也写了这句警告,大意是回调应该足够快,否则会延迟其他线程的消息发送;需要阻塞或重计算就用自建 Executor 转一手。

线程安全边界到底在哪

先说一个可能让很多人意外的实测结果:4.3.0 的 KafkaProducer.java 里,synchronized 关键字出现 0 次。

unzip -p ~/.m2/repository/org/apache/kafka/kafka-clients/4.3.0/kafka-clients-4.3.0-sources.jar \
  org/apache/kafka/clients/producer/KafkaProducer.java > /tmp/KafkaProducer.java
grep -c "synchronized" /tmp/KafkaProducer.java

本机输出 0。生产者能被多线程共用,靠的不是门面上的一把大锁,而是把共享状态分散到几个各自负责同步的组件里:

共享对象同步方式源码位置
每个分区的 Deque<ProducerBatch>以 deque 自身为 monitorRecordAccumulator.java:314、:340
BufferPoolReentrantLock + ConditionBufferPool.java:107 起
Metadata 的快照volatile MetadataSnapshot,读不加锁Metadata.java:79、:136
元数据的写路径方法级 synchronized(24 处)Metadata.java:153 起
Sender.running / forceClosevolatile 布尔Sender.java:548、:556

这个设计的好处是:两个业务线程往不同分区发消息时,它们争的是两把不同的 monitor,不会互相排队;只有当它们恰好命中同一个分区时才串行。I/O 线程读元数据快照也不加锁,所以应用线程的发送不会被元数据刷新卡住。

坏处是「线程安全」有前提,用错了照样出事:

  1. 不要跨线程复用同一个 ProducerRecord。accumulator.append 会把 serializedKey、serializedValue、headers 数组直接塞进批次,批次异步写出去。你回头改记录里的对象,改的可能是还在缓冲区里的数据。
  2. headers 的处置要看清对象。doSend 第 1019-1020 行对传进去的那份 headers 调了 setReadOnly。但 ProducerRecord 的构造器会先复制一份(ProducerRecord.java:83:this.headers = new RecordHeaders(headers);),所以被冻结的是内部副本。

本机实测(同上环境):send() 之后继续往自己持有的 RecordHeaders 里 add,没有抛异常,条数从 1 变 2。也就是说,你手里的对象和生产者手里的对象不是同一个,改了不会影响已入队的记录,也不会被冻结拦住。想把「发出去之后就不能改」当成约束,得靠代码规范,不能靠这个 setReadOnly。

  1. send() 之后关闭生产者,再发就是 IllegalStateException。本机实测原文:
SEND-AFTER-CLOSE java.lang.IllegalStateException: Cannot perform operation after producer has been closed

close() 为什么不是「加一把锁」

很多资料写「close() 要加锁,防止和 send() 并发」。这个说法在 4.3.0 的源码里对不上——上面数过了,整个类没有 synchronized。

真实的关闭流程是这样的:

// KafkaProducer.java:1398-1417(节选,方法整体为 1390-1443)
        boolean invokedFromCallback = Thread.currentThread() == this.ioThread;
        if (timeoutMs > 0) {
            if (invokedFromCallback) {
                log.warn("Overriding close timeout {} ms to 0 ms in order to prevent useless blocking due to self-join. " +
                        "This means you have incorrectly invoked close with a non-zero timeout from the producer call-back.",
                        timeoutMs);
            } else {
                // Try to close gracefully.
                final Timer closeTimer = time.timer(timeout);
                clientTelemetryReporter.ifPresent(ClientTelemetryReporter::initiateClose);
                closeTimer.update();

                if (this.sender != null) {
                    this.sender.initiateClose();
                    closeTimer.update();
                }
                if (this.ioThread != null) {
                    try {
                        this.ioThread.join(closeTimer.remainingMs());
                    } catch (InterruptedException t) {

把它拆成状态机看:

  1. sender.initiateClose():先 accumulator.close(),再 running = false,再 wakeup()。顺序是源码注释里特意强调的——先关累积器,保证 I/O 线程跳出主循环后不会再有新的 append 进来,否则那些批次的回调会永远等不到。
  2. ioThread.join(剩余时间):等 I/O 线程把存量发完。注意 I/O 线程在 running=false 之后并没有立刻退出,它还有两个收尾循环(Sender.java:258、:267):一个等「累积器里还有没抽干的批次,或还有在途请求,或有未完成事务」,一个处理事务中止。所以优雅关闭的时间下限由存量决定,不是零。
  3. join 超时,就 forceClose():置 forceClose = true,I/O 线程放弃剩余批次,accumulator.abortIncompleteBatches() 把它们的 Future 全部标记失败,然后 client.close()。

那为什么不能用一把锁包住 close()?因为回调运行在 I/O 线程上。如果 close() 拿锁等 join,而 I/O 线程此刻正在执行你写的回调、回调里又调了 close(),就成了自己等自己。源码不用锁解决,用线程身份判断解决:Thread.currentThread() == this.ioThread 命中时,把超时改成 0,直接进入强制关闭路径,只做状态切换不做阻塞等待。

flush() 用的是同一个判断(KafkaProducer.java:1216-1219),只不过它的处理更硬——直接抛异常:

// KafkaProducer.java:1216-1220
    public void flush() {
        if (Thread.currentThread() == this.ioThread) {
            log.error("KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock.");
            throw new KafkaException("KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock.");
        }

这两条路径本机都跑过,输出如下(同一份程序):

[kafka-producer-network-thread | b2-thread-model] ERROR org.apache.kafka.clients.producer.KafkaProducer - [Producer clientId=b2-thread-model] KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock.
FLUSH-IN-CALLBACK org.apache.kafka.common.KafkaException: KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock.
[kafka-producer-network-thread | b2-thread-model] WARN org.apache.kafka.clients.producer.KafkaProducer - [Producer clientId=b2-thread-model] Overriding close timeout 5000 ms to 0 ms in order to prevent useless blocking due to self-join. This means you have incorrectly invoked close with a non-zero timeout from the producer call-back.
CLOSE-IN-CALLBACK returned, cbThread=kafka-producer-network-thread | b2-thread-model

flush() 立刻失败,close(5s) 被降级成 0 再返回。两个行为都来自同一个判断,也都在日志里留下了原文。

线程归属表

把前面的事实压成一张表,遇到并发问题时按这张表定位:

动作在哪个线程会不会阻塞源码依据
send() 的拦截器链调用线程不会抛异常KafkaProducer.java:961
等元数据(首次或过期)调用线程最多 max.block.ms:988
序列化调用线程取决于你的序列化器:997-1017
分区计算调用线程(无 key 时可能在累积器内)否:1017、RecordAccumulator.java:301
申请缓冲调用线程最多剩余 max.block.msRecordAccumulator.java:333
攒批、判满、按节点抽取I/O 线程每次 runOnce() 一轮Sender.java:310、:380
发请求、收响应I/O 线程poll 最长到下次到期时间Sender.java:345
用户回调I/O 线程阻塞它会拖慢全部发送实测
close() 等待调用线程(回调内则降级)到超时为止:1390-1443

回到 libraryd

libraryd-mq 的 NaiveProducer 一开始是这样用的:一个 Spring 定时任务里 send(),另一个请求线程里也 send(),共用一个静态 KafkaProducer。这个用法本身没问题——生产者是线程安全的,前提是两边不共享 ProducerRecord,也不在回调里做重活。

真出问题的是回调。有人在 onCompletion 里写库、查库、再发一条下游事件。这些操作全部落在 kafka-producer-network-thread 上,一慢就把所有分区的发送拖住,buffer.memory 很快被填满,然后 send() 开始阻塞——应用线程和 I/O 线程在同一个环里互相等。7.2 节会把这条链条上的每个环节用数字补全。

本节只回答一个问题:这条消息在 send() 返回之前,被谁碰过、在哪个线程上碰的。答案是:调用线程碰了拦截器、元数据、序列化、分区和入队;I/O 线程碰了剩下的一切,包括你的回调。

阅读导航:上一节 6.3 KIP-848 新一代消费组协议 | 下一节 7.2 RecordAccumulator 与批次缓存

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收