慎用MyBatis Foreach 批量插入几千条数据

<insert id="batchInsert" parameterType="java.util.List">
    insert into USER (id, name) values
    <foreach collection="list" item="model" index="index" separator=","> 
        (#{model.id}, #{model.name})
    </foreach>
</insert>

乍看上去这个foreach没有问题,但是经过项目实践发现,当表的列数较多(20+),以及一次性插入的行数较多(5000+)时,整个插入的耗时十分漫长。

在Mybatis Foreach中插入不是批处理,这是一条单一的(可能会变得巨大的)SQL语句,这带来了缺点:

某些数据库(例如此处的Oracle)不支持。
在相关情况下:将有大量记录要插入,并且将达到数据库配置的限制(默认情况下,每条语句大约2000个参数),如果语句本身变得太大,最终可能会出现DB堆栈错误。
不能在mybatis XML中对集合进行迭代。

其实,在内部它仍然生成与上面的JDBC代码相同的带有多个占位符的单个INSERT语句。

MyBatis能够缓存PreparedStatement,但该语句不能缓存,因为它包含元素,并且该语句根据参数的不同而不同。
因此,MyBatis必须计算Foreach部分,在每次执行此语句时解析语句字符串以构建参数映射。
当语句字符串很大并且包含许多占位符时,这些步骤的处理成本相对较高。

查阅资料可知,mybatis默认执行器类型为Simple,会为每个语句创建一个新的预处理语句,也就是创建一个PreparedStatement对象。在我们的项目中,会不停地使用批量插入这个方法,而因为MyBatis对于含有PreparedStatement的语句,无法采用缓存,那么在每次调用方法时,都会重新解析sql语句。
当foreach后有5000+个values,PreparedStatement特别长,包含了很多占位符,对于占位符和参数的映射尤其耗时。values的增长与所需的解析时间,是呈指数型增长的。

所以,如果非要使用 foreach 的方式来进行批量插入的话,可以考虑减少一条 insert 语句中 values 的个数,最好能达到上面曲线的最底部的值,使速度最快。一般来说,一次性插20~50行数量是比较合适的,时间消耗也能接受。

重点来了。上面讲的是,如果非要用的方式来插入,可以提升性能的方式。而实际上,MyBatis文档中写批量插入的时候,是推荐使用另外一种方法

SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);
try {
    SimpleTableMapper mapper = session.getMapper(SimpleTableMapper.class);
    List<SimpleTableRecord> records = getRecordsToInsert(); // not shown
 
    BatchInsert<SimpleTableRecord> batchInsert = insert(records)
            .into(simpleTable)
            .map(id).toProperty("id")
            .map(firstName).toProperty("firstName")
            .map(lastName).toProperty("lastName")
            .map(birthDate).toProperty("birthDate")
            .map(employed).toProperty("employed")
            .map(occupation).toProperty("occupation")
            .build()
            .render(RenderingStrategy.MYBATIS3);
 
    batchInsert.insertStatements().stream().forEach(mapper::insert);
 
    session.commit();
} finally {
    session.close();
}

与默认的ExecutorType.SIMPLE不同,该语句只准备一次,然后针对要插入的每条记录执行。