为什么要单独讲 batch 发送条件
前面讲 Sender 线程时提到,Sender 每轮调用accumulator.ready()获取有可发送批次的 Broker 集合,然后drain()拉取。但ready()到底怎么判断一个 batch “可以发了”?这个判断逻辑直接决定了延迟和吞吐的平衡。
很多人对 batch 发送有误解:以为调了batch.size=32KB就一定会攒到 32KB 才发,以为linger.ms=0就是每条立即发。这篇把这些误解全部澄清。
01 ProducerBatch 的状态机
一个 batch 从创建到完成,经历 5 个状态:
- EMPTY:刚创建,还没有任何消息。从 BufferPool 拿到 16KB 内存块,构造 MemoryRecordsBuilder。
- FILLING:业务线程正在 append。这是 batch 停留时间最长的状态,也是攒批发生的阶段。
- READY:满足发送条件,可以被 Sender drain。注意 READY 只是"可以发",不是"已经发"。
- DRAINED:Sender 已经把它从 Deque 里取出来,构建成 ProduceRequest 发出去了,等 Broker ACK。
- EXPIRED:超过 delivery.timeout.ms 还没成功,放弃发送,回调异常。
状态转换的核心是 FILLING → READY,这一步有 5 个触发条件,任一满足即转换。
02 ready() 的 5 个触发条件
Sender 每轮调用accumulator.ready(nowMs),内部遍历每个分区的 Deque,取队头 batch 调用batch.ready(nowMs, lingerMs)判断。判断逻辑是一个短路或:
publicbooleanready(longnowMs,longlingerMs){returnisFull()||// 条件1:写满(nowMs-lastAppendTime>=lingerMs)||// 条件2:linger超时flushInProgress||// 条件3:flush中closed||// 条件4:已关闭metadataExpired;// 条件5:元数据过期}条件1:写满 batch.size。isFull()判断estimatedBytesWritten >= batch.size。注意是 estimated(估算值),包含消息体、记录头、CRC、压缩开销,不是纯消息体字节数。所以实际写满时可能略大于 batch.size。
条件2:linger.ms 超时。nowMs - lastAppendTime >= lingerMs。距离最后一次 append 的时间超过 linger.ms。默认 linger.ms=0,所以这个条件在每次 append 后立即满足——但这只是说 batch 变 READY 了,真正发送还要等 Sender 下一轮。
条件3:flushInProgress。业务线程调用producer.flush()时设置这个标志,所有 batch 立即变 READY,绕过 batch.size 和 linger.ms。
条件4:closed。producer.close()时设置,剩余数据强制发送。
条件5:metadataExpired。分区 Leader 变化(如 Broker 宕机触发重选举),旧 batch 发往的 Leader 可能已经不是 Leader 了,需要重新路由。
03 Sender 检查 ready 的完整链路
从 Sender 线程的 runOnce 到真正发出去,完整链路是四步:
- runOnce(nowMs):获取当前时间戳。
- ready(nowMs):遍历所有分区 Deque,检查队头 batch 是否 ready。返回
readyNodes(有 ready batch 的 Broker 集合)和nextReadyCheckDelayMs(下一个 batch 多久后会 ready,用于计算 poll 超时避免空转)。 - drain(readyNodes):只遍历 readyNodes 里的 Broker,按节点拉取 ready batch。检查
in-flight < max.in.flight,累计大小 ≤max.request.size。 - sendProduceRequests:每个 Broker 的批次列表构建一个 ProduceRequest,调用 NetworkClient.send()。
关键细节:ready() 只检查队头 batch。因为 Deque 是 FIFO,队头最老,如果队头都不 ready,后面的更不 ready。队头 ready 了才会继续检查队头下一个(可能多个 batch 同时 ready)。
04 常见的 5 个坑
坑1:linger.ms=0 就以为是"每条立即发"。linger.ms=0 只表示 ready 条件立即满足,但真正发送要等 Sender 下一轮 runOnce。Sender 轮询间隔通常 1-5ms(取决于 poll 超时),所以实际延迟是几 ms 不是 0。要真正低延迟,用flush()主动触发,但会牺牲吞吐。
坑2:只调 batch.size 不调 linger.ms。小流量场景 batch 永远写不满,linger.ms=0 导致每条都发,batch.size 白调。高吞吐场景必须配合:batch.size=32KB + linger.ms=5-10ms。batch.size 是"攒到多大",linger.ms 是"最多等多久",哪个先到触发哪个。
坑3:ready 了就以为一定发出去了。ready 只是"可以被 drain",还要等 Sender 下一轮 + 该节点 in-flight < max.in.flight。如果 Broker 慢导致 in-flight 满了,ready 的 batch 会继续等,可能超过 delivery.timeout.ms 变成 EXPIRED。监控record-send-rate和request-latency-avg,in-flight 满了说明 Broker 是瓶颈。
坑4:flush() 频繁调用导致攒批失效。flush() 把所有 batch 标记 ready,绕过 batch.size 和 linger.ms。每条消息后都 flush = 退化成单条发送,吞吐暴跌 10 倍以上。flush 只用于"必须立即发"的场景(关闭前、事务提交前),不要每条都调。
坑5:batch.size 设太大导致单条消息塞不进去。batch.size 是单个批次上限,但单条消息不能超过 batch.size——否则会创建一个只含这一条消息的大 batch。如果单条消息 > max.request.size,直接抛 RecordTooLargeException。大消息场景:batch.size ≥ 单条消息大小,max.request.size ≥ batch.size,Broker 端 message.max.bytes 同步调大。
写在最后
batch 发送条件的本质是一个**"或"逻辑**:写满、超时、flush、close、元数据过期,任一满足即 ready。理解了这 5 个条件,就能解释很多"为什么我的消息没立即发出去"的问题。
调优的核心是在 batch.size(吞吐)和 linger.ms(延迟)之间找平衡:高吞吐场景两个都调大,低延迟场景 linger.ms=0 + 小 batch.size,不要只调一个。