场景 前几天在做一个数据导入的功能,需要批量插入大概10万条数据到数据库
一开始用的是MyBatis-Plus的saveBatch方法,结果发现速度特别慢,插个几万条数据要好几分钟
我看了一下日志,发现虽然说是批量插入,但实际上是一条一条执行的SQL,这样的话性能肯定好不了
原因分析 MyBatis-Plus的saveBatch方法默认实现其实是循环调用insert方法,只是在同一个Session里执行而已
看了一下源码,大概是这样的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Transactional(rollbackFor = Exception.class) public boolean saveBatch (Collection<T> entityList, int batchSize) { try { int i = 0 ; for (T entity : entityList) { save(entity); if (++i % batchSize == 0 ) { flushStatements(); } } flushStatements(); return true ; } catch (Exception e) { throw new RuntimeException (e); } }
这样的话,如果要插入10000条数据,实际上会执行10000次insert语句
虽然在同一个事务里,但网络IO和数据库解析SQL的开销还是挺大的
解决方案 方案一:使用IService的批量插入(推荐) 其实MyBatis-Plus提供了真正的批量插入方法,需要扩展一下
先自定义一个批量插入的SQL:
1 2 3 4 5 6 public interface MyBaseMapper <T> extends BaseMapper <T> { int insertBatchSomeColumn (List<T> entityList) ; }
然后实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class MyBaseMapper <T> extends BaseMapperImpl <T> implements MyBaseMapper <T> { @Override public int insertBatchSomeColumn (List<T> entityList) { if (CollectionUtils.isEmpty(entityList)) { return 0 ; } return executeBatch(entityList, 1000 , (sqlSession, entity) -> { String sql = buildInsertSql(entity); sqlSession.insert(sql, entity); }); } }
不过这个方法比较麻烦,还得自己写SQL
方案二:配置jdbc_url(简单有效) 这个是我后来查资料发现的,只要在数据库连接URL上加个参数就行了
MySQL的连接URL改成:
1 2 3 spring: datasource: url: jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true
加上rewriteBatchedStatements=true这个参数之后,JDBC驱动会把批量的insert语句重写成真正的批量插入
比如原来是:
1 2 INSERT INTO user (name, age) VALUES ('name1' , 18 );INSERT INTO user (name, age) VALUES ('name2' , 19 );
会被重写成:
1 2 3 INSERT INTO user (name, age) VALUES ('name1' , 18 ), ('name2' , 19 );
这样性能提升就很明显了
我测试了一下,插10万条数据从原来的3分钟降到了20秒左右,提升还是挺大的
方案三:使用MyBatis的BatchExecutor 如果你不用MyBatis-Plus,或者想自己控制批量插入的逻辑,可以用MyBatis的BatchExecutor
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Service public class UserService { @Autowired SqlSessionFactory sqlSessionFactory; public void batchInsert (List<User> users) { try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); } sqlSession.commit(); } } }
这种方式配合rewriteBatchedStatements=true效果也很好
方案四:使用MySQL的LOAD DATA INFILE 如果数据量特别大,比如上百万条,还可以考虑MySQL的LOAD DATA INFILE
先把数据导出成CSV文件,然后用LOAD DATA INFILE导入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @RestController public class ImportController { @Autowired JdbcTemplate jdbcTemplate; @PostMapping("/import") public String importData (MultipartFile file) throws IOException { String tempFile = File.createTempFile("import" , ".csv" ).getAbsolutePath(); file.transferTo(new File (tempFile)); String sql = String.format( "LOAD DATA LOCAL INFILE '%s' INTO TABLE user " + "FIELDS TERMINATED BY ',' " + "LINES TERMINATED BY '\\n' " + "(name, age, email)" , tempFile ); jdbcTemplate.execute(sql); return "导入成功" ; } }
这个方法速度最快,但是需要文件系统权限,而且要处理CSV文件的格式问题
实测对比 我写了个测试方法,对比了一下几种方案的性能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @SpringBootTest public class BatchInsertTest { @Autowired UserService userService; @Test public void testBatchInsert () { List<User> users = new ArrayList <>(); for (int i = 0 ; i < 100000 ; i++) { User user = new User (); user.setName("user" + i); user.setAge(20 + i % 30 ); users.add(user); } long start = System.currentTimeMillis(); userService.saveBatch(users); long end = System.currentTimeMillis(); System.out.println("耗时:" + (end - start) / 1000 + "秒" ); } }
测试结果:
原始saveBatch:约180秒
加上rewriteBatchedStatements=true:约18秒
BatchExecutor + rewriteBatchedStatements=true:约15秒
LOAD DATA INFILE:约5秒
其他注意事项 批量大小 批量插入的时候,也不是一次插入越多越好
我测试了一下,每批1000-2000条效果比较好
1 2 3 4 5 6 7 8 9 10 11 @Service public class UserService extends ServiceImpl <UserMapper, User> { public boolean saveBatchOptimized (List<User> entityList) { if (CollectionUtils.isEmpty(entityList)) { return true ; } return saveBatch(entityList, 1000 ); } }
事务控制 批量插入一定要在事务里执行,不然插入一半出错就很麻烦
1 2 3 4 @Transactional(rollbackFor = Exception.class) public void importUsers (List<User> users) { saveBatch(users, 1000 ); }
总结 如果只是普通的业务场景,加上rewriteBatchedStatements=true这个配置就够用了
如果数据量特别大,可以考虑LOAD DATA INFILE或者其他方案
其实很多性能问题都是配置没调好,不一定非得用很复杂的方案
暂时就先记录这么多