第26章 Kafka 生产消费异常

3 阅读4分钟

第26章 Kafka 生产消费异常

26.1 生产者常见异常

异常触发场景
org.apache.kafka.common.errors.TimeoutExceptionsend() 在指定时间内未能拿到 Broker 确认(request.timeout.ms),常见于网络延迟或 Broker 负载过高
RecordTooLargeException单条消息大小超过 max.request.size(默认 1MB)
NotLeaderOrFollowerException(旧版本叫 NotLeaderForPartitionException客户端持有的分区 leader 信息已过期(比如刚发生了 leader 选举切换),需要刷新元数据重试
SerializationException配置的 Serializer 无法正确序列化消息(比如用 StringSerializer 却传入了非 String 对象)

生产者异步发送的异常处理容易被遗漏

// 错误示例:只用了 send() 的返回值 Future,但没有处理异常
producer.send(record); // 如果发送失败,异常会被"吞掉",因为没有调用 get() 也没有传入回调

// 正确方式1:传入回调,在回调里显式处理异常
producer.send(record, (metadata, exception) -> {
    if (exception != null) {
        log.error("消息发送失败: topic={}", record.topic(), exception);
        // 根据业务需要做重试或告警
    }
});

// 正确方式2:同步等待(会阻塞,牺牲吞吐量换取确定性,适合对可靠性要求极高的场景)
try {
    producer.send(record).get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
    log.error("发送失败", e.getCause());
} catch (TimeoutException e) {
    log.error("发送超时", e);
}

KafkaProducer.send() 之所以要专门强调这一点,是因为它的默认调用方式(不传回调、不调用 get()在编译和运行层面都不会有任何提示——异步发送失败的异常会在内部被记录到 Future 里,但如果调用方压根不去检查这个 Future,异常就永远不会被感知到,这是 Kafka 生产者使用中最常见、也最隐蔽的一类"消息丢失却无人知晓"的问题根源。

26.2 消费者常见异常

异常触发场景
CommitFailedException消费者在两次 poll() 之间处理消息耗时超过 max.poll.interval.ms,被判定为"假死",触发 Rebalance,此时再提交 offset 会失败
WakeupException主动调用 consumer.wakeup() 中断正在阻塞的 poll() 调用,通常用于优雅关闭消费者时中断阻塞
SerializationException反序列化消息体失败,常见于生产者和消费者的消息格式(schema)不一致

CommitFailedException 的根本原因和解决方向:

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        processSlowly(record); // 如果这里的处理逻辑非常耗时(比如同步调用了一个慢下游服务)
    }
    consumer.commitSync(); // 如果上面处理总耗时超过 max.poll.interval.ms,这里提交会失败
}

根本原因:Kafka 消费者组的 Rebalance 机制依赖消费者"及时地"调用 poll() 来证明自己还活着(这是一种基于"处理速度"而非"心跳"的存活判断,和很多人直觉理解的"只要建立了 TCP 连接就算活着"不同)。如果单次 poll() 之后的消息处理逻辑耗时过长,Kafka 会认为这个消费者已经"卡死",将其踢出消费者组并触发 Rebalance(把它负责的分区分配给组内其他消费者),此时原来的消费者再尝试提交 offset,会因为自己已经不再是这个分区的合法消费者而失败。

解决方案:

// 方案1:调大 max.poll.interval.ms,给业务处理留足够时间(治标,本质问题(处理慢)依然存在)
max.poll.interval.ms=600000

// 方案2(推荐):把耗时的业务处理逻辑异步化,poll() 循环本身只负责快速拉取和分发,
// 真正耗时的处理交给独立的线程池,避免阻塞 poll() 循环
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        executorService.submit(() -> processSlowly(record)); // 异步处理,不阻塞下一次 poll
    }
    consumer.commitSync();
}
// 需要注意:异步方案下 offset 提交的时机需要额外设计(要等异步任务真正处理完才能提交对应 offset,
// 否则会有"offset 已提交但消息实际还没处理完就发生了消费者崩溃"导致的消息丢失风险)

// 方案3:减小 max.poll.records,每次 poll 拉取的消息数量减少,缩短单次处理批次的总耗时
max.poll.records=10

26.3 消息积压的诊断思路(不是单一异常,而是一类现象)

消息积压本身不直接对应某个 Java 异常,但排查时经常牵涉到本章讨论的异常类型作为线索:

# 查看消费者组的 lag(积压量)
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-group

排查方向: 如果日志里频繁出现 CommitFailedException 或 Rebalance 相关日志(Attempt to heartbeat failed),说明消费能力跟不上,正是导致积压的直接原因,应该优先按 26.2 节的方案排查消费逻辑是否过慢,而不是简单地增加消费者实例数量(如果处理逻辑本身有阻塞式的慢调用,增加再多消费者实例,单个消费者依然会持续触发 Rebalance)。