《Java 消息中间件·Kafka 内核与客户端》7.3 Sender 与 NetworkClient

Sender 是一个 while 循环:拉就绪分区、按节点抽批、发请求、poll 响应。本节读 4.3.0 的 runOnce、sendProducerData 与 NetworkClient.poll,并用三组对照实验把 max.in.flight 与重试的乱序关系做实:5 个在途出现 9 处乱序,1 个在途与开启幂等均为 0 处。

一个 while 循环就是整个发送侧

7.1 节说生产者只有一个 I/O 线程,它跑的就是 Sender.run()。runOnce() 外面套着一个兜底的 catch (Exception),所以这个线程不会因为任何单次异常而死掉:

// Sender.java:240-251
    @Override
    public void run() {
        log.debug("Starting Kafka producer I/O thread.");

        // main loop, runs until close is called
        while (running) {
            try {
                runOnce();
            } catch (Exception e) {
                log.error("Uncaught error in kafka producer I/O thread: ", e);
            }
        }

日志里会出现 Uncaught error in kafka producer I/O thread,然后循环继续。这条设计让生产者极其耐操,也让故障变得安静:如果没人盯这个日志,线程可能每毫秒都在抛异常,而业务侧只看到「消息发得慢」。

running 变成 false 之后,run 并没有立刻结束,它还有两段收尾:

// Sender.java:258
        while (!forceClose && ((this.accumulator.hasUndrained() || this.client.inFlightRequestCount() > 0) || hasPendingTransactionalRequests())) {
// Sender.java:267
        while (!forceClose && transactionManager != null && transactionManager.hasOngoingTransaction()) {

第一段把「累积器里还有没抽干的批次」和「还有在途请求」发完,第二段处理未完成的事务。所以优雅 close() 的耗时下限由存量决定,7.1 节从调用方看是 join,从这里看是这两个循环。

runOnce 的顺序:先发后收

// Sender.java:343-346
        long currentTimeMs = time.milliseconds();
        long pollTimeout = sendProducerData(currentTimeMs);
        client.poll(pollTimeout, currentTimeMs);
    }

顺序是「先组织并发出请求,再 poll 收响应」。pollTimeout 由 sendProducerData 算出来:有数据可发就是 0(立刻回来继续发),没数据可发就是「距离最近的到期时间」。这一行决定了 Sender 的节奏:它不是定时轮询,而是事件驱动 + 超时兜底。应用线程 send() 到新批次时会 wakeup(),把 Selector.poll() 叫醒;没有新数据时,它就睡到下一个 linger 到期、重试退避结束或请求超时。事务生产者在两行之前还有一层 transactionManager 的处理(Sender.java:311-341),细节留给卷二。

sendProducerData:五步

// Sender.java:380-383
    private long sendProducerData(long now) {
        MetadataSnapshot metadataSnapshot = metadata.fetchMetadataSnapshot();
        // get the list of partitions with data ready to send
        RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(metadataSnapshot, now);

第一步是拿一份元数据快照。fetchMetadataSnapshot() 读的是 Metadata 里那个 volatile 字段(Metadata.java:79),不加锁;整个 sendProducerData 全程用同一份快照,不会出现「前半段用旧 leader、后半段用新 leader」的撕裂。第二步是过滤节点:client.ready(node, now) 返回 false 的从就绪集合里删掉,并记下最短的等待时间(:400-415)。节点没连上、正在握手、或者 in-flight 已满,都会在这一步被挡掉。

第三步是抽批:

// Sender.java:417-426
        // create produce requests
        Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(metadataSnapshot, result.readyNodes, this.maxRequestSize, now);
        addToInflightBatches(batches);
        if (guaranteeMessageOrder) {
            // Mute all the partitions drained
            for (List<ProducerBatch> batchList : batches.values()) {
                for (ProducerBatch batch : batchList)
                    this.accumulator.mutePartition(batch.topicPartition);
            }
        }

返回值是按 nodeId 分组的批次列表,不是按分区。一次 drain 会把「发往同一个 broker 的所有分区的批次」放在一个 list 里,下一步就是把它们塞进同一个 ProduceRequest。guaranteeMessageOrder 为 true 时,抽出去的批次所属分区立刻被 mute;被 mute 的分区在 batchReady 里直接跳过(RecordAccumulator.java:611),直到在途请求回来才解除。这就是「max.in.flight=1 保证顺序」的实现方式——不是限制请求数,是禁止同一分区有第二个批次在途。

第四步处理过期批次(:428-433),超过 delivery.timeout.ms 的被标失败;第五步才真正发请求。

请求按节点合并,按大小切分

drain 里的切分条件只有一行:

// RecordAccumulator.java:889-893
                if (size + first.estimatedSizeInBytes() > maxSize && !ready.isEmpty()) {
                    // there is a rare case that a single batch size is larger than the request size due to
                    // compression; in this case we will still eventually send this batch in a single request
                    break;
                } else {

maxSize 就是 max.request.size(默认 1048576)。累加到超过上限就停,剩下的留到下一轮。注意 !ready.isEmpty() 这个条件:如果第一个批次本身就超过上限,还是会让它单独成请求——宁可发一个超大的,也不让队列卡死。然后每个节点一个请求:

// Sender.java:896-905
    private void sendProduceRequest(long now, int destination, short acks, int timeout, List<ProducerBatch> batches) {
        if (batches.isEmpty())
            return;

        final Map<TopicPartition, ProducerBatch> recordsByPartition = new HashMap<>(batches.size());
        Map<String, Uuid> topicIds = topicIdsForBatches(batches);

        ProduceRequestData.TopicProduceDataCollection tpd = new ProduceRequestData.TopicProduceDataCollection();
        for (ProducerBatch batch : batches) {
            TopicPartition tp = batch.topicPartition;

一个 ProduceRequest 里可以带多个 topic、每个 topic 多个分区,每个分区一个批次。所以「一个请求 = 一个节点上所有就绪分区的批次」,请求数和分区数不是一对一。最后 client.send(clientRequest, now) 把请求交给 NetworkClient,状态变成「在途」。

in-flight 的判定只有一行

「还能不能往这个节点发」的答案在这里:

// InFlightRequests.java:96-100
    public boolean canSendMore(String node) {
        Deque<NetworkClient.InFlightRequest> queue = requests.get(node);
        return queue == null || queue.isEmpty() ||
               (queue.peekFirst().send.completed() && queue.size() < this.maxInFlightRequestsPerConnection);
    }

三个条件:队列空、或者队头已经写完且数量还没到上限。queue.peekFirst().send.completed() 这一项的意思是「上一个请求的字节已经完全写进 socket」——半截写不算,避免多个请求的字节在 TCP 缓冲里交错。maxInFlightRequestsPerConnection 来自 max.in.flight.requests.per.connection,默认 5;它按连接计,不是按分区、也不是全局。

这个数字有两条约束同时压着:

  • 设成 1,同一时刻每个 broker 只有一个未确认请求,顺序天然安全(配合 mute),但吞吐掉一个数量级;
  • 设成大于 1 且关掉幂等,源码注释写明会有乱序风险。ProducerConfig.java 里那段原文是:
Allowing retries while setting enable.idempotence to false and max.in.flight.requests.per.connection to greater than 1 will potentially change the
ordering of records because if two batches are sent to a single partition, and the first fails and is retried but the second
succeeds, then the records in the second batch may appear first.

NetworkClient:五个状态与一次 poll

连接状态是一个五值枚举:

// ConnectionState.java:29(枚举定义;上面 23-28 行是逐态注释)
    DISCONNECTED, CONNECTING, CHECKING_API_VERSIONS, READY, AUTHENTICATION_FAILED;

CHECKING_API_VERSIONS 是 4.x 里值得记住的一态:TCP 连上不算就绪,还要先发一次 ApiVersions 协商版本。所以「连上了但发不出去」是正常现象,它在协商。

「能不能发」是三个条件的与:

// NetworkClient.java:530-533
    private boolean canSendRequest(String node, long now) {
        return connectionStates.isReady(node, now) && selector.isChannelReady(node) &&
            inFlightRequests.canSendMore(node);
    }

连接就绪、通道就绪、in-flight 没满。三者缺一,sendProducerData 就会把这个节点从本轮等待。

poll 是 I/O 的实际发生地:

// NetworkClient.java:642-673(节选;630-640 的签名与 abortedSends 分支本处省略)
        long metadataTimeout = metadataUpdater.maybeUpdate(now);
        long telemetryTimeout = telemetrySender != null ? telemetrySender.maybeUpdate(now) : Integer.MAX_VALUE;
        try {
            this.selector.poll(Utils.min(timeout, metadataTimeout, telemetryTimeout, defaultRequestTimeoutMs));
        } catch (IOException e) {
            log.error("Unexpected error during I/O", e);
        }

        // process completed actions
        long updatedNow = this.time.milliseconds();
        List<ClientResponse> responses = new ArrayList<>();
        handleCompletedSends(responses, updatedNow);
        handleCompletedReceives(responses, updatedNow);
        handleDisconnections(responses, updatedNow);
        handleConnections();
        handleInitiateApiVersionRequests(updatedNow);
        handleTimedOutConnections(responses, updatedNow);
        handleTimedOutRequests(responses, updatedNow);
        handleRebootstrap(responses, updatedNow);
        completeResponses(responses);

        return responses;
    }

    private void completeResponses(List<ClientResponse> responses) {
        for (ClientResponse response : responses) {
            try {
                response.onComplete();
            } catch (Exception e) {
                log.error("Uncaught error in request completion:", e);
            }
        }

一次 poll 里发生的动作被拆成九步,其中三步和本节直接相关:handleCompletedReceives(正常响应)、handleDisconnections(连接断了)、handleTimedOutRequests(请求超时)。关键在于后两步的处理方式:它们不重发请求,而是生成一个「失败」的 ClientResponse 交回给 Sender。断开时 cancelInFlightRequests 会把所有在途请求逐个变成 ClientResponse.disconnected() 或 .timedOut()(NetworkClient.java:390-430)。重试是 Sender 根据这个响应决定的,NetworkClient 只负责报告。

这层分工解释了一个重要现象:请求超时的时候,broker 可能已经把它写进日志了。客户端看到的是「我没收到响应」,broker 看到的是「我写成功了,只是响应回不去」。客户端随后重试,同一条消息就会写两次。这就是「重试导致重复」的物理来源,而不是什么玄学。

三组对照实验:in-flight 与乱序

这一节把 3.2 节留下的尾巴收掉:那里写了「第 1 个请求失败重试、第 2 个请求成功,顺序可能颠倒」,当时没做实验。要稳定触发重试,需要一个「可重试但不致命」的错误,这里用 broker 的 message.max.bytes:

docker exec mq-b2 /opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
  --create --topic b2.reorder --partitions 1 --replication-factor 1 \
  --config max.message.bytes=1024

生产者的 batch.size=2048,批次攒到约 2 KB 就会被 broker 以 MESSAGE_TOO_LARGE 拒绝。这个错误在 Sender.completeBatch 里走的是拆分重试分支:

// Sender.java:676-688
        if (error == Errors.MESSAGE_TOO_LARGE && batch.recordCount > 1 && !batch.isDone() &&
                (batch.magic() >= RecordBatch.MAGIC_VALUE_V2 || batch.isCompressed())) {
            // If the batch is too large, we split the batch and send the split batches again. We do not decrement
            // the retry attempts in this case.
            log.warn(
                "Got error produce response in correlation id {} on topic-partition {}, splitting and retrying ({} attempts left). Error: {}",
                correlationId,
                batch.topicPartition,
                this.retries - batch.attempts(),
                formatErrMsg(response));
            if (transactionManager != null)
                transactionManager.removeInFlightBatch(batch);
            this.accumulator.splitAndReenqueue(batch);

拆开的两半被 addFirst 塞回队头(RecordAccumulator.java:527-531),等下一轮重发。塞回队头意味着它们的偏移量一定比「已经成功发出去的第二批」大——顺序就在这里丢的。跑三组,每组 60 条记录写同一个分区,记录发送顺序与落盘偏移量的逆序对数量:

组max.in.flightenable.idempotenceMESSAGE_TOO_LARGE 次数逆序对失败
A5false2290
B1false2200
C5true2400

(单次运行,不作基准。)

A 组的原始输出:

FIRST-INVERSION sendSeq=0 offset=61 laterSendSeq=9 offset=60
RESULT maxInFlight=5 idempotent=false batchSize=2048 records=60 failed=0 inversions=9
OFFSETS 61,62,63,64,65,66,67,68,69,60,70,71,72,73,...

第 0 条记录(最早发的)拿到了 61 号位移,而第 9 条只拿到 60 号,同一批里 0~8 号都排在 9 号后面。B 组同样的 22 次拆分,偏移量是严整的 120,121,122…,一个逆序都没有。C 组更值得注意:拆分了 24 次(比 A 还多),顺序完全正常。所以「重试」不是乱序的充分条件,「重试 + 同一分区有多个在途批次 + 无幂等」才是。

暂停 broker 的五秒

第二组实验更接近真实故障。单分区、max.in.flight=5、enable.idempotence=false、request.timeout.ms=2000,生产者每秒发一条,发到第 3 条时把 broker 冻住 5 秒:

命令就三步:docker pause mq-b2、sleep 5、docker unpause mq-b2。

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

Disconnecting from node 1 due to request timeout.
Cancelled in-flight PRODUCE request with correlation id 8 due to node 1 being disconnected (elapsed time since create: 2043ms, ...)
Cancelled in-flight PRODUCE request with correlation id 9 due to node 1 being disconnected (elapsed time since create: 2038ms, ...)
Got error produce response with correlation id 8 on topic-partition b2.pause-0, retrying (4 attempts left). Error: ... 
Got error produce response with correlation id 9 on topic-partition b2.pause-0, retrying (4 attempts left). Error: ...

两个请求超时被取消,然后重试。解冻后用控制台消费者读这个 topic:

docker exec mq-b2 /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 \
  --topic b2.pause --from-beginning --timeout-ms 5000 \
  --formatter-property print.offset=true
Offset:0	v0
Offset:1	v1
Offset:2	v2
Offset:3	v3
Offset:4	v4
Offset:5	v4
Offset:6	v3
Offset:7	v5
Offset:8	v6
Offset:9	v7
Offset:10	v8
Offset:11	v9
Offset:12	v10
Offset:13	v11

12 条消息,14 条记录,两个后果同时出现:

  • 重复:v3 在 3 和 6,v4 在 4 和 5。被取消的两个请求其实已经被 broker 写进去了(它们排在 3、4 号),重试又写了一遍(5、6 号)。
  • 乱序:重试的 v3(6 号)排在 v4(5 号)之后,而发送顺序是 v3 在前。

对照组把 enable.idempotence=true 打开,同样冻 5 秒、同样两个请求超时重试,消费结果是 12 条、顺序严整:

Offset:0	v0
Offset:1	v1
...
Offset:11	v11

幂等为什么能救

幂等生产者在 send() 阶段就给每个批次分配了 (producerId, epoch, baseSequence),broker 端按分区维护最近 5 个批次的序列号(源码注释:The value 5 is aligned with ProducerStateEntry#NUM_BATCHES_TO_RETAIN)。重试带了同样的序列号,broker 一看是见过的就回 DUPLICATE_SEQUENCE_NUMBER,Sender.java:700 那一支把它当成功处理,不重复写。所以幂等解决的是生产者重试导致的重复。

它同时解决乱序,因为序列号是严格递增且和发送顺序绑定的:broker 拒绝任何跳号的批次,Sender 侧的 shouldStopDrainBatchesForPartition(RecordAccumulator.java:826-853)也会在前一个批次未决时停止抽新批次。

这也解释了 max.in.flight 为什么被限制在 5:

// ProducerConfig.java:265-267
    // max.in.flight.requests.per.connection should be less than or equal to 5 when idempotence producer enabled to ensure message ordering
    // The value 5 is aligned with ProducerStateEntry#NUM_BATCHES_TO_RETAIN.
    private static final int MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION_FOR_IDEMPOTENCE = 5;

broker 只留 5 个批次的序列号。在途超过 5 个,最早的序列号可能已经被挤掉,去重就失效了。设成 6 会直接 ConfigException:max.in.flight.requests.per.connection must be set to at most 5。

参数与语义的对应表

配置影响什么源码依据
max.in.flight.requests.per.connection每连接未确认请求上限;=1 时启用 muteInFlightRequests.java:96-99、Sender.java:420
max.request.size一个 ProduceRequest 的批次总量上限RecordAccumulator.java:889
request.timeout.ms单个请求等响应的上限,超时即断连NetworkClient.java:630 起的 poll
retry.backoff.ms重试前等待,也影响 batchReady 的等待时长RecordAccumulator.java:612
delivery.timeout.ms入队到 ack 的总预算,到点标失败Sender.java:428-433
enable.idempotence重试去重 + 保序,要求 acks=all、in-flight≤5ProducerConfig.java:520-531

一条实践结论:除非你有明确理由,别关幂等,也别把 in-flight 调到 1。默认组合(幂等开、in-flight=5、acks=all)在 4.3.0 里既保序又不牺牲并发,是本机三组实验里唯一「有重试、有拆分、仍然零逆序」的配置。把 in-flight 调到 1 换来的是顺序,付出的是每个 broker 一次只能有一个请求在飞的吞吐代价——单分区场景下这个代价尤其明显。

回到 libraryd

libraryd-mq 里 IdempotentProducer 是从 NaiveProducer 改出来的,只加了一行 enable.idempotence=true。这个改动看起来小,实际同时修掉了两个问题:

一是借阅事件重试导致 library.loan.created 里出现两条 loanId 相同的事件;二是同一本书的借阅/归还事件在重试后顺序颠倒,下游状态机看到「先还后借」。

但幂等只保护「Kafka 客户端到 broker」这一段;如果下游消费者是「读到就写库」,库里仍然可能重复——那是消费侧位移与业务去重的问题,卷二展开。还有一个只在生产环境才会遇到的坑:幂等要求 acks=all。有人为了降低延迟把 acks 改成 1,enable.idempotence 会被静默关掉(ProducerConfig.java:602-609 的逻辑:用户没显式配幂等时,冲突配置会让它降级为 false,只打一条 INFO 日志)。于是「我们开了幂等」这句话在配置层面成立,在运行期不成立。查这个问题要看生产者启动日志里的 enable.idempotence = false,而不是看配置文件。

阅读导航:上一节 7.2 RecordAccumulator 与批次缓存 | 下一节 8.1 KafkaConsumer 的两套实现

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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