《Java 消息中间件·Kafka 内核与客户端》7.2 RecordAccumulator 与批次缓存

每个分区一条双端队列、每个批次占满 16 KB 缓冲、整块内存由 BufferPool 统一分配。本节读 4.3.0 的 append、ready 与 allocate,实测 32 KB 缓冲池被打满后 send() 阻塞 5 秒,并纠正一个常见说法:异常不是从 send() 抛出的,而是从 Future 里拿到的。

累积器不是一条队列

「生产者缓冲区」这个说法容易让人以为里面是一条大队列,消息先进先出,I/O 线程从队头取。

实际结构是每个分区一条双端队列,队列里放的是已经攒好的批次。4.3.0 的字段定义很明确:

// RecordAccumulator.java:84
    private final ConcurrentMap<String /*topic*/, TopicInfo> topicInfoMap = new CopyOnWriteMap<>();
// RecordAccumulator.java:1283-1290
    private static class TopicInfo {
        public final ConcurrentMap<Integer /*partition*/, Deque<ProducerBatch>> batches = new CopyOnWriteMap<>();
        public final BuiltInPartitioner builtInPartitioner;

        public TopicInfo(BuiltInPartitioner builtInPartitioner) {
            this.builtInPartitioner = builtInPartitioner;
        }
    }

两层 Map 而不是一层:外层按 topic 分组,内层按分区号索引。这个形状和 3.x 的 Map<TopicPartition, Deque<ProducerBatch>> 不一样——4.x 把分区队列挂到 TopicInfo 下面,是为了让「同一个 topic 的所有分区队列长度」能在一次遍历里拿到,供自适应分区器算负载。

顺带一个对并发很关键的注释,就在字段声明附近:

// RecordAccumulator.java:87-88
    // The following variables are only accessed by the sender thread, so we don't need to protect them.
    private final Set<TopicPartition> muted;

muted、nodesDrainIndex 这些只被 I/O 线程碰,所以连 volatile 都不给。而 batches 里的 deque 是应用线程和 I/O 线程都会碰的,所以每次访问都要 synchronized (dq)。同步粒度就是「一条分区队列」,这也是为什么多个线程往不同分区发不会互相阻塞。

append 的三段式

append 是整个生产者里最热的路径。它的骨架是一个循环,循环体分三段:

// RecordAccumulator.java:312-338
                // check if we have an in-progress batch
                Deque<ProducerBatch> dq = topicInfo.batches.computeIfAbsent(effectivePartition, k -> new ArrayDeque<>());
                synchronized (dq) {
                    // After taking the lock, validate that the partition hasn't changed and retry.
                    if (partitionChanged(topic, topicInfo, partitionInfo, dq, nowMs, cluster))
                        continue;

                    RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
                    if (appendResult != null) {
                        // If queue has incomplete batches we disable switch (see comments in updatePartitionInfo).
                        boolean enableSwitch = allBatchesFull(dq);
                        topicInfo.builtInPartitioner.updatePartitionInfo(partitionInfo, appendResult.appendedBytes, cluster, enableSwitch);
                        return appendResult;
                    }
                }

                if (buffer == null) {
                    int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(
                            RecordBatch.CURRENT_MAGIC_VALUE, compression.type(), key, value, headers));
                    log.trace("Allocating a new {} byte message buffer for topic {} partition {} with remaining timeout {}ms", size, topic, effectivePartition, maxTimeToBlock);
                    // 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.
                    // NOTE: getting time may be expensive, so calling it under a lock
                    // should be avoided.
                    nowMs = time.milliseconds();
                }

三段分别是:

  1. 试着塞进队尾那个还没写满的批次(tryAppend)。塞得进去就直接返回,一次加锁、零分配。
  2. 塞不进去就申请缓冲(free.allocate)。注意这一步在 synchronized (dq) 之外——申请可能要阻塞几秒,绝不能拿着分区锁等,否则同分区的其他线程全被卡死。
  3. 拿着新缓冲建批次,再进一次锁,把批次 addLast 到队尾。

第二段里有个细节值钱:分配的 size 是 Math.max(batchSize, 这条记录自己的估算大小)。所以 batch.size 是下限不是上限——一条 1 MB 的记录会拿到 1 MB 的缓冲,而不是被切成 64 个 16 KB 批次。这也是「单条记录大小 vs batch.size」最容易搞混的地方。

还有 finally 块里那句 free.deallocate(buffer):如果申请了缓冲但中途因为分区变化 continue 了,缓冲会还回去。缓冲区泄漏是在这里被堵死的。

批次什么时候算「满」

isFull 的定义在 MemoryRecordsBuilder 里:

// MemoryRecordsBuilder.java:886-890
    public boolean isFull() {
        // note that the write limit is respected only after the first record is added which ensures we can always
        // create non-empty batches (this is used to disable batching when the producer's batch size is set to 0).
        return appendStream == CLOSED_STREAM || (this.numRecords > 0 && this.writeLimit <= estimatedBytesWritten());
    }

翻译成中文:已经写完(stream 关闭),或者至少有一条记录且估算字节数达到上限。

「至少一条记录」这个条件是给 batch.size=0 用的。配成 0 时每个批次只能装一条——这是「关掉攒批」的正确写法,而不是把 linger.ms 设成 0。linger.ms=0 只是「攒到就不等」,攒批本身还在。

batch.size 默认 16384 字节(ProducerConfig 文档里的默认值),指的是批次的估算字节上限,不是消息体字节数。批次头、每条记录的时间戳和长度前缀都算在里面,所以一个 16 KB 的批次实际能装的消息略少于 16 KB。

新批次建好之后,appendNewBatch 返回的标志位决定了要不要叫醒 I/O 线程:

// RecordAccumulator.java:398-401
        dq.addLast(batch);
        incomplete.add(batch);

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

dq.size() > 1 || batch.isFull() 就是 batchIsFull 的来源。队列里已经有一个批次了,说明前一个正在等发送,那这个新批次也该让 I/O 线程知道。7.1 节里那句 if (result.batchIsFull || result.newBatchCreated) sender.wakeup() 就是接这个信号。

ready():什么样的批次可以发

I/O 线程每一轮 runOnce() 都先问累积器:现在哪些节点有数据可发?

判定逻辑在 batchReady 里,五个条件满足任意一个就算「可发」:

// RecordAccumulator.java:608-623
    private long batchReady(boolean exhausted, TopicPartition part, Node leader,
                            long waitedTimeMs, boolean backingOff, int backoffAttempts,
                            boolean full, long nextReadyCheckDelayMs, Set<Node> readyNodes) {
        if (!readyNodes.contains(leader) && !isMuted(part)) {
            long timeToWaitMs = backingOff ? retryBackoff.backoff(backoffAttempts > 0 ? backoffAttempts - 1 : 0) : lingerMs;
            boolean expired = waitedTimeMs >= timeToWaitMs;
            boolean transactionCompleting = transactionManager != null && transactionManager.isCompleting();
            boolean sendable = full
                    || expired
                    || exhausted
                    || closed
                    || flushInProgress()
                    || transactionCompleting;
            if (sendable && !backingOff) {
                readyNodes.add(leader);
            } else {
条件含义
full队列里不止一个批次,或队头批次已经满了
expired队头批次已经等了 linger.ms
exhausted有人正卡在内存池上等缓冲
closed生产者已关闭
flushInProgress()有人调了 flush()
transactionCompleting事务正在提交

这六个里最容易被忽略的是 exhausted。它来自 partitionReady 开头的一行:

// RecordAccumulator.java:666
        boolean exhausted = this.free.queued() > 0;

含义是:只要有线程在内存池上排队,就把所有分区的 linger 全部作废,能发的立刻发。这是一条防死锁设计——如果一直等 linger,缓冲永远不还,等缓冲的线程就永远醒不来。后面实测里会看到这条规则真的救了场。

另外 waitedTimeMs >= timeToWaitMs 里的 timeToWaitMs 在退避期间不是 linger,而是 retryBackoff.backoff(...)。重试中的批次等的是退避时间,不是攒批时间。

BufferPool:16 KB 一格的内存池

BufferPool 是块状分配器,不是通用的 malloc。它的四个字段决定一切:

// BufferPool.java:49-55
    private final long totalMemory;
    private final int poolableSize;
    private final ReentrantLock lock;
    private final Deque<ByteBuffer> free;
    private final Deque<Condition> waiters;
    /** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize.  */
    private long nonPooledAvailableMemory;
  • totalMemory 来自 buffer.memory,默认 33554432(32 MiB)。
  • poolableSize 就是 batch.size。构造 BufferPool 时传的是同一个 batchSize(KafkaProducer.java:450)。
  • free 是复用队列:大小正好等于 poolableSize 的缓冲,释放时回到这里,不走 GC。
  • waiters 是等待队列,先进先出。

分配路径:

// BufferPool.java:127-134
            // now check if the request is immediately satisfiable with the
            // memory on hand or if we need to block
            int freeListSize = freeSize() * this.poolableSize;
            if (this.nonPooledAvailableMemory + freeListSize >= size) {
                // we have enough unallocated or pooled memory to immediately
                // satisfy the request, but need to allocate the buffer
                freeUp(size);
                this.nonPooledAvailableMemory -= size;

不够就阻塞,等别的批次释放:

// BufferPool.java:144-164
                    while (accumulated < size) {
                        long startWaitNs = time.nanoseconds();
                        long timeNs;
                        boolean waitingTimeElapsed;
                        try {
                            waitingTimeElapsed = !moreMemory.await(remainingTimeToBlockNs, TimeUnit.NANOSECONDS);
                        } finally {
                            long endWaitNs = time.nanoseconds();
                            timeNs = Math.max(0L, endWaitNs - startWaitNs);
                            recordWaitTime(timeNs);
                        }

                        if (this.closed)
                            throw new KafkaException("Producer closed while allocating memory");

                        if (waitingTimeElapsed) {
                            this.metrics.sensor("buffer-exhausted-records").record();
                            throw new BufferExhaustedException("Failed to allocate " + size + " bytes within the configured max blocking time "
                                + maxTimeToBlockMs + " ms. Total memory: " + totalMemory() + " bytes. Available memory: " + availableMemory()
                                + " bytes. Poolable size: " + poolableSize() + " bytes");
                        }

三个要点:

  1. 第一次等待用满 remainingTimeToBlockNs,超时直接抛。所以 max.block.ms 是总时长,循环里的多次等待共享这个预算(remainingTimeToBlockNs -= timeNs)。
  2. 抛的是 BufferExhaustedException,位置在 org.apache.kafka.clients.producer 包,不在 common.errors 里。它继承 TimeoutException,TimeoutException 继承 RetriableException,RetriableException 继承 ApiException——这条继承链决定了它最终会落到 7.1 节讲的那个 catch (ApiException e) 分支。
  3. size > totalMemory 在方法开头就直接 IllegalArgumentException(第 108-112 行),不进入等待。所以 batch.size 大于 buffer.memory 是配置错误,不是「等一等就好」。

释放路径同样有讲究:

// BufferPool.java:260-268
    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;
            }

只有「大小正好等于 poolableSize 且容量没被压缩改变」的缓冲才回池子。其它大小的缓冲(比如为一条超大记录单独申请的)直接还成可用计数,下次按需重新分配。所以「一批一条大消息」这种用法会把池子打成碎片化,bufferpool-wait-ratio 会明显上升。

实测:把池子调到 32 KB

设计如下:topic 8 个分区,buffer.memory=32768(32 KB)、batch.size=16384(16 KB)、linger.ms=10000,每条消息 100 字节,轮流发到 8 个分区。

预期:池子只够两格,第三个分区开始就得等。

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

SEND seq=0 partition=0 elapsedMs=17 futureDone=false
SEND seq=1 partition=1 elapsedMs=0 futureDone=false
SEND seq=2 partition=2 elapsedMs=5009 futureDone=true
SEND seq=3 partition=3 elapsedMs=5001 futureDone=true
SEND seq=4 partition=4 elapsedMs=8 futureDone=false
SEND seq=5 partition=5 elapsedMs=0 futureDone=false
SEND seq=6 partition=6 elapsedMs=5 futureDone=false
SEND seq=7 partition=7 elapsedMs=0 futureDone=false
SEND seq=8 partition=0 elapsedMs=5002 futureDone=true
SEND seq=9 partition=1 elapsedMs=5000 futureDone=true
SUMMARY lingerMs=10000 maxBlockMs=5000 bufferMemory=32768 okSends=24 maxSendMs=5011 totalSendMs=45087 thrown=none

24 次 send(),9 次卡满 5000 ms。max.block.ms=5000,elapsedMs=5009/5001/5002——精确到毫秒的吻合,说明卡的就是内存池那一次 await,没有别的来源。

注意 thrown=none:24 次调用里,一次异常都没抛出来。这是本节最需要澄清的一点。

异常不在 send() 上,在 Future 上

同一组参数,把 max.block.ms 调到 3000,然后对已经完成的 Future 调 get():

FUTURE-FAILED seq=4 p=4 elapsedMs=3013 type=org.apache.kafka.clients.producer.BufferExhaustedException
FUTURE-FAILED-MSG Failed to allocate 16384 bytes within the configured max blocking time 3000 ms. Total memory: 32768 bytes. Available memory: 0 bytes. Poolable size: 16384 bytes
FUTURE-FAILED seq=5 p=5 elapsedMs=3011 type=org.apache.kafka.clients.producer.BufferExhaustedException
FUTURE-FAILED-MSG Failed to allocate 16384 bytes within the configured max blocking time 3000 ms. Total memory: 32768 bytes. Available memory: 0 bytes. Poolable size: 16384 bytes

send() 阻塞了 3013 ms,然后正常返回一个 Future;这个 Future 已经处于失败状态,get() 时抛出 BufferExhaustedException。

原因在 7.1 节的源码里:BufferExhaustedException 是 ApiException 的子类,doSend 的 catch (ApiException e) 会把它包成 FutureFailure 返回(KafkaProducer.java:1049-1061)。

所以 3.1 节里那句「send() 也可能在 max.block.ms 到期后抛异常」需要修正为准确说法:

send() 会在缓冲不足时阻塞,最多 max.block.ms;超时后不抛异常,而是返回一个已失败的 Future。

这个区别在工程上不是文字游戏。写成 try { producer.send(r); } catch (TimeoutException e) { ... } 的代码永远进不了 catch,因为异常根本不到这里。要发现缓冲打满,只能:

  • 对 Future 加回调或在 get() 处捕获;
  • 看 buffer-exhausted-records 这个指标(就是 BufferPool.java:160 打点的那个 sensor);
  • 看 bufferpool-wait-ratio。

exhausted 真的会让 linger 失效

同一份程序里还有一条更微妙的记录。另一轮(max.block.ms=3000)的末尾:

SEND seq=7 p=7 elapsedMs=1006 future pending

seq=7 只等了 1006 ms 就拿到了缓冲,而 linger 配的是 10 秒。原因就是前面那行 boolean exhausted = this.free.queued() > 0:seq=4、5、6 卡在池子上之后,exhausted 变成 true,batchReady 立刻把 0、1、2、3 号分区标成可发,I/O 线程把在途批次发出去、缓冲归还,seq=7 于是拿到了一格。

这条路径说明:内存池紧张时,linger 会被自动放弃。反过来也成立——你为了攒大批次调大 linger,一旦缓冲不够,实际行为是「不等了,有什么发什么」。参数之间不是独立的,buffer.memory 会否决 linger.ms。

三组参数的对照

同一台机器,同样的 8 分区、24 次发送,只改 linger.ms:

配置最大单次 send() 耗时24 次总耗时结果
buffer.memory=32K, batch.size=16K, linger=10000, max.block=50005011 ms45087 ms9 次卡满后失败
buffer.memory=32K, batch.size=16K, linger=50, max.block=500061 ms404 ms全部成功

(单次运行,随调度波动,不作基准。本机 load average 常年在 70 以上。)

差距不是「linger 小所以快」这么简单。linger=50 ms 时批次很快可发,I/O 线程每 50 ms 就把缓冲还回来一轮,池子的周转率足够高,所以 send() 最多等一个 linger 周期。linger=10 s 时缓冲一旦被占住就是 10 秒起步,32 KB 的池子根本转不动。

结论:buffer.memory 要和 linger.ms、分区数一起算。

同时能容纳的批次数 ≈ buffer.memory / batch.size
每个分区至少要的批次数 = 1(一个在发,一个在攒,实际按 2 估)
可用分区数 ≈ (buffer.memory / batch.size) / 2

32 KB / 16 KB = 2 格,除以 2 只剩 1 个分区能正常周转——上面那个 8 分区的实验必然卡。默认的 32 MiB / 16 KB = 2048 格,够 1000 个分区各占两格。这条算式很粗,不含消息体大小和压缩,但足够判断「我这个配置是不是从一开始就不可能」。

max.block.ms 到底管几件事

max.block.ms 默认 60000,它管的是 send() 里所有「等外部条件」的时间,共享一个总预算:

阻塞点源码位置等什么
元数据KafkaProducer.java:988topic 的分区数与 leader
缓冲RecordAccumulator.java:333内存池里腾出一格

7.1 节讲过 remainingWaitMs 的算法:元数据花掉的时间会从缓冲预算里扣。所以一次 send() 的最坏耗时是 max.block.ms,不是它的两倍。

不在这个预算里的有:delivery.timeout.ms(默认 120000,管入队到 ack 的总时长)、request.timeout.ms(默认 30000,管单个请求)。三个超时的关系是包含关系,15.1 节讲监控时会再对一遍。

回到 libraryd

libraryd-mq 的 NaiveProducer 用的是默认配置:32 MiB 缓冲、16 KB 批次、5 ms linger。这个组合在 3 个分区、每秒几百条的负载下从来不会碰到池子的边界。

真正把它打爆的是「批量导入」这个场景:运维上传一个 CSV,一次灌 20 万条借阅记录,全部走同一个 producer。导入线程 send() 的速度远高于 broker 的接收速度,缓冲在几秒内被填满,然后 send() 开始按 max.block.ms=60000 阻塞。因为异常在 Future 上,代码里没有 get(),也没有回调检查,表现就是「导入卡住一分钟,然后什么都没发生」——消息丢了,日志里一行错误都没有。

修法有三层,从轻到重:

  1. 给 send() 挂回调,把失败计数打出来;
  2. 导入线程自己做限流,用 future.get() 或信号量把在途批次数压住;
  3. 单独给导入场景一个 producer,把 buffer.memory 和 max.block.ms 配成适合批量写的值,不要和在线流量共用。

第 2 条和第 3 条都建立在同一个认识上:限制并发的单位是批次,不是消息数。16 KB 一个批次,32 MiB 就是 2048 个批次——这个数才是这个生产者真正能同时承受的上限。

阅读导航:上一节 7.1 KafkaProducer 的线程模型 | 下一节 7.3 Sender 与 NetworkClient

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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