🚀 Kafka 批量消费 + 多线程处理 + DLQ + Prometheus 全流程实战总结

136 阅读8分钟

🚀 Kafka 批量消费 + 多线程处理 + DLQ + Prometheus 全流程实战总结

在实际系统开发中,随着业务复杂度的不断提升,简单的单条消费模式逐渐难以支撑高并发与高可靠性的要求。
为了提升系统整体性能和稳定性,引入批量消费、异步处理与实时监控体系,成为了必不可少的工程实践。

在最近的项目 —— rapid-crud-generator 中,针对 Kafka 消费链路,新增了以下关键能力:

  • 🔁 Kafka 批量拉取(Batch Pull)+ 线程池并发处理:提升消息处理吞吐量,降低单条消费延迟。
  • 🛡️ 异常隔离与失败保护
    • 🛠️ 针对数据校验类异常(ValidationException),单条消息失败不会中断整体消费流程,失败消息将记录并发送至 audit-log-failed Topic;
    • 🎯 针对系统级异常(如数据库不可用、严重运行时错误),则中断当前批次处理,抛出 RuntimeException,触发 Kafka 重试机制,最终失败的批次消息进入 DLQ(Dead Letter Queue)。
  • 批量处理超时检测与未完成任务预警:通过 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_totalDLQ 消息成功处理次数
dlq_processing_duration_secondsDLQ 消息处理耗时

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 传递的格式变了也能灵活应对。
  • 示例代码:

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 / 留言交流!


如果你觉得有用,可以点赞支持!
也可以关注我,后续会继续分享更多【高并发 / 分布式系统设计】的实战经验