1. MyBatis批量插入性能瓶颈解析
第一次接触MyBatis批量插入是在三年前的一个电商项目,当时需要每天凌晨导入百万级商品数据。最初采用简单的单条插入方式,结果跑一次全量导入需要6个小时,数据库服务器CPU直接飙到100%。这个惨痛教训让我开始深入研究MyBatis的批量插入优化。
MyBatis默认的Simple执行器模式存在明显性能缺陷。它每次执行insert语句都会经历完整的生命周期:解析SQL→参数映射→预编译→执行→提交。当处理10万条数据时,这个流程会被重复10万次,其中预编译阶段尤其消耗资源。我做过测试,在MySQL 5.7环境下,Simple模式插入1万条记录耗时约25秒。
更糟糕的是,某些场景下还会出现"SQL语句洪水"现象。比如使用foreach拼接SQL时,如果一次性拼接5000条values,生成的SQL可能达到几MB大小。不仅网络传输耗时,数据库解析这么长的SQL也会消耗大量内存。曾经有个案例,某系统批量插入时直接把数据库连接撑爆了。
2. 经典优化方案:ExecutorType.BATCH深度剖析
ExecutorType.BATCH是我最早采用的优化方案,它的核心原理可以用"预编译复用"来概括。开启BATCH模式后,MyBatis会缓存预编译后的PreparedStatement,后续插入只需替换参数值,避免了重复预编译的开销。
具体实现需要三个关键步骤:
// 1. 创建BATCH模式的SqlSession SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper = session.getMapper(UserMapper.class); // 2. 循环执行插入操作 for (User user : userList) { mapper.insert(user); } // 3. 统一提交 session.flushStatements(); session.commit(); } finally { session.close(); }这里有个容易踩的坑:忘记调用flushStatements()。有次我批量插入10万数据,内存直接OOM,就是因为没有及时清空批处理缓存。最佳实践是每1000条左右flush一次,既保证批处理效果,又避免内存溢出。
性能对比数据很能说明问题:
- Simple模式:1万条/25秒
- BATCH模式:1万条/3.2秒
- foreach拼接:1万条/1.8秒(但内存消耗是BATCH的3倍)
3. 现代最佳实践:MultiRowInsertStatementProvider详解
随着MyBatis 3.5+的推出,MultiRowInsertStatementProvider成为了新的性能标杆。它通过动态SQL生成技术,在保证可读性的同时实现了接近原生JDBC的性能。我在最近一个物联网项目中采用这种方案,写入速度比传统BATCH模式又提升了40%。
具体实现示例:
try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); List<User> users = generateTestUsers(10000); MultiRowInsertStatementProvider<User> insert = insertMultiple(users) .into(user) .map(id).toProperty("id") .map(username).toProperty("username") .map(password).toProperty("password") .build() .render(RenderingStrategies.MYBATIS3); mapper.insertMultiple(insert); session.commit(); }这个方案的亮点在于:
- 自动优化SQL格式,生成高效的批量插入语句
- 内置参数绑定安全防护,避免SQL注入
- 支持类型处理器自动应用
- 与MyBatis缓存机制完美兼容
实测对比数据:
- 传统BATCH:10万条/8.5秒
- MultiRowInsert:10万条/5.2秒
- JDBC原生批处理:10万条/4.9秒
4. MyBatis-Plus的saveBatch魔法
对于使用MyBatis-Plus的项目,其saveBatch方法提供了开箱即用的批量插入方案。最近帮一个初创团队优化他们的CRM系统,仅用saveBatch替换原有逻辑,数据导入时间就从2小时缩短到15分钟。
标准用法很简单:
List<User> userList = generateUsers(100000); userService.saveBatch(userList);但有几个实用技巧值得分享:
- 合理设置batchSize参数,默认是1000,但根据我的测试,在SSD存储的MySQL上设置为5000更优
- 配合rewriteBatchedStatements=true参数使用,性能可再提升30%
- 事务边界要明确,建议在Service层添加@Transactional
我整理了一个性能对比矩阵:
| 方案 | 10万条耗时 | CPU占用 | 内存峰值 |
|---|---|---|---|
| 单条插入 | 285s | 85% | 1.2GB |
| foreach拼接 | 4.8s | 45% | 3.5GB |
| BATCH模式 | 6.2s | 38% | 800MB |
| saveBatch | 5.5s | 40% | 1.1GB |
5. 实战中的避坑指南
在金融级应用中,我们遇到过批量插入导致主从同步延迟的问题。当时采用BATCH模式每秒插入2万条记录,结果从库延迟达到20分钟。解决方案是:
- 在JDBC URL添加useServerPrepStmts=false
- 设置rewriteBatchedStatements=true
- 采用分片插入策略,每5000条提交一次
另一个常见问题是自增ID获取。在BATCH模式下,必须等到事务提交后才能获取真实ID。有次我们实现订单拆单功能时就踩了这个坑。解决方案有两种:
// 方案1:使用SELECT LAST_INSERT_ID()手动查询 @Options(useGeneratedKeys=true, keyProperty="id") @Insert("INSERT INTO orders(...) VALUES(...)") void insertOrder(Order order); // 方案2:采用UUID等非自增主键 public class Order { private String id = UUID.randomUUID().toString(); //... }对于超大批量数据(千万级),建议采用分段+多线程处理。但要注意线程数不要超过数据库连接池大小,否则会适得其反。我常用的线程池配置:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 5, // 核心线程数=连接池最大连接数的一半 10, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy() );6. 性能优化全链路实践
完整的性能优化应该覆盖整个数据处理链路。在最近一个日志分析系统中,我们通过全链路优化将吞吐量提升了15倍:
数据准备阶段:
- 使用ParallelStream快速转换数据格式
- 预分配List容量避免扩容开销
List<Log> logs = rawData.parallelStream() .map(this::convertToLog) .collect(Collectors.toList());数据库配置:
spring.datasource.hikari.maximum-pool-size=20 spring.datasource.url=jdbc:mysql://...&rewriteBatchedStatements=true&cachePrepStmts=true运行时监控:
- 使用Micrometer记录关键指标
- 设置合理的超时时间
@Transactional(timeout = 30) public void batchInsert(List<Data> data) { //... }失败处理机制:
- 实现批量重试逻辑
- 采用死信队列处理异常数据
RetryTemplate retryTemplate = new RetryTemplate(); retryTemplate.execute(context -> { return batchOperation(); });
这套方案在AWS c5.2xlarge实例上实现了每秒插入12万条的稳定吞吐量,而且CPU占用保持在70%以下。关键是要根据实际业务场景调整各个阶段的参数,没有放之四海而皆准的最优解。