一次线上故障复盘:MySQL 默认配置引发的批量插入性能雪崩

大家好,我是 lucy。最近在维护一个基于 Spring Boot 3 + MyBatis-Plus 的业务系统时,遇到了一个典型的批量插入耗时从 200ms 飙升到 40s的生产故障。排查过程让我对 MySQL 的默认配置有了更深的理解,记录下来供大家参考。

📋 问题背景

系统有个定时任务,每天凌晨会从上游系统同步一批福利兑换订单,单次批量插入记录数约 5000~20000 条。业务初期运行正常,某次版本迭代后,这个定时任务的执行时间从原来的 200ms 左右突然飙升到 30s~45s,严重影响下游系统的数据同步时效。

🔍 排查过程

第一步:定位耗时环节

通过在插入逻辑前后打日志,初步确认耗时发生在 DAO 层批量插入阶段,而非上游数据拉取或业务处理环节。

@Override
public void saveBatch(List list) {
    long start = System.currentTimeMillis();
    baseMapper.insertBatchSomeColumn(list);
    log.info("批量插入 {} 条记录,耗时 {} ms", list.size(), System.currentTimeMillis() - start);
}

日志显示,插入 10000 条数据耗时超过 40s,而数据量较小时(如 1000 条)则耗时正常。这说明耗时与数据量不是线性关系,存在某个临界点触发质变。

第二步:检查 SQL 执行计划

在测试环境模拟批量插入,并开启 MySQL 慢查询日志:

-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询日志(临时)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0;

-- 执行批量插入后查看日志
SHOW GLOBAL STATUS LIKE 'Slow_queries';

慢查询日志中捕获到的 SQL 语句本身并不慢,真正的问题出在每次插入前后的隐式操作

第三步:检查表结构与索引

SHOW CREATE TABLE order_t\G

发现表中有一个 UNIQUE KEY 索引,字段为 order_no(订单号)。业务逻辑在插入前会先查询是否存在该订单号,但排查代码后发现插入操作本身并没有先查再插

深入查看 Mapper XML,发现如下配置:

<insert id="insertBatchSomeColumn">
    INSERT INTO order_t (
        id, order_no, user_id, amount, status, create_time, update_time
    ) VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.id}, #{item.orderNo}, #{item.userId},
         #{item.amount}, #{item.status}, #{item.createTime}, #{item.updateTime})
    </foreach>
    ON DUPLICATE KEY UPDATE update_time = VALUES(update_time)
</insert>

这里使用了 ON DUPLICATE KEY UPDATE,在存在唯一索引冲突时执行更新,而不是直接报错。问题就藏在这里——当批量插入中大量记录存在冲突时,每次冲突都会触发一次隐藏的读+写,即先查后更新,相当于一次插入变成了两次操作。

第四步:确认根因——上游数据重复

查看上游接口变更记录发现:某次上游系统接口调整后,同一批订单数据会被重复推送多次(接口未做幂等控制)。这就导致批量插入时,5000 条数据中有大量 order_no 是重复的,每次遇到重复 key,MySQL 都要先查后更新,耗时急剧增加。

🛠️ 解决方案

方案一:上游接口幂等改造(根本解决)

这是最彻底的方案,要求上游系统在推送数据时增加幂等 key,或者在数据层面做去重。本次推动上游团队在接口层增加了 dedup_key 参数,从源头解决问题。

方案二:批量插入前本地去重(兜底防护)

在应用层做兜底,去重后再插入:

@Override
public void saveBatchSafe(List<OrderEntity> list) {
    // 利用 LinkedHashSet 按插入顺序去重
    Set<String> seen = new LinkedHashSet<>();
    List<OrderEntity> distinctList = list.stream()
        .filter(e -> seen.add(e.getOrderNo()))
        .toList();

    log.info("去重:原始 {} 条,去重后 {} 条", list.size(), distinctList.size());
    baseMapper.insertBatchSomeColumn(distinctList);
}

方案三:分批次插入 + 批量大小调优

调整 MyBatis-Plus 的批量插入分页大小,避免单次 SQL 过长:

# application.yml
mybatis-plus:
  properties:
    # 每次批量插入的最大条数
    max-batch-insert-size: 500

将原来一次性插入 10000 条改为每批 500 条,分 20 批插入。结合去重逻辑后,整体耗时从 40s 稳定降到 800ms 以内

📊 优化效果

阶段 数据量 耗时
优化前(含冲突数据) 10000 条 40s+
加去重逻辑 10000 条(去重后 3000 条) 3s
分批插入(每批 500) 3000 条 600ms

💡 经验总结

  1. 批量插入性能问题,不要只看 SQL 本身,要关注数据质量——重复数据会触发唯一索引冲突,代价远高于正常插入。
  2. 上游接口变更要同步评估影响,特别是幂等性、数据去重这类问题,晚了就变成生产故障。
  3. 批量操作一定要设置合理的分批大小,过大的单次 SQL 在 MySQL 端会产生临时表和内存压力。
  4. 定时任务的监控告警不能少——本次问题从发生到被发现隔了 3 天,如果接入了执行耗时告警,可以第一时间发现。

⚠️ 声明:本文相关内容仅供参考

以上内容基于真实项目经历整理,具体根因和解决方案需结合实际情况分析。如有疑问欢迎留言交流。

📡 来源:个人技术博客,2026-04-19,来自于 tech-article-publisher

发表评论

苏ICP备18039580号-2