累积器不是一条队列
「生产者缓冲区」这个说法容易让人以为里面是一条大队列,消息先进先出,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();
}
三段分别是:
- 试着塞进队尾那个还没写满的批次(
tryAppend)。塞得进去就直接返回,一次加锁、零分配。 - 塞不进去就申请缓冲(
free.allocate)。注意这一步在synchronized (dq)之外——申请可能要阻塞几秒,绝不能拿着分区锁等,否则同分区的其他线程全被卡死。 - 拿着新缓冲建批次,再进一次锁,把批次
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");
}
三个要点:
- 第一次等待用满
remainingTimeToBlockNs,超时直接抛。所以max.block.ms是总时长,循环里的多次等待共享这个预算(remainingTimeToBlockNs -= timeNs)。 - 抛的是
BufferExhaustedException,位置在org.apache.kafka.clients.producer包,不在common.errors里。它继承TimeoutException,TimeoutException继承RetriableException,RetriableException继承ApiException——这条继承链决定了它最终会落到 7.1 节讲的那个catch (ApiException e)分支。 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=5000 | 5011 ms | 45087 ms | 9 次卡满后失败 |
buffer.memory=32K, batch.size=16K, linger=50, max.block=5000 | 61 ms | 404 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:988 | topic 的分区数与 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(),也没有回调检查,表现就是「导入卡住一分钟,然后什么都没发生」——消息丢了,日志里一行错误都没有。
修法有三层,从轻到重:
- 给
send()挂回调,把失败计数打出来; - 导入线程自己做限流,用
future.get()或信号量把在途批次数压住; - 单独给导入场景一个 producer,把
buffer.memory和max.block.ms配成适合批量写的值,不要和在线流量共用。
第 2 条和第 3 条都建立在同一个认识上:限制并发的单位是批次,不是消息数。16 KB 一个批次,32 MiB 就是 2048 个批次——这个数才是这个生产者真正能同时承受的上限。
阅读导航:上一节 7.1 KafkaProducer 的线程模型 | 下一节 7.3 Sender 与 NetworkClient
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。