摘要
线上订单推送链路出现大量 BufferExhaustedException: Available memory: 0,Disruptor 订单工作线程长时间阻塞,最终定位根因并非单纯缓冲区打满,而是一处极易踩坑的Sender 线程自阻塞(Self-Deadlock,自死锁)。
重点:这不是多线程之间互相等待的传统 JVM 锁死锁;是同一个 Kafka Sender IO 线程,等待只有自己才能执行完成的任务,永久卡住自身。
一、问题原始代码
java
public void doOrderPush(CoOrder order, ContractConfig config) {
PushMessage orderPushMessage = buildOrderPushMessage(order, config);
String json = JsonUtils.encodeQuietly(orderPushMessage);
Integer partitionId = PushBusinessKafkaEnum.ORDER_PUSH.getPartitionId();
kafkaTemplate.send(orderPushKafkaTopic, partitionId, String.valueOf(orderPushMessage.getEventId()), json)
.whenComplete((result, ex) -> {
if (ex != null) {
// 异常触发重试
log.error("订单消息发送失败, partitionId {}, value {} ", partitionId, json);
retrySend(partitionId, String.valueOf(orderPushMessage.getEventId()), json);
}
});
}
/**
* 手动同步重试逻辑
*/
private void retrySend(Integer partitionId, String key, String value) {
for (int i = 0; i < 3; i++) {
try {
// .get() 同步阻塞等待发送结果
kafkaTemplate.send(orderPushKafkaTopic, partitionId, key, value).get();
log.info("订单消息重试成功, partitionId {}", partitionId);
return;
} catch (Exception e) {
log.warn("订单消息重试第{}次失败, partitionId {}", i + 1, partitionId, e);
}
}
}
表面逻辑看起来合理:消息发送异步回调,失败手动循环重试;很多业务同学都会写出类似代码,上线平稳一段时间,流量波动、Broker 抖动后集中爆发故障。
二、核心前置知识:Kafka Producer IO 线程全解析
2.1 哪些回调方法运行在 Kafka IO 线程?
kafkaTemplate.send() 返回的是 ListenableFuture,其无指定自定义线程池的回调方法,全部默认运行在 Kafka Sender IO 线程,也是本次故障的核心诱因。
绑定 IO 线程的高危方法(禁止内部写阻塞逻辑):
-
whenComplete(BiConsumer):无重载线程池参数时,回调执行于 Sender IO 线程 -
addCallback(ListenableFutureCallback):经典回调写法,默认绑定 IO 线程 -
successCallback/failureCallback单例回调:无自定义 executor 时,均由 IO 线程执行
唯一安全写法:使用带 Executor 重载的方法,手动指定业务线程池执行回调,脱离 IO 线程绑定:
java
// 安全写法:回调交由自定义线程池,不占用IO线程
kafkaTemplate.send(...).whenCompleteAsync(result -> {}, ex -> {}, customExecutor);
核心原理:Kafka 生产者完成网络收发、拿到 Broker 响应后,会在当前 IO 线程内回调 Future 监听;只有手动指定异步线程池,才会脱离 IO 线程执行回调逻辑。
2.2 Kafka IO 线程数量:默认值与规则
Kafka Producer 的 IO 线程(Sender 线程)数量由生产者配置参数 num.io.threads 控制,核心参数明细:
-
默认值 :
1(绝大多数 SpringBoot 项目默认单 IO 线程) -
线程命名 :固定前缀
kafka-producer-network-thread -
核心特性:单线程串行执行所有消息的批次组装、网络发送、响应处理、回调执行,无并发能力
这也是本次故障会直接卡死链路的关键:全局仅有 1 个 IO 工作线程,一旦该线程阻塞,整个生产者的消息发送、回调处理逻辑完全停滞,无备用线程兜底。
2.3 IO 线程数量如何自定义配置?
Spring Boot 项目可通过配置文件、Java 配置类两种方式修改 IO 线程数,适配业务吞吐需求:
方式1:yml/properties 配置(推荐)
yaml
spring:
kafka:
producer:
# 自定义 Kafka IO 线程数,高吞吐场景可调整为2-4,不建议过大
num-io-threads: 2
方式2:Java 配置类全局设置
java
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configs = new HashMap<>();
configs.put(ProducerConfig.NUM_IO_THREADS_CONFIG, 2);
// 其他生产者配置...
return new DefaultKafkaProducerFactory<>(configs);
}
配置建议:常规业务保持默认 1 即可;高吞吐、多分区、大流量场景可提升至 2-4,无需过度调大,避免线程竞争开销。
2.4 两大核心线程分工(彻底厘清链路)
-
业务线程(Disruptor 工作线程) :调用
kafkaTemplate.send(),仅负责将消息写入RecordAccumulator32 MB 缓冲区,立即返回,不阻塞网络逻辑; -
Kafka Sender IO 线程(唯一核心):独立后台线程,全权负责缓冲区消息拉取、批次封装、网络发送、Broker 响应接收、回调方法执行,整个流程单线程串行执行。
同时明确 Future.get() 语义:阻塞当前执行线程,直到消息完成全流程收发、拿到 Broker 响应才释放阻塞。
三、自死锁完整链路推演
java
"kafka-producer-network-thread | futures-kepler-producer-1" #507 daemon prio=5 os_prio=0 cpu=20333.16ms elapsed=23493111.86s tid=0x00007f56780d0f30 nid=0x205 waiting on condition [0x00007f562e1df000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@17.0.2/Native Method)
- parking to wait for <0x00001000161e05f0> (a java.util.concurrent.CompletableFuture$Signaller)
at java.util.concurrent.locks.LockSupport.park(java.base@17.0.2/LockSupport.java:211)
at java.util.concurrent.CompletableFuture$Signaller.block(java.base@17.0.2/CompletableFuture.java:1864)
at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@17.0.2/ForkJoinPool.java:3463)
at java.util.concurrent.ForkJoinPool.managedBlock(java.base@17.0.2/ForkJoinPool.java:3434)
at java.util.concurrent.CompletableFuture.waitingGet(java.base@17.0.2/CompletableFuture.java:1898)
at java.util.concurrent.CompletableFuture.get(java.base@17.0.2/CompletableFuture.java:2072)
at com.superatomfin.futures.kepler.service.publisher.OrderPushRelayer.retrySend(OrderPushRelayer.java:68)
at com.superatomfin.futures.kepler.service.publisher.OrderPushRelayer.lambda$doOrderPush$0(OrderPushRelayer.java:56)
at com.superatomfin.futures.kepler.service.publisher.OrderPushRelayer$$Lambda$3419/0x0000000801ab6690.accept(Unknown Source)
at java.util.concurrent.CompletableFuture.uniWhenComplete(java.base@17.0.2/CompletableFuture.java:863)
at java.util.concurrent.CompletableFuture$UniWhenComplete.tryFire(java.base@17.0.2/CompletableFuture.java:841)
at java.util.concurrent.CompletableFuture.postComplete(java.base@17.0.2/CompletableFuture.java:510)
at java.util.concurrent.CompletableFuture.completeExceptionally(java.base@17.0.2/CompletableFuture.java:2162)
at org.springframework.kafka.core.KafkaTemplate.lambda$buildCallback$9(KafkaTemplate.java:891)
at org.springframework.kafka.core.KafkaTemplate$$Lambda$3412/0x0000000801aaf4e0.onCompletion(Unknown Source)
at org.springframework.kafka.core.DefaultKafkaProducerFactory$CloseSafeProducer$1.onCompletion(DefaultKafkaProducerFactory.java:1111)
at org.apache.kafka.clients.producer.KafkaProducer$AppendCallbacks.onCompletion(KafkaProducer.java:1567)
at org.apache.kafka.clients.producer.internals.ProducerBatch.completeFutureAndFireCallbacks(ProducerBatch.java:311)
at org.apache.kafka.clients.producer.internals.ProducerBatch.done(ProducerBatch.java:272)
at org.apache.kafka.clients.producer.internals.ProducerBatch.completeExceptionally(ProducerBatch.java:236)
at org.apache.kafka.clients.producer.internals.Sender.failBatch(Sender.java:829)
at org.apache.kafka.clients.producer.internals.Sender.failBatch(Sender.java:818)
at org.apache.kafka.clients.producer.internals.Sender.failBatch(Sender.java:770)
at org.apache.kafka.clients.producer.internals.Sender.completeBatch(Sender.java:702)
at org.apache.kafka.clients.producer.internals.Sender.lambda$null$2(Sender.java:627)
at org.apache.kafka.clients.producer.internals.Sender$$Lambda$3431/0x0000000801ab3430.accept(Unknown Source)
at java.util.ArrayList.forEach(java.base@17.0.2/ArrayList.java:1511)
at org.apache.kafka.clients.producer.internals.Sender.lambda$handleProduceResponse$3(Sender.java:612)
at org.apache.kafka.clients.producer.internals.Sender$$Lambda$3430/0x0000000801ab31f8.accept(Unknown Source)
at java.lang.Iterable.forEach(java.base@17.0.2/Iterable.java:75)
at org.apache.kafka.clients.producer.internals.Sender.handleProduceResponse(Sender.java:612)
at org.apache.kafka.clients.producer.internals.Sender.lambda$sendProduceRequest$9(Sender.java:916)
at org.apache.kafka.clients.producer.internals.Sender$$Lambda$3427/0x0000000801ab23d0.onComplete(Unknown Source)
at org.apache.kafka.clients.ClientResponse.onComplete(ClientResponse.java:154)
at org.apache.kafka.clients.NetworkClient.completeResponses(NetworkClient.java:618)
at org.apache.kafka.clients.NetworkClient.poll(NetworkClient.java:610)
at org.apache.kafka.clients.producer.internals.Sender.runOnce(Sender.java:348)
at org.apache.kafka.clients.producer.internals.Sender.run(Sender.java:250)
at java.lang.Thread.run(java.base@17.0.2/Thread.java:833)
当首次发送消息出现异常,进入 whenComplete 回调,死锁闭环彻底形成:
-
回调执行线程 = 唯一的 Sender IO 线程(无自定义 executor,默认源线程执行回调);
-
回调内部调用
retrySend(),再次执行send().get(); -
send()将重试消息写入 Accumulator,紧接着.get()让 IO 线程原地阻塞,等待本条重试消息发送完成; -
消息发送、缓冲区消费、网络请求的执行者,正是当前被阻塞的 IO 线程本身;
-
线程卡在阻塞逻辑,无法继续轮询缓冲区、处理消息发送,形成闭环等待。
✅ 最终结果:单线程自阻塞(Self-Deadlock),永久无法自行恢复
叠加循环重试逻辑:for (int i = 0; i < 3; i++) 反复触发阻塞,彻底钉死 IO 线程,完全丧失消息处理能力。
下面是自死锁完整链路的流程图,清晰展示了从首次发送失败到缓冲区打满的闭环过程:
#mermaid-svg-nOFDe03pTrFMUuAp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nOFDe03pTrFMUuAp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nOFDe03pTrFMUuAp .error-icon{fill:#552222;}#mermaid-svg-nOFDe03pTrFMUuAp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nOFDe03pTrFMUuAp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nOFDe03pTrFMUuAp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nOFDe03pTrFMUuAp .marker.cross{stroke:#333333;}#mermaid-svg-nOFDe03pTrFMUuAp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nOFDe03pTrFMUuAp p{margin:0;}#mermaid-svg-nOFDe03pTrFMUuAp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nOFDe03pTrFMUuAp .cluster-label text{fill:#333;}#mermaid-svg-nOFDe03pTrFMUuAp .cluster-label span{color:#333;}#mermaid-svg-nOFDe03pTrFMUuAp .cluster-label span p{background-color:transparent;}#mermaid-svg-nOFDe03pTrFMUuAp .label text,#mermaid-svg-nOFDe03pTrFMUuAp span{fill:#333;color:#333;}#mermaid-svg-nOFDe03pTrFMUuAp .node rect,#mermaid-svg-nOFDe03pTrFMUuAp .node circle,#mermaid-svg-nOFDe03pTrFMUuAp .node ellipse,#mermaid-svg-nOFDe03pTrFMUuAp .node polygon,#mermaid-svg-nOFDe03pTrFMUuAp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nOFDe03pTrFMUuAp .rough-node .label text,#mermaid-svg-nOFDe03pTrFMUuAp .node .label text,#mermaid-svg-nOFDe03pTrFMUuAp .image-shape .label,#mermaid-svg-nOFDe03pTrFMUuAp .icon-shape .label{text-anchor:middle;}#mermaid-svg-nOFDe03pTrFMUuAp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nOFDe03pTrFMUuAp .rough-node .label,#mermaid-svg-nOFDe03pTrFMUuAp .node .label,#mermaid-svg-nOFDe03pTrFMUuAp .image-shape .label,#mermaid-svg-nOFDe03pTrFMUuAp .icon-shape .label{text-align:center;}#mermaid-svg-nOFDe03pTrFMUuAp .node.clickable{cursor:pointer;}#mermaid-svg-nOFDe03pTrFMUuAp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nOFDe03pTrFMUuAp .arrowheadPath{fill:#333333;}#mermaid-svg-nOFDe03pTrFMUuAp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nOFDe03pTrFMUuAp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nOFDe03pTrFMUuAp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nOFDe03pTrFMUuAp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nOFDe03pTrFMUuAp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nOFDe03pTrFMUuAp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nOFDe03pTrFMUuAp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nOFDe03pTrFMUuAp .cluster text{fill:#333;}#mermaid-svg-nOFDe03pTrFMUuAp .cluster span{color:#333;}#mermaid-svg-nOFDe03pTrFMUuAp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nOFDe03pTrFMUuAp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nOFDe03pTrFMUuAp rect.text{fill:none;stroke-width:0;}#mermaid-svg-nOFDe03pTrFMUuAp .icon-shape,#mermaid-svg-nOFDe03pTrFMUuAp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nOFDe03pTrFMUuAp .icon-shape p,#mermaid-svg-nOFDe03pTrFMUuAp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nOFDe03pTrFMUuAp .icon-shape .label rect,#mermaid-svg-nOFDe03pTrFMUuAp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nOFDe03pTrFMUuAp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nOFDe03pTrFMUuAp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nOFDe03pTrFMUuAp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 成功
失败
业务线程调用 kafkaTemplate.send()
消息首次发送
✅ 正常流程结束
进入 whenComplete 回调
回调执行线程 = 唯一的 Sender IO 线程
回调内调用 retrySend()
执行 send().get() 同步阻塞
IO 线程等待消息发送完成
但消息发送的执行者正是当前被阻塞的 IO 线程
线程无法继续轮询缓冲区、处理消息发送
形成闭环等待:IO 线程等待自身执行的任务
✅ 单线程自阻塞(Self-Deadlock)
IO 线程完全停滞
不再消费 RecordAccumulator 缓冲区
缓冲区消息持续堆积,内存无法释放
业务线程持续调用 send() 申请内存
缓冲区可用内存为 0
抛出 BufferExhaustedException: Available memory: 0
四、连锁雪崩:从线程卡死到 BufferExhaustedException
Sender 线程停滞之后,雪崩链路清晰且不可逆:
-
唯一的 IO 线程卡死,不再消费
RecordAccumulator缓冲区消息; -
缓冲区消息持续堆积,占用内存无法释放,只进不出;
-
业务 Disruptor 核心工作线程持续调用
send(),尝试向已满的缓冲区申请内存; -
缓冲区可用内存为0,Producer 按照默认配置
max.block.ms=60 000,阻塞业务线程最长 60 秒; -
超时无空闲内存,抛出核心异常:
org.apache.kafka.common.errors.BufferExhaustedException: Available memory: 0。
额外风险变体:若缓冲区已打满,重试逻辑中的 send() 会在内存分配阶段直接阻塞 IO 线程 60 秒,无需执行到 .get() 即可卡死链路。
五、三大配置放大故障影响(诱因叠加)
本次故障并非单纯代码缺陷,三个不合理配置大幅放大故障范围、延长故障持续时间:
5.1 linger.ms=0 + 未开启压缩
消息无等待聚合机制,每条消息单独构建网络请求,小消息高频发送极易触发 Broker 抖动、发送失败,大幅提升进入重试分支的概率,加剧 IO 线程阻塞风险。
5.2 所有消息固定单 Partition 发送
业务代码硬编码固定分区:PushBusinessKafkaEnum.ORDER_PUSH.getPartitionId(),所有流量集中在 partition = 0,吞吐受限于单分区 Leader 节点 RTT 往返时延,无法分散压力,单点故障极易扩散。
5.3 max.block.ms 默认 60 秒
订单推送属于非核心辅助链路,却使用60秒超长阻塞时间,一旦缓冲区打满,会持续阻塞订单核心 Disruptor 工作线程,直接反压主业务流程,造成大面积业务卡顿。
六、错误修复方案避坑
很多常规修复思路存在隐性风险,无法彻底解决问题:
❌ 方案1:直接去掉.get()异步重试
无阻塞但会触发重试风暴,发送失败时无限异步重试,疯狂创建 Future 对象,导致内存持续飙升、线程资源耗尽。
❌ 方案 2:回调内新建线程重试
无线程池管控,故障爆发瞬间大量创建临时线程,引发线程溢出、CPU 飙升,仅能临时止血,属于不规范方案。
七、标准根治方案
7.1 优先方案:使用 Kafka 原生重试机制(推荐)
彻底抛弃业务手动重试逻辑,依托 Producer 底层成熟机制,规避线程阻塞问题:
1. 核心配置优化
yaml
spring:
kafka:
producer:
# 开启原生重试,替代业务手动重试
retries: 3
# 消息投递超时时间
delivery-timeout-ms: 120000
# 等待消息聚合20ms,批量发送,降低网络压力
linger-ms: 20
# 开启LZ4压缩,减小网络包体积,提升吞吐
compression-type: lz4
# 缩短阻塞时长,非核心链路不拖垮主业务
max-block-ms: 3000
# 适当调高IO线程数,提升并发处理能力
num-io-threads: 2
2. 修复后业务代码
java
kafkaTemplate.send(topic, partitionId, key, json)
.whenComplete((result, ex) -> {
if (ex != null) {
// 仅日志告警、监控打点,不再手动重试
log.error("订单消息推送失败,交由Kafka 原生重试机制处理", ex);
}
});
7.2 兜底方案:业务强制自定义重试
若业务有特殊补偿需求,必须手动重试,严格遵循铁律:IO 线程回调内禁止任何阻塞操作,重试任务交由独立业务线程池。
java
// 自定义独立线程池,专门处理消息补偿,隔离 Kafka IO 线程
private final ExecutorService kafkaCompensateExecutor = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadPoolExecutor.CallerRunsPolicy()
);
kafkaTemplate.send(orderPushKafkaTopic, partitionId, key, json)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("订单消息发送失败,提交独立线程池补偿", ex);
// 重试任务脱离IO线程,无自阻塞风险
kafkaCompensateExecutor.submit(() -> retrySend(partitionId, key, value));
}
});
八、核心知识点总结
8.1 两种死锁核心区别
-
传统死锁:多线程互相持有对方资源、互相等待,JVM 锁级别死锁;
-
Kafka 自死锁:单 IO 线程阻塞等待自身执行的任务,属于业务编码 + 线程模型认知缺陷导致的专属死锁。
8.2 Kafka IO 线程核心禁忌
所有无自定义线程池的 send 回调(whenComplete/addCallback),均运行在单例 IO 线程,内部绝对禁止:get() 阻塞、同步 IO、锁等待、sleep 等阻塞操作。
8.3 IO 线程关键配置复盘
-
线程数量:默认 1 个,由
num-io-threads参数配置; -
高危方法:
whenComplete、addCallback无异步线程池重载方法; -
核心特性:单线程串行作业,一旦阻塞,全局消息发送停滞。
九、线上自检排查清单
-
核查所有 Kafka send 回调,是否存在阻塞代码、手动同步重试逻辑;
-
确认 IO 线程数配置,高吞吐场景是否合理调优;
-
检查
linger.ms、压缩、max.block.ms配置,避免非核心链路反压主业务; -
禁止单分区承载全量流量,规避单点吞吐瓶颈;
-
所有消息重试优先使用 Kafka 原生机制,杜绝业务手动同步重试。