一、业务背景:设备状态与甘特图的生死时速
在工厂的 IoT 场景中,我们实时采集设备的运行状态(运行、停机、故障等)。数据流向如下:
设备传感器 -> Kafka(原始状态) -> Spring Boot 实时计算 -> Kafka(明细数据) -> Spring Boot 聚合写入 -> Doris
核心业务逻辑
我们需要精确计算设备在每种状态下的持续时间,用于生成生产报表和设备利用率甘特图。
-
实时流处理 :消费设备状态变更数据,计算
状态开始时间到状态结束时间的差值。 -
兜底定时任务 :由于网络抖动或设备故障,可能存在"状态丢了"的情况。因此我们设定了一个 10分钟定时任务:
-
扫描明细表,如果某台设备超过10分钟没有新的状态数据流入。
-
自动插入一条"停机状态"数据,以确保甘特图不会显示设备一直在运行。
-
问题爆发
业务人员反馈:甘特图上显示的停机时间比实际长很多,甚至出现了连续的"幽灵停机"。
二、排查过程:从业务到技术的"鬼打墙"
第一阶段:怀疑数据计算逻辑
起初以为是定时任务写错了,导致误判设备停机。
-
排查 :检查定时任务的SQL逻辑,确认
NOW() - last_update_time > 10分钟的条件无误。 -
结果:逻辑正确,但数据入库的时间戳确实"断档"了。
第二阶段:怀疑Kafka(经典的"甩锅"环节)
既然数据断了,肯定是消息没消费。
-
现象:Kafka消费者每隔3分钟就停止消费,Lag开始堆积,1分钟后又恢复,周而复始。
-
操作:疯狂调整Kafka参数。
java
spring:
kafka:
consumer:
properties:
session.timeout.ms: 45000
max.poll.interval.ms: 300000
heartbeat.interval.ms: 3000
- 结果:无效。消费依然规律性地"睡一分钟"。
第三阶段:怀疑Zookeeper
难道是ZK选举导致协调器漂移?
-
排查 :检查ZK日志,
grep -c "LEADER ELECTION" zookeeper.out,结果是0。 -
结论:ZK非常稳定,排除。
第四阶段:怀疑GC和业务线程
是不是Full GC把线程卡住了?
-
排查 :
jstat -gcutil pid 1000,GC正常,没有长时间的Stop The World。 -
现象 :通过
jstack抓取线程栈,发现消费者线程大多处于WAITING (parking)状态,且卡在httpclient相关代码处。
第四阶段:怀疑GC和业务线程
是不是Full GC把线程卡住了?
-
排查 :
jstat -gcutil pid 1000,GC正常,没有长时间的Stop The World。 -
现象 :通过
jstack抓取线程栈,发现消费者线程大多处于WAITING (parking)状态,且卡在httpclient相关代码处。
三、根因定位:被遗忘的HttpClient连接池
我们的架构中,Spring Boot消费Kafka数据后,需要通过 Stream Load 方式写入 Doris。这里使用了 Apache CloseableHttpClient。
问题代码逻辑:
java
@KafkaListener(topics = "device-status")
public void consume(DeviceStatus status) {
// 1. 计算状态持续时间
calculateDuration(status);
// 2. 写入Doris
httpClient.execute(streamLoadRequest); // 阻塞点!
}
致命陷阱:HttpClient默认连接池配置
-
maxTotal: 20 (整个连接池最多20个连接) -
defaultMaxPerRoute: 2 (每个Doris FE节点最多2个连接)
在高并发设备数据涌入时发生了什么?
-
连接耗尽:设备数多,并发高。10个消费者线程同时写入Doris,很快占满了连接池(20个连接)。
-
线程阻塞 :第21个请求进来,
httpClient.execute()开始阻塞,等待获取连接。 -
心跳超时:由于消费者线程被阻塞,无法向Kafka Broker发送心跳(Heartbeat)。
-
重平衡(Rebalance):Broker认为消费者死掉了,踢出消费者组,触发Rebalance。
-
消费暂停:Rebalance期间,分区被回收,消费完全停止。这就是为什么业务看到"数据断档"。
-
定时任务误判 :因为消费停止,10分钟定时任务扫描时发现"设备10分钟没数据",错误地插入了一条停机数据。
-
恢复:几秒后,Doris写入完成,连接释放,消费者重新加入组,继续消费。
这就完美解释了 "消费一会停一会" 以及 **"甘特图停机时间不准"** 的现象。
四、解决方案:打破连接瓶颈
1. 紧急止血:调大连接池
既然20个不够,那就先调大。
java
@Configuration
public class HttpClientConfig {
@Bean
public CloseableHttpClient httpClient() {
PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager();
// 根据消费者并发数 * 2 调整
connManager.setMaxTotal(200);
// 根据Doris FE节点数调整
connManager.setDefaultMaxPerRoute(50);
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(5000)
.setSocketTimeout(30000)
.setConnectionRequestTimeout(2000) // 获取连接超时,避免无限期等
.build();
return HttpClients.custom()
.setConnectionManager(connManager)
.setDefaultRequestConfig(requestConfig)
.build();
}
}
调整效果:消费立刻恢复平滑,不再停顿。
2. 业务修复:校准甘特图
数据连续后,定时任务的误判消失。对于历史脏数据,我们通过离线任务进行了订正。
五、经验总结与避坑指南
1. 日志会骗人,线程栈不说谎
Kafka日志显示 Group coordinator is unavailable或 Rebalance,这只是结果 ,不是原因 。当遇到这种周期性停顿时,第一时间 jstack看线程在干嘛,往往能直接定位到阻塞点(数据库、锁、HTTP连接)。
2. AI是向导,不是司机
在排查过程中,AI助手给出了很多方向(调Kafka参数、检查ZK等),这在初期很有帮助。但是,具体是哪一行代码、哪一个配置导致的,必须开发者亲自下场,结合业务逻辑去测试、调试。AI无法替你跑生产环境的流量。
3. 高并发下,中间件的默认值往往是"坑"
Apache HttpClient 的默认最大连接数(20)和单路由连接数(2)是为"温和"的场景设计的。在微服务和高并发数据管道中,这绝对不够用。凡是涉及连接池(DB连接池、Redis连接池、HTTP连接池),上线前必须根据并发量重新评估。
4. 业务闭环验证
技术问题的解决不能止步于日志不报错。就像这次,必须回到业务端(甘特图)确认数据准确,才算真正的完结。
希望这篇复盘能帮助大家在遇到"间歇性消费停顿"时,绕过Kafka的迷雾,直击连接池的本质。