1. 从5分钟到3秒:MyBatis批量插入性能优化实战
上周接手了一个数据迁移任务,需要将10万条用户数据从旧系统迁移到新系统。最初采用最简单的单条插入方式,整个过程耗时约5分钟。经过三个关键优化步骤后,最终将时间压缩到3秒。本文将详细分享整个优化过程,包括代码实现、原理分析以及踩坑经验。
2. 初始方案:循环单条插入(300秒)
2.1 问题代码分析
最常见的MyBatis插入方式就是循环调用单条插入:
// 方式1:循环单条插入(最慢) for (User user : userList) { userMapper.insert(user); }这种写法虽然简单直观,但性能极差。10万条数据需要执行10万次独立的SQL语句,每次插入都包含以下开销:
- 应用层到数据库的网络往返
- SQL解析和优化
- 事务处理
- 索引维护
假设每条插入平均耗时3ms,10万条就是300秒(5分钟)。在实际测试中,这个数字还会受到数据库负载、网络延迟等因素影响而变得更糟。
2.2 性能瓶颈解析
这种方式的性能瓶颈主要来自:
- 网络IO:每次插入都需要完整的请求-响应过程
- 事务开销:默认情况下MyBatis自动提交事务,每条insert都是一个独立事务
- SQL解析:数据库需要重复解析结构相同的SQL语句
提示:即使将事务改为手动提交,也只是减少了部分事务开销,网络和SQL解析的开销仍然存在。
3. 第一层优化:批量SQL(30秒)
3.1 批量插入实现
MyBatis提供了foreach标签支持批量SQL生成:
<!-- Mapper.xml --> <insert id="batchInsert"> INSERT INTO user (name, age, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}) </foreach> </insert>Java调用代码:
// 分批插入,每批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); }3.2 性能提升原理
这种方式将多条insert合并为一条SQL语句,例如:
INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com'), ('李四', 30, 'lisi@example.com'), ... ('王五', 28, 'wangwu@example.com');优势在于:
- 减少网络往返次数(从N次变为N/batchSize次)
- 数据库只需解析一次SQL
- 单次事务提交
实测10万条数据插入时间从300秒降至30秒,提升10倍。
3.3 注意事项
批大小选择:建议500-1000条/批,过大可能导致:
- SQL过长超过
max_allowed_packet限制 - 内存占用过高
- 数据库锁持有时间过长
- SQL过长超过
主键返回:如果需要获取自增ID,需配置:
<insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">但注意MySQL对批量插入返回自增ID的支持有限制。
4. 第二层优化:JDBC批处理(8秒)
4.1 启用批处理模式
MySQL提供了rewriteBatchedStatements参数优化批处理:
jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true配合MyBatis的批处理执行器:
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(); } }4.2 技术原理
- ExecutorType.BATCH:MyBatis会缓存预处理语句,而不是立即执行
- rewriteBatchedStatements:MySQL驱动会将多个INSERT重写为更高效的形式
- 分批刷新:定期清空缓存防止内存溢出
这种组合使得实际发送到数据库的SQL数量大幅减少,10万条数据降至8秒完成。
4.3 常见问题排查
参数不生效检查:
- 确认连接URL包含
rewriteBatchedStatements=true - 确认使用了
ExecutorType.BATCH - MySQL驱动版本建议5.1.13+(8.0+最佳)
- 确认连接URL包含
主键返回异常:
- 批量模式下自增ID可能不连续
- 解决方案:升级MySQL驱动到8.0.17+
内存管理:
- 大批量操作需定期
flushStatements - 考虑使用
clearCache防止二级缓存膨胀
- 大批量操作需定期
5. 第三层优化:多线程并行(3秒)
5.1 并行处理实现
public void parallelBatchInsert(List<User> userList) { int threadCount = 4; // 根据CPU核心数和连接池大小调整 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(); }5.2 关键配置要点
线程数选择:
- 不超过数据库连接池最大连接数
- 建议CPU核心数×1.5
- 可通过压测找到最优值
数据分片:
- 均匀分配数据量到各线程
- 使用
subList避免数据拷贝
资源管理:
- 使用
CountDownLatch确保所有线程完成 - 正确关闭线程池
- 使用
5.3 适用场景与限制
适用场景:
- 数据量大且可并行处理
- 对实时性要求高的迁移任务
- 非事务性操作(或可接受最终一致性)
限制:
- 需要足够数据库连接资源
- 可能引发主键冲突(需确保数据分区不重叠)
- 错误处理更复杂
6. 优化效果对比
| 优化阶段 | 耗时 | 提升倍数 | 核心优化点 |
|---|---|---|---|
| 循环单条插入 | 300s | 1x | - |
| 批量SQL | 30s | 10x | 减少网络往返 |
| JDBC批处理 | 8s | 37x | 减少SQL解析和事务次数 |
| 多线程并行 | 3s | 100x | 并行利用多核CPU |
7. 完整解决方案代码
@Service public class BatchInsertService { @Autowired private SqlSessionFactory sqlSessionFactory; /** * 高性能批量插入 * 10万条数据约3秒 */ public void highPerformanceBatchInsert(List<User> userList) { if (userList == null || userList.isEmpty()) { return; } int threadCount = Math.min(4, Runtime.getRuntime().availableProcessors()); int batchSize = (int) Math.ceil((double) userList.size() / threadCount); ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { int start = i * batchSize; int end = Math.min((i + 1) * batchSize, userList.size()); if (start >= userList.size()) { latch.countDown(); continue; } List<User> subList = new ArrayList<>(userList.subList(start, end)); executor.submit(() -> { try { doBatchInsert(subList); } finally { latch.countDown(); } }); } try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } executor.shutdown(); } private void doBatchInsert(List<User> userList) { try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (int i = 0; i < userList.size(); i++) { mapper.insert(userList.get(i)); if ((i + 1) % 1000 == 0) { sqlSession.flushStatements(); sqlSession.clearCache(); } } sqlSession.flushStatements(); sqlSession.commit(); } } }8. 关键问题与解决方案
8.1 SQL过长问题
现象:
- 报错"Packet for query is too large"
- 数据库拒绝执行超长SQL
解决方案:
- 减小批处理大小(建议500-1000条/批)
- 调整MySQL配置:
SET GLOBAL max_allowed_packet=256*1024*1024;
8.2 内存溢出问题
现象:
- OOM异常
- 应用内存持续增长
解决方案:
- 分页读取源数据:
int pageSize = 10000; int total = countTotal(); for (int i = 0; i < total; i += pageSize) { List<User> page = selectByPage(i, pageSize); batchInsertWithExecutor(page); } - 定期清空MyBatis缓存
- 增加JVM堆内存
8.3 主键冲突问题
现象:
- 多线程插入时出现主键重复
- 自增ID不连续
解决方案:
- 使用UUID或其他分布式ID方案
- 确保数据分片不重叠
- 使用数据库序列(Oracle)或调整自增步长(MySQL)
9. 优化原则总结
- 减少网络往返:批量操作优于单条操作
- 降低事务开销:合并事务提交次数
- 并行处理:充分利用多核CPU
- 资源控制:合理设置批大小和线程数
- 监控调整:根据实际运行情况持续优化
在实际项目中,建议从简单的批量SQL开始,逐步应用更高级的优化技术。同时要注意,并非所有场景都适合多线程并行,需要根据具体业务需求和系统资源情况选择合适的方案。