MyBatis-Plus的saveBatch批量插入为何效率很低耗时长详解及解决方案

Posted 凌兮~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MyBatis-Plus的saveBatch批量插入为何效率很低耗时长详解及解决方案相关的知识,希望对你有一定的参考价值。

mysql数据库

针对MySQL数据库saveBatch批量插入效率比较低,是比较好解决的,一般都是由于数据库连接url上没有配置批量操作的属性,只需要在url上加上如下属性即可:

rewriteBatchedStatements=true

即类似如下:

jdbc:mysql://数据库地址/数据库名?useUnicode=true&characterEncoding=UTF8&allowMultiQueries=true&rewriteBatchedStatements=true

加上之后,你就会发现,saveBatch的速度直线提升,效果还是很不错的,一万条数据估计也就在几百毫秒。

Oracle数据库

Oracle数据库的问题就比较大了,而且至今潘老师也没找到一个比较完美的解决方案,此次写这篇博客也正是由于Oracle数据库saveBatch效率贼低引起的,先看下图,批量插入一万条数据(MyBatis-Plus的saveBatch默认一次1000条,1w条会分10次,当然你也可以设置Batch Size),耗时竟然达到10s多,简直不能忍啊,堪比龟速!


于是就开始各种排查,经过一番仔细debug、翻源码,反复测试对比验证,最终发现是因为MyBatis-Plus的针对Oracle主键序列生成策略导致的,在上图打印的日志可以看出,在每次打印参数之前,都会先执行下SELECT XXX.NEXTVAL FROM DUAL,这是我们在实体Entity上加上了MyBatis-Plus的注解@KeySequence(value = “XXX”)引起的,本来预想的1万条只需要和数据库交互10次就解决了,现在看打印日志情况,预估是insert和查询序列合计预计2万次交互,于是,测试了下去掉@KeySequence注解,手工给id赋好值,再次批量保存1万条,结果如下:

好家伙,直接干到1s多点,整整节约了10倍的时间,病根终于确定了,就是@KeySequence注解导致的。

但你这就想把锅甩给MyBatis-Plus那就大错特错了,这锅归根到底其实还是MyBatis的,为什么呢?经过潘老师一番深入探查,发现正如MyBatis-Plus官方所说,只对MyBatis做扩展却不改变,做一对快乐的好基友,@KeySequence(value = “XXX”)作用就是结合KeyGenerator来实现自动化生成主键并回填,但是MyBatis-Plus的所有KeyGenerator也是从MyBatis继承扩展而来,而使用了之后,对于Oracle而言就相当于在insert语句前加上了selectKey语句,类似如下:

<selectKey keyColumn="id" keyProperty="id" resultType="int" order="BEFORE">
       select XXX.nextval from dual
</selectKey>

这是不是很熟悉,就是我们学MyBatis时候学的啊,MyBatis-Plus只不过是把它变成了@KeySequence注解,省去了你写这段xml了而已,而所有问题的源头就来自于这段xml,潘老师亲自测试,在insert前加上这段xml后使用Mybatis原生的SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH)批量插入,发现批量插入无效,耗时和MyBatis-plus差不多,1w条大概10多秒,但是一旦去掉这段xml,再使用原生的Batch插入,我的天,竟然只要500多毫秒,也就是0.5秒,这差距在20倍左右,一直以为是MyBatis-Plus的锅,原来是MyBatis的锅,这下舒服了,可以安心地用MyBatis-Plus了,除非你不用mybatis了~

解决方案

后期补充:最优解:

今天经过潘老师继续翻看源码,还真让潘老师发现了一个新的非常好的解决方案,原来我们的xml中sql都是由MyBatis-Plus动态帮我们生成的,包括id为insert的,经过测试发现,如果我们在xml中自己再新定义一个id为insert的sql语句(注意id必须为insert),就将原来的默认生成的覆盖掉了,直接使用我们自己定义的,那么问题就迎刃而解了,具体如下:
a)去掉KeyGenerator实体对象的配置
b)去掉@KeySequence注解
c)id-type: 设置为AUTO
d)在实体对应的xml新增id为insert的sql,类似如下:

<insert id="insert" parameterType="user">
        insert into t_user(id, username, password)
        values(SEQ_USER.NEXTVAL,#username,#password)
</insert>

e)继续调用MyBatis-Plus的saveBatch或save,都会走我们写的这个insert对应的xml
f)测试后1w条大概在几百毫秒。

原可选方案:
1)其中一种比较理想的解决方案是使用触发器来解决,抛弃@KeySequence注解,在插入数据Entity时,由触发器生成序列给id赋值,缺点是无法将id返回到实体,况且我们项目组要求不允许使用触发器,因此就放弃了这种方案。
2)网上还有一种方案就是使用begin end包裹所有sql,不过经过亲测,发现比使用MyBatis-Plus主键生成策略还坑,时间直接再翻10倍,直接忽略吧,因为它底层根本不是Batch,而且我还试了一次10w条,直接就挂了(抛异常,提示变量太多),1W条耗时120多秒,比龟速还龟速!
以下是网上方案(大批量数据强烈不建议使用):
存储过程不是写死在数据库中,而是在Mapper.xml中,使用begin end包裹foreach批量生成insert语句作为一个整体提交给Oracle,id由序列在数据库直接生成,类似如下(注意两个地方的分号不能少):

<insert id="insertBatch" parameterType="java.util.List">
        begin
        <foreach collection="list" item="user">
            insert into t_user(id, username, password)
            values(SEQ_USER.NEXTVAL,#user.username,#user.password);
        </foreach>
        end;
    </insert>

注意:这种方案在写插入SQL如果想通用性比较好的话,需要针对部分有默认值的字段特别处理,因为你提交的是Entity集合,如果这些字段存在为null的也会作为null插入,而不会使用默认值,这是因为insert语句是全字段声明式插入,所以即使某个字段为空,它也不会使用数据库中的默认值,因此解决方案一种是有默认值字段的不在insert语句中声明,不过这样通用性不好,另外一种就是对其进行动态判断

3)第三种方案就是不使用数据库序列生成主键,我们自己使用java代码写个主键生成策略(类似UUID这种),这样就避免了和数据库的频繁交互,我觉得这种方案也比较靠谱。

4)还有一种和第2种类似,不过是使用UNION ALL来联合插入的,避免了begin end,但是据说只能UNION ALL2000条,多了就会出问题。

5)最后一种,回归本源,使用原生的MyBatis的SqlSession中使用Batch模式吧,别使用selectKey就行

mybatis-plus的批量新增insertBatchSomeColumn

MyBatis-Plus 是基于 MyBatis 进行封装的一套优秀的持久层框架,它提供了丰富的便捷操作方法和强大的代码生成器,大大简化了 MyBatis 的使用。在 MyBatis-Plus 中,我们可以使用 insertBatchSomeColumn 方法来实现批量新增指定字段的操作。

mybatis-plus的 IService接口 默认提供 saveBatch批量插入,也是唯一一个默认批量插入,在数据量不是很大的情况下可以直接使用,但这种是一条一条执行的效率上会有一定的瓶颈,在这里先看下saveBatch的执行情况

 

 

 

可以看到sql语句是一条一条执行的,插入多少条数据就相当于执行了多少次的插入sql, 点进saveBatch方法,看看内部是怎么实现的

 

 注意看,方法有一个事务注解,说明插入整批数据会作为一个事务进行,

默认1000条一次,怪不得执行时每隔1000条会处于一个准备执行状态,等待几秒后才会往下执行(这里等待的时间就是1000个单条的insert语句执行的时间)

再往下点是一个saveBatch接口,参数分别是插入的对象集合、插入批次数量也就是默认的1000

这里也有一个事务的注解,这是因为saveBatch是一个重载方法,插入的时候也可以指定插入批次数量调用

继续往下进入executeBatch

 

 

这里就是真正执行的方法了,idxLimit会对比DEFAULT_BATCH_SIZE和集合长度两个数中的最小数,作为批量大小,也就是说当集合长度不够1000,那么执行的时候批量大小就是集合的长度,就执行一次。

for循环中的consumer:对应的类型是一个函数式接口,代表一个接受两个输入参数且不返回任何内容的操作符。意思是给定两个参数sqlSession、循环中当前element对象,执行一次传递过来的consumer匿名函数。也就是上边源码里的插入匿名函数。
 

当i == indLimit时:执行一次预插入,并重新计算idxLimit的值

if中idxLimit计算规则:当前idxLimit加batchSize(默认1000) 和 集合长度 取最小值,计算出来的结果肯定不会超过集合的长度,最后的批次时idxLimit等于集合的长度,将这个值作为下一次执行预插入的时间点。

sqlSession.flushStatements():当有处于事务中的时候,起到一种预插入的作用,执行了这行代码之后,要插入的数据会锁定数据库的一行记录,并把数据库默认返回的主键赋值给插入的对象,这样就可以把该对象的主键赋值给其他需要的对象中去了,这里不是事务提交啊。

最后方法执行完后@Transactional注解会默认提交事务,如果调用的方法上还有@Transactional注解,默认的事务传播类型是Propagation.REQUIRED,不会新开启事务,如果没有@Transactional注解才会新开起事务

下面我们使用真正的批量新增方法insertBatchSomeColumn,看看两者的区别

1. 什么是批量新增指定字段

批量新增指定字段是指在一次 SQL 语句中执行多条 INSERT 语句,但是只插入指定的字段。批量新增指定字段可以提高数据操作效率,减少数据库与应用程序之间的网络传输次数,减轻数据库服务器的压力,提高系统的并发性能。

2. MyBatis-Plus 批量新增指定字段的方法的使用

mybatis-plus提供了InsertBatchSomeColumn批量insert方法。通过SQL 自动注入器接口 ISqlInjector注入通用方法 SQL 语句 然后继承 BaseMapper 添加自定义方法,全局配置 sqlInjector 注入 MP 会自动将类所有方法注入到 mybatis 容器中。我们需要通过这种方式注入下。
 

/**
 * 自定义Sql注入
 *
 */

public class EasySqlInjector extends DefaultSqlInjector 
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) 
        // 注意:此SQL注入器继承了DefaultSqlInjector(默认注入器),调用了DefaultSqlInjector的getMethodList方法,保留了mybatis-plus的自带方法
        List<AbstractMethod> methodList = super.getMethodList(mapperClass, tableInfo);
        methodList.add(new InsertBatchSomeColumn(i -> i.getFieldFill() != FieldFill.UPDATE));
        return methodList;
    

 自定义EasyBaseMapper

/**
 * @author 武天
 * @date 2023/3/22 18:19
 */
public interface EasyBaseMapper<T> extends BaseMapper<T> 
    /**
     * 批量插入 仅适用于mysql
     *
     * @param entityList 实体列表
     * @return 影响行数
     */
    Integer insertBatchSomeColumn(Collection<T> entityList);

 mapper继承的这里改为继承刚书写的自定义mapper

/**
 * 
 * 
 * @author wutian
 * @email $email
 * @date 2022-10-03 22:12:06
 */
@Mapper
public interface BreakDownCluesDao extends EasyBaseMapper<ExcelBreakdownClues> 

调用测试

 

可以看到明显的区别,sql只执行了一条,后面都是入参逗号相隔的形式,耗时也有所优化,这是插入数量不是很多的情况,如果是上万条或者上千万条数据,这种形式的优越性就体现出来了 

3. 批量新增指定字段的注意事项

在使用 MyBatis-Plus 进行批量新增指定字段时,需要注意以下几点:

  • 每次新增的数据量不要过大,建议每批次新增的数据量控制在 1000 条以内。
  • 要新增的指定字段不能为 null,需要手动设置默认值。
  • 如果要新增的指定字段在实体类中有对应的字段值,会被忽略。

以上是关于MyBatis-Plus的saveBatch批量插入为何效率很低耗时长详解及解决方案的主要内容,如果未能解决你的问题,请参考以下文章

MyBatissaveBatch 性能调优

mysql小技能:批量插入性能优化

mybatis-plus解决 sqlserver批量插入list报错

mybatis-plus解决 sqlserver批量插入list报错

Mybatis-Plus批量插入数据太慢,使用rewriteBatchedStatements属性优化,堪称速度与激情!

记录mybatis-plus多数据源批量操作时,数据源切换失效之谜