🚀 Kafka 批量消费 + 多线程处理 + DLQ + Prometheus 全流程实战总结
在实际系统开发中,随着业务复杂度的不断提升,简单的单条消费模式逐渐难以支撑高并发与高可靠性的要求。
为了提升系统整体性能和稳定性,引入批量消费、异步处理与实时监控体系,成为了必不可少的工程实践。
在最近的项目 —— rapid-crud-generator 中,针对 Kafka 消费链路,新增了以下关键能力:
- 🔁 Kafka 批量拉取(Batch Pull)+ 线程池并发处理:提升消息处理吞吐量,降低单条消费延迟。
- 🛡️ 异常隔离与失败保护:
- 🛠️ 针对数据校验类异常(ValidationException),单条消息失败不会中断整体消费流程,失败消息将记录并发送至
audit-log-failed
Topic; - 🎯 针对系统级异常(如数据库不可用、严重运行时错误),则中断当前批次处理,抛出 RuntimeException,触发 Kafka 重试机制,最终失败的批次消息进入 DLQ(Dead Letter Queue)。
- 🛠️ 针对数据校验类异常(ValidationException),单条消息失败不会中断整体消费流程,失败消息将记录并发送至
- ⏰ 批量处理超时检测与未完成任务预警:通过
CountDownLatch
限定子任务最大执行时间(30秒),在超时后自动记录并取消未完成任务,防止 Kafka 消费线程被挂死,提高整体系统稳定性。
本文将完整记录从问题发现到方案落地的全过程,希望能帮到也在做分布式高并发处理的你。
🛠️ 项目背景
rapid-crud-generator 是一个快速生成后端(Spring Boot)+ 前端(Angular)CRUD管理台的小型平台。
在用户提交 JSON Schema 生成项目时,系统自动异步发送审计日志到 Kafka,最终落地到:
- MongoDB(主存储,可靠性高)
- Elasticsearch(副存储,支持全文搜索)
每一次用户生成代码,都会自动产生一条审计日志消息。
🎯 遇到的问题
一开始,Kafka 消费端是单条消费的(一个消息处理完,ACK确认)。存在以下问题:
问题 | 影响 |
---|---|
消费速率低 | 每条消息独立处理,效率差 |
失败放大 | 任意一条异常,整个消费线程阻塞,无法继续 |
无法高并发扩展 | 消费速率上不去,堆积风险高 |
🔥 解决方案总览
为了解决上述问题,进行如下改动:
- ✅ Kafka 批量消费(每次拉取 100 条消息)
- ✅ 多线程异步处理(线程池控制并发)
- ✅ 异常捕获与隔离(validation失败单独收集,不影响其他)
- ✅ 超时检测 + 未完成任务警告
- ✅ DLQ 死信队列负责处理主线程失败异常消息
- ✅ audit-log-failed Topic 负责记录 MongoDB / Elasticsearch 保存失败的单条消息
- ✅ Prometheus 埋点成功率/失败率/耗时
- ✅ Future 超时取消(超时未完成子任务主动取消,防止资源堆积)
🧱 实现细节
1️⃣ Kafka 批量消费配置
factory.setBatchListener(true);
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
factory.setConcurrency(3); // 3个消费者线程
每次批量拉取 100 条消息(MAX_POLL_RECORDS_CONFIG=100
),手动提交 offset。
2️⃣ 多线程并发处理
批量到达后,使用 ThreadPoolTaskExecutor
提交到线程池,并通过 Future 控制子任务状态。
for (AuditLogEvent event : events) {
Future<?> future = consumerTaskExecutor.submit(() -> {
try {
saveToMongo(event);
saveToElasticSearchAsync(event);
} catch (Exception e) {
failedEvents.add(event);
} finally {
latch.countDown();
}
});
futureMap.put(event, future);
}
- 每个子任务通过
Future
进行生命周期管理 - 任务完成后
latch.countDown()
- 失败消息收集到
failedEvents
,后续处理
3️⃣ 超时检测与警告,子任务取消
boolean finished = latch.await(30, TimeUnit.SECONDS);
if (!finished) {
log.error("🚨 Timeout! Some tasks unfinished");
for (Map.Entry<AuditLogEvent, Future<?>> entry : futureMap.entrySet()) {
if (!entry.getValue().isDone()) {
entry.getValue().cancel(true); // 主动取消未完成子任务
failedEvents.add(entry.getKey()); // 记录到失败列表
}
}
}
- ✅ 超时后主动取消未完成的子任务,防止线程池资源无限堆积。
- ✅ 超时失败的任务仍然发到
audit-log-failed
Topic,保证失败追踪。
🎯 为什么要引入 Future 超时取消?
- 传统 latch 超时,只能释放主线程,后台子任务仍可能堆积
- 通过
Future.cancel(true)
,可以主动取消超时未完成的子任务 - 避免线程池堆积、内存膨胀、系统 OOM风险
📈 效果
- 保持线程池负载受控
- 限制单批处理时间
- 超时子任务可追踪、失败补偿
- 提升整体系统稳定性和可预期性
🧠 Kafka 批量消费 + Future 超时取消 时序图
sequenceDiagram
participant Kafka
participant Consumer
participant ThreadPool
participant MongoDB
participant Elasticsearch
participant FutureMap
participant AuditLogFailedTopic
participant DLQ
Kafka->>Consumer: 拉取一批消息 (List<AuditLogEvent>)
Consumer->>ThreadPool: 为每条消息提交Future任务
ThreadPool-->>FutureMap: 返回Future,记录到FutureMap
Consumer->>Consumer: latch.await(30秒)
alt 全部子任务完成
Consumer->>Kafka: ack确认消费
else 有子任务超时未完成
Consumer->>FutureMap: 遍历futureMap取消未完成任务
Consumer->>AuditLogFailedTopic: 将超时任务发到audit-log-failed Topic
Consumer->>Kafka: ack确认消费
end
alt 任务执行中出现ValidationException
ThreadPool->>AuditLogFailedTopic: 异步发送校验失败的消息
end
alt 任务执行中出现系统级异常 (如数据库不可用)
Consumer->>Kafka: 抛出RuntimeException
Kafka->>DLQ: 经过最大重试后,进入Dead Letter Queue
end
ThreadPool->>MongoDB: 保存到Mongo
ThreadPool->>Elasticsearch: 保存到Elasticsearch
4️⃣ 单条异常隔离 + 失败消息异步发送
失败的消息不会阻塞其他线程,而是单独送入异步失败主题:
for (AuditLogEvent failed : failedEvents) {
kafkaTemplate.send("audit-log-failed", failed);
}
✅ 主流程不中断,失败可以事后处理。
5️⃣ DLQ 死信队列集成
对于批量处理中的「系统级异常」,仍然交由 Kafka Retry + DLQ:
ConsumerRecordRecoverer recoverer = (record, ex) -> {
log.error("❌ Reached max retry. Sending to DLQ. Record: {}", record.value());
kafkaTemplate.send("audit-log-dlt", record.key().toString(), (AuditLogEvent) record.value());
};
return new DefaultErrorHandler(recoverer, backOff);
- 重试 3 次失败后,自动发到
audit-log-dlt
死信队列 - 死信消费者单独保存到 MongoDB 的
audit_dead_logs
集合
6️⃣ Prometheus + Grafana 监控
内置以下指标:
指标 | 描述 |
---|---|
log_task_success_total | 成功处理任务数 |
log_task_failure_total | 失败处理任务数 |
log_task_duration_seconds_bucket | 处理耗时(支持 p95/p99) |
dlq_consumed_success_total | DLQ 消息成功处理次数 |
dlq_processing_duration_seconds | DLQ 消息处理耗时 |
Grafana 示例图:
📈 成功率/失败率趋势 + p95耗时变化
详情可从我另外的文章可见:# 封装通用线程池 + Prometheus 可视化任务耗时与成功率(实战记录)
📈 测试验证
测试用例 | 结果 |
---|---|
手动 POST 10 条正常消息 | MongoDB + Elasticsearch 落地成功 |
手动抛出异常 | 成功进入 DLQ, audit-log-failed topic,并落地 MongoDB |
批量 POST 500 条消息 | 多线程并发消费,无阻塞 |
💥 遇到的坑与排查(详解版)
在实际实现过程中,我遇到了几个比较典型的问题,下面逐一分享排查和解决过程:
1. Kafka 服务未启动导致无线重连
-
背景:
项目mvn spring-boot:run
正常启动 -
问题现象:
但控制台不断输出类似以下日志:o.apache.kafka.common.metrics.Metrics : Closing reporter org.apache.kafka.common.metrics.JmxReporter o.apache.kafka.common.metrics.Metrics : Metrics reporters closed
-
Kafka 连接失败后自动重试,CPU占用上升,但没有真正消费任何消息。
-
原因分析:
- 本地
docker-compose
中包含 Kafka、MongoDB、Elasticsearch 等核心依赖。 - Spring Boot 项目启动时尝试连接 Kafka Broker(
localhost:9092
),但 Kafka 服务未启动,导致连接超时。 - Kafka 客户端默认有无限重连机制(除非特别配置),所以持续刷屏错误日志。
- 本地
-
解决方案:
- 在本地开发环境,启动服务前必须确保 Docker Compose 正常运行。
- 正确的启动顺序应该是:
docker-compose up -d
cd backend
./mvnw spring-boot:run
2. DLQ 消费时出现 ClassCastException(批量消费后格式变化导致)
-
背景:
在 Kafka 主消费逻辑中,为了处理失败重试后的最终失败消息,配置了 Dead Letter Queue(DLQ)。 -
问题现象:
当 DLQ 消费器接收到消息时,抛出如下异常:java.lang.ClassCastException: class java.lang.String cannot be cast to class AuditLogEvent
-
原因分析:
情况 单条消费(旧版) 批量消费(新版) DLQ 存储格式 直接是 Java 对象(AuditLogEvent) 字符串 JSON(纯文本) 消费端是否可以直接反序列化 ✅ 可以直接消费成 AuditLogEvent ❌ 不行,需要先当作 String 读入,再反序列化 批量消费(
@KafkaListener(batch = true)
)模式下,Kafka Producer 在发送到 DLQ 时,把失败的消息序列化成了纯 JSON 字符串。
如果 DLQ 消费器还按照AuditLogEvent
类型直接消费,就会因为类型不匹配抛出ClassCastException
。 -
解决方案:
- 修改 DLQ 消费器的泛型为
String
。 - 手动使用
ObjectMapper
将字符串反序列化为AuditLogEvent
对象。 - 这样保证兼容性,即使未来 DLQ 传递的格式变了也能灵活应对。
- 修改 DLQ 消费器的泛型为
-
示例代码:
public void handleDLQ(String message) {
try {
AuditLogEvent event = objectMapper.readValue(message, AuditLogEvent.class);
Instant start = Instant.now();
log.warn("❌ [DLQ] Received: {}", event);
AuditDeadLetterDocument doc = new AuditDeadLetterDocument();
doc.setAction(event.getAction());
doc.setEntity(event.getEntity());
doc.setPayload(event.getPayload());
doc.setTimestamp(event.getTimestamp());
doc.setDeadLetteredAt(LocalDateTime.now());
doc.setErrorMessage("Failed after max retry attempts");
//其余逻辑
}
🔥 核心原因
- 单条消费时,Spring Kafka 默认 按你的 ConsumerFactory 设置的反序列化器(比如
JsonDeserializer<AuditLogEvent>
)来每条自动转成对象。 - 批量消费时,Spring Kafka 只关心批量拿回来的一批数据,不会一条条套用 JsonDeserializer 的泛型推导规则。
- 所以批量拉回来的 List,是“反序列化到 String(或者 byte[])”这一层,而不会自动帮你反序列化成 List。
3. 多线程池管理混乱
-
背景:
项目中存在多个异步线程池(如:日志的Elastic search异步处理线程池、Kafka消费处理线程池)。 -
问题现象:
由于线程池 Bean 名字相似,依赖注入容易出错(比如找不到 Bean、注入到错误线程池)。 -
原因分析:
Spring 容器中同时存在多个Executor
类型的 Bean,默认按类型注入时会冲突。 -
解决方案:
- 封装统一的
ExecutorRegistry
类,按名称(key)管理线程池。 - 所有需要线程池的地方,不直接注入,而是通过
executorRegistry.getExecutor(key)
这样的方式按需获取。 - 避免命名冲突,同时提高后期线程池动态扩展的灵活性。
- 封装统一的
✅ 总结
经过这一轮优化,系统达到了:
- 🚀 支持批量高并发消费
- 🛡️ 单条validation类型失败不影响整体
- 📊 实时可观测性保障
- 🔄 失败消息可追溯处理
- 🔥 良好扩展性(线程池 + Prometheus)
- 🧠 批处理子任务支持超时控制与资源释放,防止系统雪崩。
🔗 项目地址
GitHub 👉 rapid-crud-generator
⭐ 欢迎 Star / Fork / 留言交流!
如果你觉得有用,可以点赞支持!
也可以关注我,后续会继续分享更多【高并发 / 分布式系统设计】的实战经验