@[toc]宋哥上周转发了一篇关于批量数据插入的文章,跟大家聊聊批量数据插入的问题,以及如何快速批量插入。有朋友看了文章提出了不同意见:宋哥认真和BUG同学聊了聊,基本明白了这位朋友的意思,所以自己也写了一个测试用例,重新整理了今天的文章,希望和大家一起讨论这个问题小伙伴们,欢迎小伙伴们提出更好的解决方案。一、思路分析对于批量插入的问题,我们使用JDBC来操作。其实有两种思路:用for循环逐条插入数据(这需要批处理)。生成一个insertsql,类似于这个insertintouser(username,address)values('aa','bb'),('cc','dd')....哪个更快?我们从两个方面考虑这个问题:插入SQL本身的效率。网络输入输出。先说第一种方案,就是用for循环插入:这种方案的好处是JDBC中的PreparedStatement有预编译功能,预编译后会缓存起来,后面的SQL执行速度会更快,而且JDBC可以开启批处理,这个批处理功能很强大。缺点是很多时候我们的SQL服务器和应用服务器可能不是同一个,所以必须考虑网络IO。如果网络IO比较耗时,可能会拖慢SQL的执行速度。再说第二种方案,就是生成一条SQL插入:这种方案的优点是网络IO只有一次,即使分片处理也只是网络IO的几倍,所以这种方案不会花费太多网络IO时间。当然,这个解决方案有几个缺点。一是SQL太长,甚至可能需要分片后分批处理;二是无法充分发挥PreparedStatement预编译的优势,SQL需要重新解析,无法复用;三是最终生成的SQL太长,数据库管理器解析这么长的SQL需要时间。那么我们最后要考虑的是我们花在网络IO上的时间是否超过了SQL插入的时间?这是我们要考虑的核心问题。2、数据测试接下来我们做一个简单的测试,批量插入5万条数据。首先准备一个简单的测试表:CREATETABLE`user`(`id`int(11)unsignedNOTNULLAUTO_INCREMENT,`username`varchar(255)DEFAULTNULL,`address`varchar(255)DEFAULTNULL,`password`varchar(255)DEFAULTNULL,PRIMARYKEY(`id`))引擎=InnoDBDEFAULTCHARSET=utf8mb4;接下来创建一个SpringBoot工程,引入MyBatis依赖和MySQL驱动,然后在application.properties:spring中配置数据库连接信息。datasource.username=rootspring.datasource.password=123spring.datasource.url=jdbc:mysql:///batch_insert?serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true大家需要注意的是,在数据库连接URL地址里多了一个参数rewriteBatchedStatements,这是核心。默认情况下,MySQLJDBC驱动程序会忽略executeBatch()语句,将一组我们希望批量执行的SQL语句打散,一个一个地发送到MySQL数据库。批量插入实际上是单次插入,直接导致性能下降。将rewriteBatchedStatements参数设置为true,数据库驱动会帮我们批量执行SQL。OK,准备工作就完成了。2.1方案一测试首先我们看一下方案一的测试,即逐条插入(实际上是批处理)。首先创建相关的映射器,如下:@MapperpublicinterfaceUserMapper{IntegeraddUserOneByOne(Useruser);}对应的XML文件如下:insertintouser(username,address,password)values(#{username},#{地址},#{密码})服务如下:@ServicepublicclassUserServiceextendsServiceImplimplementsIUserService{privatestaticfinalLoggerlogger=LoggerFactory.getLogger(UserService.class);@AutowiredUserMapper用户映射器;@AutowiredSqlSessionFactorysqlSessionFactory;@Transactional(rollbackFor=Exception.class)publicvoidaddUserOneByOne(Listusers){SqlSessionsession=sqlSessionFactory.openSession(ExecutorType.BATCH);UserMapperum=session.getMapper(UserMapper.class);longstartTime=System.currentTimeMillis();对于(用户用户:用户){um.addUserOneByOne(用户);}session.commit();长端时间e=System.currentTimeMillis();logger.info("一条一条插入SQL需要时间{}",(endTime-startTime));}}这里要说一下:虽然是一个一个插入,但是我们需要开启批处理模式(BATCH),这样前后只用一个SqlSession。如果不采用批处理方式,反复获取Connection和释放Connection会耗费大量时间,效率极低。我不会为每个人测试它。接下来写个简单的测试接口看看:@RestControllerpublicclassHelloController{privatestaticfinalLoggerlogger=getLogger(HelloController.class);@Autowired用户服务用户服务;/***一个一个插入*/@GetMapping("/user2")publicvoiduser2(){Listusers=newArrayList<>();for(inti=0;i<50000;i++){Useru=newUser();u.setAddress("广州:"+i);u.setUsername("张三:"+i);u.setPassword("123:"+i);用户.add(u);}userService.addUserOneByOne(用户);}}写一个简单的单元测试:/****单元测试加事务的目的是插入后自动回滚,避免影响下一次测试结果*一个一个插入*/@Test@TransactionalvoidaddUserOneByOne(){List<用户>users=newArrayList<>();for(inti=0;i<50000;i++){Useru=newUser();u.setAddress("广州:"+i);u.setUsername("张三:"+i);u.setPassword("123:"+i);用户.add(u);}userService.addUserOneByOne(users);}可以看到耗时901毫秒,插入5万条数据耗时不到1秒。2.2方案二测试方案二是生成一条SQL,然后插入。mapper如下:@MapperpublicinterfaceUserMapper{voidaddByOneSQL(@Param("users")Listusers);}对应的SQL如下:insertintouser(username,address,password)values(#{user.username},#{user.address},#{user.password})服务如下:@ServicepublicclassUserServiceextendsServiceImplimplementsIUserService{privatestaticfinalLoggerlogger=LoggerFactory.getLogger(UserService.class);@AutowiredUserMapper用户映射器;@AutowiredSqlSessionFactorysqlSessionFactory;@Transactional(rollbackFor=Exception.class)publicvoidaddByOneSQL(Listusers){longstartTime=System.currentTimeMillis();userMapper.addByOneSQL(用户);longendTime=System.currentTimeMillis();logger.info("合并成一条SQL插入日志时间{}",(endTime-startTime));}}然后在单元测试中调整这个方法:/***MergeintooneSQLinsert*/@Test@TransactionalvoidaddByOneSQL(){Listusers=newArrayList<>();for(inti=0;i<50000;i++){Useru=newUser();u.setAddress("广州:"+i);u.setUsername("张三:"+i);u.setPassword("123:"+i);用户.add(u);}userService.addByOneSQL(users);}可以看出插入5万条数据耗时1805毫秒。可见生成一条SQL的执行效率还是很差的。另外需要注意的是第二种方案还有一个问题,就是当数据量很大的时候,生成的SQL会特别长,MySQL可能一次处理不了这么大的SQL时间。这时候需要修改mysql配置或者插入数据分片,这些操作都会导致插入时间变长。2.3对比分析显然,方案一更有优势。当批量插入10万或20万条数据时,方案一的优势会更加明显(方案二需要修改mysql配置或对插入数据进行分片)。3、MP是怎么做到的?小伙伴们知道,其实MyBatisPlus还有一个批量插入方法saveBatch,我们来看看它的实现源码:@Transactional(rollbackFor=Exception.class)@OverridepublicbooleansaveBatch(CollectionentityList,intbatchSize){StringsqlStatement=getSqlStatement(SqlMethod.INSERT_ONE);returnexecuteBatch(entityList,batchSize,(sqlSession,entity)->sqlSession.insert(sqlStatement,entity));}可以看到这里得到的sqlStatement是一个INSERT_ONE,也就是一个Oneinsert。我们再看下executeBatch方法,如下:batchSize<1,"batchSize不能小于1");return!CollectionUtils.isEmpty(list)&&executeBatch(entityClass,log,sqlSession->{intsize=list.size();inti=1;for(Eelement:list){consumer.accept(sqlSession,element);if((i%batchSize==0)||i==size){sqlSession.flushStatements();}i++;}});}这里注意return中的第三个,第一个参数是一个lambda表达式,它也是MP中批量插入的核心逻辑。可以看出,MP先对数据进行分片(默认分片大小为1000),分片完成后逐条插入。继续看executeBatch方法,你会发现这里的sqlSession其实是批处理sqlSession,不是普通的sqlSession。综上所述,MP中的批量插入方案其实和我们2.1节的批量插入思路是一样的。4.总结,经过上面的分析,现在小伙伴们知道批量插入怎么做了吗?宋哥提供了一个测试用例,公众号后台回复批量插入测试获取案例地址,案例中有三个单元测试方法,直接运行,可以看到批量插入的时间差(数据库脚本在资源目录)。有兴趣的朋友不妨试试~最后再次感谢BUG童鞋的评论~