MyBatis批量插入:从5分钟到3秒的逆袭之路

MyBatis批量插入:从5分钟到3秒的逆袭之路

开篇:抛出性能困境

宝子们,上周我接到了一个超 "刺激" 的数据迁移任务,要把老系统中的 10 万条数据导入到新系统。想着用 MyBatis 批量插入应该是小 case,结果现实却给了我狠狠一击!最初的代码跑起来,插入 10 万条数据居然整整耗时 5 分钟 !这要是放到生产环境,用户不得分分钟把我 "吐槽" 上热搜?领导也坐不住了,直接下达指令:必须优化,越快越好!于是,我开启了一场和时间赛跑的性能优化之旅,没想到最后真让我把时间从 5 分钟缩短到了 3 秒 ,今天就来给大家分享一下我都做了些什么。

最初的困境:5 分钟的漫长等待

(一)低效代码展示

起初,我采用了最常规的 foreach 循环单条插入方式 ,代码大致如下:

xml 复制代码
<insert id="batchInsertOld" parameterType="list">
    <foreach collection="list" item="item" index="index" open="" close="" separator=";">
        INSERT INTO user (id, name, age) VALUES (#{item.id}, #{item.name}, #{item.age})
    </foreach>
</insert>

在 Java 代码中,调用这个方法时传入一个包含 10 万条用户数据的 List:

java 复制代码
List<User> userList = generateUserList(100000); // 生成10万条用户数据
userMapper.batchInsertOld(userList);

(二)性能问题剖析

这种写法看似简单直接,但实际上存在严重的性能问题 。每一次循环插入,都要经历一次数据库连接的建立(虽然可能是从连接池获取,但也有开销)、SQL 语句的解析、执行以及事务的提交。当数据量达到 10 万条时,这些操作的累计开销就变得极为庞大 。

  1. 频繁建立数据库连接:即使使用了数据库连接池,频繁获取和归还连接也会消耗大量时间,降低系统的并发处理能力。

  2. 多次提交事务:事务的提交涉及到数据库的日志写入、数据持久化等操作,频繁提交会增加磁盘 I/O 的负担 。

  3. SQL 解析次数过多:数据库需要对每条插入语句进行语法解析、语义分析、查询优化等操作,10 万次的解析操作让数据库不堪重负 。

在实际测试中,插入 10 万条数据,数据库的 CPU 使用率飙升到 90% 以上,磁盘 I/O 也达到了峰值,整个系统几乎处于瘫痪状态,这也难怪插入操作需要 5 分钟之久 ,这样的性能表现,在生产环境中是绝对无法接受的。

第一次优化:批量 SQL,初尝提速

(一)优化思路与实现

经过一番查阅资料和深思熟虑,我决定把循环插入改成批量 SQL 。在 Mapper.xml 文件中,使用<foreach>标签来拼接插入语句 ,代码如下:

xml 复制代码
<insert id="batchInsert" parameterType="list">
    INSERT INTO user (id, name, age) VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.id}, #{item.name}, #{item.age})
    </foreach>
</insert>

在 Java 代码中,为了避免一次性生成的 SQL 语句过长,导致数据库报错,我采用了分批插入的方式,每批插入 1000 条数据 :

java 复制代码
int batchSize = 1000;
for (int i = 0; i < userList.size(); i += batchSize) {
    int end = Math.min(i + batchSize, userList.size());
    List<User> batch = userList.subList(i, end);
    userMapper.batchInsert(batch);
}

(二)性能提升效果

当我满怀期待地再次运行代码时,结果让我眼前一亮!插入 10 万条数据的时间从原来的 5 分钟,直接降到了 30 秒 ,效率提升了整整 10 倍 !这主要是因为批量 SQL 减少了网络往返次数 ,原来每插入一条数据都要进行一次网络通信,现在一次可以发送多条数据,大大节省了时间。同时,数据库也只需要对一条 SQL 语句进行解析和执行,减少了重复劳动 。这就好比原来你要一个一个地搬砖,现在可以一次搬一摞,速度自然就快了起来 。不过,30 秒的时间还是不够理想,离领导要求的 "越快越好" 还有一定差距,于是我决定继续深入挖掘优化空间 。

第二次优化:JDBC 批处理,再进一步

(一)关键配置与代码实现

经过第一次优化后,虽然速度有了显著提升,但我还是觉得不够快。于是,我继续深入研究,发现了 JDBC 批处理这个 "秘密武器" 。首先,需要开启 MySQL 的 rewriteBatchedStatements 参数 ,这个参数可以让 MySQL 驱动将多条 INSERT 语句合并成一条,从而减少数据库的解析和执行次数 。在 Spring Boot 项目中,我们可以在 application.yml 文件中修改数据库连接 URL,添加这个参数 :

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/your_database?rewriteBatchedStatements=true
    username: your_username
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

接着,在 MyBatis 中使用批处理模式 。在获取 SqlSession 时,指定执行器类型为 ExecutorType.BATCH ,代码如下:

java 复制代码
@Autowired
private SqlSessionFactory sqlSessionFactory;

public void batchInsertWithExecutor(List<User> userList) {
    try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        int batchSize = 1000;
        for (int i = 0; i < userList.size(); i++) {
            mapper.insert(userList.get(i));
            if ((i + 1) % batchSize == 0) {
                sqlSession.flushStatements();
                sqlSession.clearCache();
            }
        }
        sqlSession.flushStatements();
        sqlSession.commit();
    }
}

这里的sqlSession.flushStatements()方法会将缓存中的 SQL 语句一次性发送到数据库执行 ,sqlSession.clearCache()方法则是为了防止缓存占用过多内存 。

(二)性能提升分析

当我再次运行代码时,插入 10 万条数据的时间从 30 秒直接降到了 8 秒 !这简直太神奇了 。在 ExecutorType.BATCH 模式下,MyBatis 会将多次插入操作的 SQL 语句缓存起来,而不是立即发送到数据库 。直到调用sqlSession.commit()或者sqlSession.flushStatements()时,才会将这些 SQL 语句一次性发送给数据库执行 ,大大减少了网络往返次数 。同时,配合rewriteBatchedStatements=true参数,MySQL 驱动会将多条 INSERT 语句合并成一条更高效的 SQL 语句 ,进一步减少了数据库的解析和执行开销 。就好像原来你是一次送一块砖到工地,现在你可以一次送一车砖,而且还把这些砖整齐地码放好了,效率自然就更高了 。

第三次优化:多线程并行,达成 3 秒奇迹

(一)多线程方案设计

经过前两次优化,虽然插入时间已经大幅缩短,但我还是觉得不够快。于是,我决定引入多线程并行插入的方式,充分利用服务器的多核 CPU 资源 。具体实现思路是将数据分成多个小批次,每个批次交给一个线程去处理 。

在 Java 代码中,我创建了一个固定大小的线程池,然后将数据按线程数进行分割,提交给线程池中的线程并行执行 。这里需要注意的是,每个线程都要使用独立的 SqlSession ,以避免线程之间的资源竞争 。代码如下:

java 复制代码
public void parallelBatchInsert(List<User> userList) {
    int threadCount = 4; // 根据数据库连接池大小调整
    int batchSize = userList.size() / threadCount;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    List<Future<?>> futures = new ArrayList<>();

    for (int i = 0; i < threadCount; i++) {
        int start = i * batchSize;
        int end = (i == threadCount - 1)? userList.size() : (i + 1) * batchSize;
        List<User> subList = userList.subList(start, end);

        futures.add(executor.submit(() -> {
            batchInsertWithExecutor(subList);
        }));
    }

    // 等待所有任务完成
    for (Future<?> future : futures) {
        try {
            future.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    executor.shutdown();
}

这里的batchInsertWithExecutor方法就是第二次优化中使用 JDBC 批处理的插入方法 。通过多线程并行插入,每个线程负责一部分数据的插入,大大提高了插入效率 。

(二)最终性能成果与注意事项

当我怀着忐忑的心情再次运行代码时,奇迹发生了!插入 10 万条数据的时间从 8 秒直接降到了 3 秒 ,这简直太不可思议了 !从最初的 5 分钟到现在的 3 秒,性能提升了整整 100 倍 ,我自己都被这个结果惊到了 。

不过,在使用多线程并行插入时,也有一些需要注意的地方 :

  1. 线程数设置:线程数并非越多越好,要根据服务器的 CPU 核心数、数据库连接池大小等因素综合考虑 。如果线程数过多,可能会导致线程上下文切换频繁,反而降低性能 。一般来说,可以将线程数设置为 CPU 核心数的 2 倍左右 。

  2. 事务一致性:如果需要保证事务一致性,这种多线程并行插入的方式可能不太适用 。因为每个线程都有自己的事务,如果某个线程插入失败,其他线程已经插入的数据无法回滚 。如果必须保证事务一致性,可以考虑使用分布式事务解决方案,如 Seata 等 。

  3. 主键冲突:在多线程并行插入时,要特别注意主键冲突的问题 。如果数据中包含唯一主键,多个线程同时插入可能会导致主键冲突异常 。可以通过在数据库表上添加唯一约束,或者在插入前进行主键校验等方式来避免 。

通过这三次优化,我深刻体会到了性能优化的魅力和挑战 。在实际开发中,我们不能满足于代码能跑就行,要不断追求更高的性能和更好的用户体验 。希望我的优化经验能对大家有所帮助,如果你也有类似的性能优化经历,欢迎在评论区分享哦 !

踩过的坑与解决方案

在这次性能优化过程中,我也遇到了不少坑 ,好在都一一解决了,现在就把这些经验分享给大家,希望能帮助大家少走弯路 。

(一)foreach 拼接 SQL 过长

在第一次优化使用<foreach>拼接 SQL 时,如果一次插入的数据量过多,SQL 语句会变得非常长 。当 SQL 长度超过 MySQL 的max_allowed_packet限制时(默认是 4MB ),就会导致插入失败 ,报错信息类似于:Packet for query is too large (xxx > yyy). You can change this value on the server by setting the max_allowed_packet' variable

为了解决这个问题,我采用了分批插入的策略 ,每批插入 500 - 1000 条数据 。这样既能避免 SQL 过长的问题,又能在一定程度上提高插入效率 。因为如果一次性发送过长的 SQL,网络传输和数据库解析的时间都会增加 ,而分批插入可以将大任务拆分成多个小任务,让数据库和网络的负载更加均衡 。

(二)rewriteBatchedStatements 不生效

在第二次优化开启rewriteBatchedStatements参数时,我发现有时候这个参数并没有生效 ,SQL 还是会被一条条地发送到数据库执行 。经过一番排查,发现有以下几个可能的原因 :

  1. URL 参数错误 :首先要确保在数据库连接 URL 中正确添加了rewriteBatchedStatements=true ,并且没有拼写错误 。比如,我就曾经因为粗心把rewriteBatchedStatements写成了rewriteBatchStatements,导致参数不生效 。

  2. 未使用 ExecutorType.BATCH :在获取 SqlSession 时,必须指定执行器类型为ExecutorType.BATCH ,否则rewriteBatchedStatements也无法发挥作用 。这就好比你买了一辆高性能跑车,但却一直用低速档行驶,根本发挥不出它的速度优势 。

  3. MySQL 驱动版本太旧 :如果 MySQL 驱动版本太旧,可能不支持rewriteBatchedStatements参数 。可以查看 MySQL 官方文档,确认当前驱动版本是否支持该参数 。如果不支持,及时升级驱动版本 。

(三)自增主键返回问题

在使用rewriteBatchedStatements=true进行批量插入时,还遇到了一个自增主键返回的问题 。如果在<insert>标签中设置了useGeneratedKeys="true"keyProperty="id"来获取自增主键 ,在某些情况下,可能只能获取到第一条数据的自增主键,后面的数据主键为 0 或者 null 。

经过查阅资料发现,这是因为rewriteBatchedStatements=true时,MySQL 驱动会将多条 INSERT 语句合并成一条,而这种合并后的语句在返回自增主键时存在问题 。解决办法是将 MySQL 驱动升级到 8.0.17 + 版本 ,这个版本修复了自增主键返回的问题 。

(四)内存溢出

在最初的实现中,我是一次性将 10 万条数据加载到内存中进行处理的 ,这就带来了一个潜在的风险 ------ 内存溢出(OOM) 。当数据量非常大时,一次性加载所有数据会占用大量的内存,导致 JVM 内存不足 ,抛出OutOfMemoryError异常 。

为了解决这个问题,我采用了分页读取 + 分批插入的策略 。首先,通过countTotal()方法获取数据的总条数 ,然后根据设定的pageSize(比如 10000 条)进行分页读取 。每次读取一页数据,调用batchInsertWithExecutor方法进行分批插入 ,代码如下:

java 复制代码
int pageSize = 10000;
int total = countTotal();
for (int i = 0; i < total; i += pageSize) {
    List<User> page = selectByPage(i, pageSize);
    batchInsertWithExecutor(page);
}

这样,每次只处理一页数据,大大减少了内存的占用 ,避免了内存溢出的问题 。同时,由于每一页数据都采用了前面优化后的批量插入方式,插入效率也得到了保证 。

相关推荐
Rust研习社32 分钟前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒1 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro2 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax2 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH2 小时前
Koa和Express的区别
后端
MariaH2 小时前
Koa框架的使用
后端
luckdewei3 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某5 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy5 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom5 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github