MyBatis批量插入:从5分钟到3秒的逆袭之路
开篇:抛出性能困境
宝子们,上周我接到了一个超 “刺激” 的数据迁移任务,要把老系统中的 10 万条数据导入到新系统。想着用 MyBatis 批量插入应该是小 case,结果现实却给了我狠狠一击!最初的代码跑起来,插入 10 万条数据居然整整耗时 5 分钟 !这要是放到生产环境,用户不得分分钟把我 “吐槽” 上热搜?领导也坐不住了,直接下达指令:必须优化,越快越好!于是,我开启了一场和时间赛跑的性能优化之旅,没想到最后真让我把时间从 5 分钟缩短到了 3 秒 ,今天就来给大家分享一下我都做了些什么。
最初的困境:5 分钟的漫长等待
(一)低效代码展示
起初,我采用了最常规的 foreach 循环单条插入方式 ,代码大致如下:
<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:
List<User> userList = generateUserList(100000); // 生成10万条用户数据
userMapper.batchInsertOld(userList);
(二)性能问题剖析
这种写法看似简单直接,但实际上存在严重的性能问题 。每一次循环插入,都要经历一次数据库连接的建立(虽然可能是从连接池获取,但也有开销)、SQL 语句的解析、执行以及事务的提交。当数据量达到 10 万条时,这些操作的累计开销就变得极为庞大 。
-
频繁建立数据库连接:即使使用了数据库连接池,频繁获取和归还连接也会消耗大量时间,降低系统的并发处理能力。
-
多次提交事务:事务的提交涉及到数据库的日志写入、数据持久化等操作,频繁提交会增加磁盘 I/O 的负担 。
-
SQL 解析次数过多:数据库需要对每条插入语句进行语法解析、语义分析、查询优化等操作,10 万次的解析操作让数据库不堪重负 。
在实际测试中,插入 10 万条数据,数据库的 CPU 使用率飙升到 90% 以上,磁盘 I/O 也达到了峰值,整个系统几乎处于瘫痪状态,这也难怪插入操作需要 5 分钟之久 ,这样的性能表现,在生产环境中是绝对无法接受的。
第一次优化:批量 SQL,初尝提速
(一)优化思路与实现
经过一番查阅资料和深思熟虑,我决定把循环插入改成批量 SQL 。在 Mapper.xml 文件中,使用<foreach>标签来拼接插入语句 ,代码如下:
<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 条数据 :
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,添加这个参数 :
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 ,代码如下:
@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 ,以避免线程之间的资源竞争 。代码如下:
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 倍 ,我自己都被这个结果惊到了 。
不过,在使用多线程并行插入时,也有一些需要注意的地方 :
-
线程数设置:线程数并非越多越好,要根据服务器的 CPU 核心数、数据库连接池大小等因素综合考虑 。如果线程数过多,可能会导致线程上下文切换频繁,反而降低性能 。一般来说,可以将线程数设置为 CPU 核心数的 2 倍左右 。
-
事务一致性:如果需要保证事务一致性,这种多线程并行插入的方式可能不太适用 。因为每个线程都有自己的事务,如果某个线程插入失败,其他线程已经插入的数据无法回滚 。如果必须保证事务一致性,可以考虑使用分布式事务解决方案,如 Seata 等 。
-
主键冲突:在多线程并行插入时,要特别注意主键冲突的问题 。如果数据中包含唯一主键,多个线程同时插入可能会导致主键冲突异常 。可以通过在数据库表上添加唯一约束,或者在插入前进行主键校验等方式来避免 。
通过这三次优化,我深刻体会到了性能优化的魅力和挑战 。在实际开发中,我们不能满足于代码能跑就行,要不断追求更高的性能和更好的用户体验 。希望我的优化经验能对大家有所帮助,如果你也有类似的性能优化经历,欢迎在评论区分享哦 !
踩过的坑与解决方案
在这次性能优化过程中,我也遇到了不少坑 ,好在都一一解决了,现在就把这些经验分享给大家,希望能帮助大家少走弯路 。
(一)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 还是会被一条条地发送到数据库执行 。经过一番排查,发现有以下几个可能的原因 :
-
URL 参数错误:首先要确保在数据库连接 URL 中正确添加了
rewriteBatchedStatements=true,并且没有拼写错误 。比如,我就曾经因为粗心把rewriteBatchedStatements写成了rewriteBatchStatements,导致参数不生效 。 -
未使用 ExecutorType.BATCH:在获取 SqlSession 时,必须指定执行器类型为
ExecutorType.BATCH,否则rewriteBatchedStatements也无法发挥作用 。这就好比你买了一辆高性能跑车,但却一直用低速档行驶,根本发挥不出它的速度优势 。 -
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方法进行分批插入 ,代码如下:
int pageSize = 10000;
int total = countTotal();
for (int i = 0; i < total; i += pageSize) {
List<User> page = selectByPage(i, pageSize);
batchInsertWithExecutor(page);
}
这样,每次只处理一页数据,大大减少了内存的占用 ,避免了内存溢出的问题 。同时,由于每一页数据都采用了前面优化后的批量插入方式,插入效率也得到了保证 。