摘要:用了这么久 KafkaProducer,你真的清楚 send() 到底什么时候抛异常、什么时候走回调?为什么有些错误业务代码永远 catch 不到?这篇从源码级别梳理 Producer 端六类异常的产生、分类、传递路径和重试决策。
关键词:KafkaProducer、异常处理、RetriableException、重试机制、TimeoutException、Callback
开头:一个真实的线上问题
有个业务同学问过我:代码里 send() 外面包了 try-catch,为什么消息发送失败了异常却没被捕获?后来查日志才发现------异常是在 Callback 里抛的,不是在 send() 调用线程。
这个问题背后其实是 KafkaProducer 异常处理体系的核心:异常在哪一步产生,决定了它通过什么通道传给业务方。这篇把这个问题讲透。
01 六类异常全景

Producer 从 send() 到 broker 响应,异常分六大类。关键不是记住每个异常名,而是搞清楚两个维度:同步还是异步 、可重试还是不可重试。
同步异常在 send() 方法内部抛出,消息还没进 RecordAccumulator:
java
// KafkaProducer.send() 源码关键片段
public Future<RecordMetadata> send(ProducerRecord<K,V> record, Callback callback) {
// 1. 序列化(同步抛 SerializationException)
byte[] serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
byte[] serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
// 2. 选分区(同步抛 Partitioner 异常)
int partition = partitioner.partition(record.topic(), record.key(), serializedKey,
record.value(), serializedValue, cluster);
// 3. 追加到 accumulator(同步抛 BufferExhaustedException)
RecordAccumulator.RecordAppendResult result = accumulator.append(
record.topic(), partition, timestamp, serializedKey, serializedValue, ...);
// 4. 唤醒 sender 线程
if (result.batchIsFull || result.newBatchCreated)
this.sender.wakeup();
return result.future; // ← 到这里才返回 future
}
看这段就明白:序列化、选分区、内存不足这三类异常,发生在 return future 之前,所以是直接 throw,future 根本不存在。
02 同步异常 vs 异步异常

异步异常发生在消息已经进了 accumulator、sender 发出去之后。这时候 send() 已经返回了 future,业务方只能通过 callback 或 future.get() 感知:
java
// 异步异常如何到达业务方
// 1. Broker 返回错误码
ProduceResponse.PartitionResponse resp = response.responses().get(tp);
short errorCode = resp.errorCode;
// 2. 根据错误码判断是否可重试
if (errorCode == Errors.NONE.code()) {
batch.done(baseOffset, timestamp, null); // 成功
} else {
RuntimeException e = Errors.forCode(errorCode).exception();
if (e instanceof RetriableException) {
// 可重试:不 done,放回队列
} else {
batch.done(-1, -1, e); // 不可重试:通知业务方
}
}
业务代码必须同时处理两条路径:
java
// 正确写法:同时 catch send() 异常和 callback 异常
try {
producer.send(record, (metadata, exception) -> {
if (exception != null) {
// 异步异常在这里
log.error("async send failed", exception);
metrics.incrementError();
}
}).get(3, TimeUnit.SECONDS); // 同步等待 future 也可能抛
} catch (SerializationException | BufferExhaustedException e) {
// 同步异常在这里
log.error("sync send failed", e);
}
只 catch send() 的异常,会漏掉异步失败;只写 callback,会漏掉序列化和内存不足。
03 重试决策与退避策略

可重试异常的判断逻辑很简单------是不是 RetriableException 子类。但重试有两个上限:
java
// Sender.run() 中重试判断的关键逻辑
// 条件一:重试次数未超限
// 条件二:未超过 request.timeout.ms
// 条件三:未超过 delivery.timeout.ms
boolean retriable = exception instanceof RetriableException
&& (attempts < retries)
&& (now - createdTime < deliveryTimeoutMs);
if (retriable) {
// 放回 deque 头部,下次 poll 重发
deque.putFirst(batch);
// 触发元数据刷新(可能是 leader 切换)
metadata.requestUpdate();
// 等待退避时间
time.sleep(retryBackoffMs * (1 << attempt)); // 指数退避
} else {
// 转失败,通知业务方
batch.done(-1, -1, exception);
}
三个超时参数别搞混:
request.timeout.ms(默认 30s):单次请求等待 broker 响应的时间retry.backoff.ms(默认 100ms):两次重试之间的等待时间delivery.timeout.ms(默认 120s):从消息创建到最终成功/失败的总时间上限
delivery.timeout.ms 是最终兜底------即使 retries 没超限,超过这个时间也一定失败。这是防止无限重试的最后一道防线。
04 连接断开时的异常处理
网络层异常有个特殊情况:连接断开时,该节点上所有 InFlight 的请求都会失败。这时候不是一条一条处理,而是批量失败:
java
// NetworkClient.handleDisconnections()
private void handleDisconnections() {
for (String nodeId : selector.disconnected()) {
// 该节点所有未完成的请求全部失败
List<ClientRequest> requests = inFlightRequests.dequeueAll(nodeId);
for (ClientRequest req : requests) {
RuntimeException e = new DisconnectException(
"Connection to " + nodeId + " lost.");
req.callback().onComplete(new ClientResponse(req, e));
}
}
}
这个设计保证了:即使 broker 突然宕机,已经发出去但还没收到响应的消息,不会永远挂在那里占内存------批量失败、批量通知、批量释放。
写在最后
KafkaProducer 的异常处理体系核心就一句话:异常在哪一步产生,就走哪条通道。同步异常直接 throw,异步异常走 callback/future,可重试异常自动退避重试,不可重试异常立即通知。
线上排查发送问题时,先看异常类型:如果是同步异常,查序列化器和内存;如果是异步异常,查 broker 端错误码;如果重试很多次才失败,查网络和 leader 切换。