Kafka源码深度剖析:消息有异常是如何处理的

摘要:用了这么久 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 切换。

相关推荐
大大大大晴天1 小时前
每天认识一个组件:ClickHouse
大数据
zwjapple3 小时前
skill总结
大数据·elasticsearch·搜索引擎
百年江湖随意沉浮3 小时前
DATA AI · 数据可视化
大数据
SelectDB3 小时前
实时更新排错现场:导入了却查到旧数据,和 FINAL 越加越慢的两条路
大数据·数据库·数据分析
SelectDB4 小时前
拿 Planet 的账单一行行对下来,我总结出三个可自查的校验点
大数据·数据库·数据分析
SelectDB4 小时前
Apache Doris + Vane:让多模态数据进入业务分析
大数据·数据库·数据分析
考拉讲支付4 小时前
延迟分账 / 核销分账的技术实现方案:服务完成后再分钱,到底怎么设计?
大数据·运维·人工智能·微信小程序·小程序·系统架构
大大大大晴天️4 小时前
Agent Loop 深度解析:理解、规划、行动、观察与纠错
大数据·ai-agent
ADAI_Bowen4 小时前
写文章 - 知乎
大数据·运维·人工智能