Kafka + MongoDB 高并发数据插入:八年 Java 开发的实战优化秘籍

60 阅读8分钟

Kafka + MongoDB 高并发数据插入:八年 Java 开发的实战优化秘籍

作为一名踩坑无数的八年 Java 老兵,最近刚搞定一个「日均千万级数据同步」的需求 —— 用 Kafka 承接上游高并发流量,再平稳写入 MongoDB。过程中踩过数据丢失、写入超时、消息堆积的大坑,也沉淀了一套可直接落地的优化方案。今天就把这套从生产环境淬炼出的实战经验,掰开揉碎了分享给大家,新手也能直接抄作业!

一、先搞懂核心矛盾:为什么高并发下直接插入会崩?

很多新手上来就写「Kafka 消费→单条插入 MongoDB」的代码,压测直接崩给你看。其实问题的核心矛盾就三个,八年经验告诉我,绕不开也躲不掉:

  • IO 开销爆炸:单条插入时,每条数据都要走一次网络请求 + 数据库事务,百万级数据的网络往返直接把 MongoDB 压垮;
  • 数据一致性风险:Kafka 默认自动提交 offset,一旦 MongoDB 写入失败但 offset 已提交,数据直接丢失,对账都没法对;
  • 并发控制失衡:Kafka 分区数和消费线程不匹配,要么线程空闲浪费资源,要么分区不够导致消费堆积。

之前接手过一个项目,就是因为没处理好这些问题,导致 MongoDB 写入超时率高达 30%,消息堆积超百万条。后来按下面的方案优化后,TPS 直接从 3000 飙升到 5 万 +,超时率压到 0.1% 以下。

二、架构设计:高并发的基石是「解耦 + 批量」

高并发场景下,架构设计决定了 80% 的性能上限。推荐这套经过生产验证的架构:

上游系统 → Kafka(分区并行) → 消费者(批量拉取) → 本地缓存队列 → MongoDB(批量写入)
                                  ↓
                              死信队列(异常处理)

核心设计思路就两个:

  1. 用 Kafka 做流量削峰:不管上游流量波动多大,Kafka 都能稳稳接住,消费者按 MongoDB 的承载能力匀速消费;
  2. 批量处理减少 IO:把「单条拉取 + 单条插入」改成「批量拉取 + 批量插入」,MongoDB 批量写入性能比单条高 3~6 倍。

三、Kafka 消费者:调优到极致的「数据接收器」

Kafka 是高并发的入口,配置错一个参数,后面再怎么优化都白搭。这几个核心参数是八年经验总结的黄金配置,闭眼抄就行:

1. 核心配置优化(application.yml)

spring:
  kafka:
    consumer:
      bootstrap-servers: 172.31.64.10:9092,172.31.64.11:9092,172.31.64.12:9092
      group-id: ${spring.application.name}
      auto-offset-reset: earliest  # 首次启动消费历史数据,后续可改latest
      enable-auto-commit: false    # 关闭自动提交,避免数据丢失(关键!)
      max-poll-interval-ms: 600000 # 10分钟,适配MongoDB批量写入耗时
      max-poll-records: 500        # 单次拉取500条,平衡吞吐量和内存
      session-timeout-ms: 45000    # 必须小于max-poll-interval-ms
      heartbeat-interval-ms: 3000  # 3秒发一次心跳,避免被踢下线
    listener:
      ack-mode: manual_immediate   # 手动提交offset,写入成功才确认
      concurrency: 6               # 消费线程数=Topic分区数(关键匹配!)
      poll-timeout: 3000           # 拉取超时时间,避免无限阻塞

2. 关键优化点解读

  • 分区数与线程数匹配:Kafka 的核心并发机制是分区,一个分区只能被同组一个消费者消费。建议 Topic 分区数 = 消费线程数(concurrency),我这里设 6 个分区 + 6 个线程,并行消费效率拉满;
  • 关闭自动提交 offset:这是避免数据丢失的核心!之前有个项目就是因为开了自动提交,MongoDB 写入超时后数据丢了几十万条,排查了三天才找到问题;
  • 批量拉取参数:max-poll-records 设 500 是经过压测的黄金值 —— 太小会导致批量效果差,太大则会占用过多内存,甚至触发 max-poll-interval-ms 超时。

3. 消费者代码实现(批量拉取 + 手动提交)

@Slf4j
@Component
public class KafkaMongoConsumer {
    @Autowired
    private MongoTemplate mongoTemplate;
    @Value("${spring.kafka.listener.concurrency}")
    private int concurrency;
    private static final String COLLECTION_NAME = "business_data";
    // 本地缓存队列,承接批量拉取的数据
    private final ConcurrentLinkedQueue<String> dataQueue = new ConcurrentLinkedQueue<>();
    // 批量写入阈值,和max-poll-records保持一致
    private static final int BATCH_SIZE = 500;

    @KafkaListener(topics = "business-data-topic", groupId = "${spring.application.name}")
    public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
        try {
            String data = record.value();
            if (StringUtils.isBlank(data)) {
                log.warn("空消息,offset:{}", record.offset());
                ack.acknowledge();
                return;
            }
            // 加入本地缓存队列
            dataQueue.offer(data);
            // 达到批量阈值,执行写入
            if (dataQueue.size() >= BATCH_SIZE) {
                batchWriteToMongo();
                // 手动提交offset,确认消费成功
                ack.acknowledge();
                log.info("批量写入成功,提交offset:{},本次写入{}条", record.offset(), BATCH_SIZE);
            }
        } catch (Exception e) {
            log.error("消费失败,offset:{}", record.offset(), e);
            // 失败不提交offset,Kafka会重新推送
        }
    }

    // 定时刷盘,避免缓存数据积压(比如最后一批不足500条)
    @Scheduled(fixedRate = 5000)
    public void flushData() {
        if (!dataQueue.isEmpty()) {
            batchWriteToMongo();
            log.info("定时刷盘,写入{}条数据", dataQueue.size());
        }
    }
}

四、MongoDB 写入:从「能写」到「快写」的优化

MongoDB 本身性能很强,但高并发下如果配置不当,很容易出现写入超时、连接耗尽的问题。这几个优化点,让 MongoDB 的写入性能直接翻倍:

1. 核心配置优化(application.yml)

spring:
  data:
    mongodb:
      uri: mongodb://carsjoy:carsjoyMongo%40220@172.31.64.13:27021/carsjoy?authSource=carsjoy&maxPoolSize=200&minPoolSize=20&maxIdleTimeMS=300000&writeConcern=MAJORITY

2. 关键配置解读

  • 连接池配置:maxPoolSize 设 200(默认 100),保证高并发下有足够连接;minPoolSize 设 20,避免频繁创建连接的开销;maxIdleTimeMS 设 5 分钟,自动回收闲置连接;
  • 认证配置:必须指定 authSource(认证库),否则会默认用 admin 库认证,导致连接失败(踩过这个坑的举个手);
  • 写入确认级别:writeConcern=MAJORITY,要求副本集多数节点确认写入成功,既保证数据安全,又不会过度影响性能。如果是非核心业务,也可以设为 ACKNOWLEDGED 提升速度。

3. 批量写入代码实现(核心优化!)

MongoDB 的批量写入 API 是性能关键,一定要用insertMany()bulkWrite(),并设置ordered: false实现并行写入:

private void batchWriteToMongo() {
    List<Document> documentList = new ArrayList<>(BATCH_SIZE);
    String data;
    // 从缓存队列中取出数据
    while ((data = dataQueue.poll()) != null) {
        Document document = Document.parse(data);
        // 可以在这里添加业务字段,比如创建时间
        document.put("createTime", new Date());
        documentList.add(document);
    }
    if (documentList.isEmpty()) {
        return;
    }
    try {
        // 批量插入,ordered=false并行执行,失败不中断
        mongoTemplate.getCollection(COLLECTION_NAME)
                .insertMany(documentList, new InsertManyOptions().ordered(false));
    } catch (MongoBulkWriteException e) {
        // 处理部分写入失败的情况
        int successfulCount = e.getWriteResult().getInsertedCount();
        int failedCount = documentList.size() - successfulCount;
        log.error("批量写入部分失败,成功{}条,失败{}条", successfulCount, failedCount, e);
        // 失败的数据可以发送到死信队列
        sendToDeadLetterQueue(documentList, e.getWriteErrors());
    } catch (Exception e) {
        log.error("批量写入失败", e);
        // 全部失败,重新放回队列重试
        documentList.forEach(doc -> dataQueue.offer(doc.toJson()));
    }
}

4. 额外优化技巧(生产必备)

  • 索引优化:高频写入的集合,尽量减少索引数量,只保留必要的查询索引。插入前可以先删除索引,插入后再重建,性能提升明显;
  • 使用 SSD 存储:MongoDB 写入对 IO 要求高,SSD 的 IOPS 是 HDD 的 10 倍以上,能显著降低写入延迟;
  • 分片集群:如果数据量超大(亿级以上),可以给 MongoDB 做分片,按业务键拆分数据,分散写入压力。

五、容错机制:高并发下的数据安全网

高并发场景下,异常是常态,必须做好容错设计,否则一次网络波动就可能导致数据丢失或系统雪崩:

1. 死信队列(DLQ)实现

消费失败的消息(比如数据格式错误、MongoDB 持续超时),不能一直重试,要发送到死信队列单独处理:

@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
private static final String DEAD_LETTER_TOPIC = "business-data-dlq";

private void sendToDeadLetterQueue(List<Document> documentList, List<WriteError> errors) {
    for (WriteError error : errors) {
        int index = error.getIndex();
        String failedData = documentList.get(index).toJson();
        // 发送到死信队列,并添加错误信息
        kafkaTemplate.send(DEAD_LETTER_TOPIC, failedData)
                .addCallback(
                        result -> log.info("死信队列发送成功:{}", failedData),
                        ex -> log.error("死信队列发送失败", ex)
                );
    }
}

创建死信队列时,建议和原 Topic 保持相同的分区数,便于问题排查。

2. 幂等性处理

避免重复消费导致的数据重复,在 MongoDB 中给业务唯一键(比如消息 ID)创建唯一索引:

// 初始化时创建唯一索引
@PostConstruct
public void initIndex() {
    mongoTemplate.getCollection(COLLECTION_NAME)
            .createIndex(Indexes.ascending("messageId"), new IndexOptions().unique(true));
}

这样即使消息重复消费,MongoDB 也会拒绝重复插入,保证数据一致性。

3. 监控告警

一定要配置监控,实时掌握系统状态:

  • Kafka:监控消费延迟、积压消息数、消费者在线状态;
  • MongoDB:监控写入耗时、连接池使用率、磁盘空间;
  • 可以用 Prometheus+Grafana 搭建监控面板,设置阈值告警(比如消费延迟超过 5 分钟就发短信通知)。

六、八年开发踩坑实录(避坑指南)

  1. 坑 1:Topic 分区数太少之前设了 2 个分区,就算开了 6 个消费线程,也只有 2 个线程在工作,消息堆积严重。后来把分区数改成 6,消费速度直接翻倍。记住:分区数决定了消费端的最大并行度。
  2. 坑 2:批量太大导致 OOM一开始把 max-poll-records 设为 2000,结果高并发下 JVM 内存飙升,频繁 GC。后来压测发现 500 是黄金值,既能保证批量效果,又不会占用过多内存。
  3. 坑 3:MongoDB 连接池耗尽没配置 maxPoolSize,默认 100 个连接,高并发下连接耗尽,写入超时。改成 200 后,连接池使用率稳定在 60% 左右,再也没出现超时问题。
  4. 坑 4:自动提交 offset这个是最致命的坑!一次 MongoDB 集群切换,导致写入失败,但 offset 已经自动提交,几十万条数据丢失。从那以后,所有 Kafka 消费都默认关闭自动提交。

七、最终效果与总结

按上面的方案优化后,系统表现如下:

  • 吞吐量:从 3000 TPS 提升到 5 万 + TPS,支撑日均千万级数据插入;
  • 稳定性:写入超时率从 30% 降到 0.1% 以下,无数据丢失;
  • 可扩展性:通过增加 Kafka 分区和 MongoDB 分片,可轻松支撑更高流量。

总结一下,Kafka+MongoDB 高并发数据插入的核心就三点:

  1. 批量处理:Kafka 批量拉取 + MongoDB 批量写入,减少 IO 开销;
  2. 参数匹配:Topic 分区数 = 消费线程数,批量大小适配 MongoDB 承载能力;
  3. 容错兜底:手动提交 offset + 死信队列 + 幂等性处理,保证数据安全。

这套方案已经在多个生产环境验证过,不管是日志收集、数据同步还是高并发业务写入,都能直接复用。如果你的场景有特殊需求,也可以根据实际情况调整参数。