mybatis-plus批量插入你用对了吗

2022-11-26 14:40:40 浏览数 (1)

一次代码review,大佬说了算

记得有一次我们小组code review,组长看了下我们批量插入是使用mybatis原生的xml foreach实现的,于是二话不说,拍桌子,说这有性能问题。叫我们直接使用mybatis-plus,可是为啥呢?怎么用,需要注意哪些地方,也没给我们说个明白。好吧,我们对这一块也没具体调研过,就直接按他的想法去实现了。性能有没有提升了好几倍呢,其实也没实践过,反正review过了。直到有一天。。。

mybatis-plus批量插入就一定比mybatis原生foreach强?

实践是检验真理的唯一标准,我们分别使用mp批量插入方法和mybatis foreach来验证

相关环境准备

引入 mybatis-plus

代码语言:txt复制
   <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.2.0</version>
        </dependency>

mapper及对应xml

代码语言:txt复制
public interface UserMapper extends BaseMapper<UserInfo> {
    /**
     * 原生批量插入
     * @param list
     * @return
     */
    int saveBatchByNative(@Param("list") List<UserInfo> list);
}
代码语言:txt复制
    <insert id="saveBatchByNative" >
        INSERT INTO `t_user`(`name`,`age`,`descr`) VALUES
        <foreach collection="list" separator="," item="item">
            (#{item.name},#{item.age},#{item.descr})
        </foreach>
    </insert>

按照惯例,controller及service 调用

代码语言:txt复制
@Service("mybatisUserService")
public class UserService extends ServiceImpl<UserMapper,UserInfo> {

    @Autowired
    private UserMapper userMapper;

    @Transactional(rollbackFor = Exception.class)
    public void insertBatchByPlus(int maxInsert){
        List<UserInfo> users = getUsers(maxInsert);
        long start = System.currentTimeMillis();
        boolean insert = this.saveBatch(users,1000);
        System.out.println("mp batch insert row :" maxInsert " and spend time(ms) :" (System.currentTimeMillis()-start));

    }

    @Transactional(rollbackFor = Exception.class)
    public void insertBatchByNative(int maxInsert){
        List<UserInfo> users = getUsers(maxInsert);
        long start = System.currentTimeMillis();
        int insert = userMapper.saveBatchByNative(users);
        System.out.println("native batch insert row :" insert " and spend time(ms) :" (System.currentTimeMillis()-start));

    }



@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/insert")
    public void addUser(@RequestParam(name = "max") int max){
//        userService.insert();

        userService.insertBatchByNative(max);

        userService.insertBatchByPlus(max);
    }

}

测试结果

分别测 10000 、50000,执行结果截图

image.pngimage.png
image.pngimage.png

意外吧!基于前面的环境,mybatis-plus并没有占优势,反而慢得离谱。难道大佬是瞎说的?其实也不全是。。

开启批量插入

数据源配置url参数加上 rewriteBatchedStatements=true,如

代码语言:txt复制
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/study_01?characterEncoding=utf-8&useSSL=false&serverTimezone=UTC&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxxxx

再次测试结果

还是一样,分别测试10000、50000 ,执行结果截图

image.pngimage.png

咋一看,mybatis并没有差太多。于是,再调下,mp 的batchsize成2000(前面设置了1000)

image.pngimage.png

这不太对,并非越大越好。于是,调低 ,batchsize = 200

image.pngimage.png

是有一丢丢优势,不过,也并非batchsize越小越好,因为越小不就是和one by one 差不多了吗

难道,开了批量参数rewriteBatchedStatements,mp就一定比mybatis快了?也并非如此,看下

image.pngimage.png

可能遇到的问题

  1. 关于max_allowed_packet的配置,异常表示当前发送的包太大了
image.pngimage.png

可通过my.cnf 或者 set global max_allowed_packet = 10485760 配置,它们的区别在于重启之后配置的有效性

粗略看下执行流程

mybatis-plus 批量实现

代码语言:txt复制
  @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean saveBatch(Collection<T> entityList, int batchSize) {
        String sqlStatement = sqlStatement(SqlMethod.INSERT_ONE);
        try (SqlSession batchSqlSession = sqlSessionBatch()) {
            int i = 0;
            for (T anEntityList : entityList) {
                batchSqlSession.insert(sqlStatement, anEntityList);
                if (i >= 1 && i % batchSize == 0) {
                    //按我们传入的batchsize 分批插入
                    //不过,是否真的分批还得往下看,也有可能一种假象
                    batchSqlSession.flushStatements();
                }
                i  ;
            }
            batchSqlSession.flushStatements();
        }
        return true;
    }

最终走到 com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchInternal

代码语言:txt复制
@Override
    protected long[] executeBatchInternal() throws SQLException {
        synchronized (checkClosed().getConnectionMutex()) {
            //省略一些无关代码

            try {
                //省略一些无关代码
                // batchHasPlainStatements 默认为false 
                if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {

                    if (((PreparedQuery<?>) this.query).getParseInfo().canRewriteAsMultiValueInsertAtSqlLevel()) {
                        return executeBatchedInserts(batchTimeout);
                    }

                    if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
                            && this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
                        return executePreparedBatchAsMultiStatement(batchTimeout);
                    }
                }
                //如果不开启 rewriteBatchedStatements 则走这个逻辑
                return executeBatchSerially(batchTimeout);
            } finally {
                this.query.getStatementExecuting().set(false);

                clearBatch();
            }
        }
    }

先看下executeBatchSerially 方法,作者对这个方法的解释是

image.pngimage.png
代码语言:txt复制
 protected long[] executeBatchSerially(int batchTimeout) throws SQLException {

        synchronized (checkClosed().getConnectionMutex()) {
          
              // 省略一些无关代码

              //nbrCommands 其实就是 batchsize,
              // 所以对于该方法,本质就是通过遍历,一条一条插入。

                    for (batchCommandIndex = 0; batchCommandIndex < nbrCommands; batchCommandIndex  ) {

                          // 省略一些无关代码

                        // 关注下 executeUpdateInternal 这个方法很重要,可以理解为一次跟server端交互对sql的执行过程
>) arg;
                                updateCounts[batchCommandIndex] = executeUpdateInternal(queryBindings, true);

                              // 省略一些无关代码

                 
                    }

            return (updateCounts != null) ? updateCounts : new long[0];
        }
    }

再看下com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchedInserts,同样,看下解释

image.pngimage.png
代码语言:txt复制
protected long[] executeBatchedInserts(int batchTimeout) throws SQLException {

           // 省略一些无关代码

                     for (int i = 0; i < numberArgsToExecute; i  ) {

                       // numValuesPerBatch == numberArgsToExecute 就不会满足
                        if (i != 0 && i % numValuesPerBatch == 0) {
                            //一般不会执行到这里,除非批量执行sql 的 size 超过了 max_allowed_packet
                            try {
                                updateCountRunningTotal  = batchedStatement.executeLargeUpdate();
                            } catch (SQLException ex) {
                                sqlEx = handleExceptionForBatch(batchCounter - 1, numValuesPerBatch, updateCounts, ex);
                            }

                            getBatchedGeneratedKeys(batchedStatement);
                            batchedStatement.clearParameters();
                            batchedParamIndex = 1;

                        }
                        //通过遍历给每个sql进行参数设置
                        batchedParamIndex = setOneBatchedParameterSet(batchedStatement, batchedParamIndex, this.query.getBatchedArgs().get(batchCounter  ));
                    }

                    try {
                        //执行批量插入,其实也就是调用了executeUpdateInternal方法
                        updateCountRunningTotal  = batchedStatement.executeLargeUpdate();
                    } catch (SQLException ex) {
                        sqlEx = handleExceptionForBatch(batchCounter - 1, numValuesPerBatch, updateCounts, ex);
                    }

       // 省略一些无关代码

}



   /**
     * JDBC 4.2
     * Same as PreparedStatement.executeUpdate() but returns long instead of int.
     */
    public long executeLargeUpdate() throws SQLException {
        return executeUpdateInternal(true, false);
    }

可以看出 executeBatchSerially和executeBatchedInserts 最终都调用 executeUpdateInternal方法(可以简单的理解为jdbc和server服务端一次通信的过程),区别在于是否通过遍历一条一条的发送;

mybatis foreach 执行过程

由于引入的mybatis-plus ,mapper代理类是MybatisMapperProxy

代码语言:txt复制
   @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            if (Object.class.equals(method.getDeclaringClass())) {
                return method.invoke(this, args);
            } else if (method.isDefault()) {
                return invokeDefaultMethod(proxy, method, args);
            }
        } catch (Throwable t) {
            throw ExceptionUtil.unwrapThrowable(t);
        }
        final MybatisMapperMethod mapperMethod = cachedMapperMethod(method);
        return mapperMethod.execute(sqlSession, args);
    }

最终走到com.mysql.cj.jdbc.ClientPreparedStatement#execute

代码语言:txt复制
 public boolean execute() throws SQLException {
// 省略一些无关代码

Message sendPacket = ((PreparedQuery<?>) this.query).fillSendPacket();

// 一次打包发送到server端
  rs = executeInternal(this.maxRows, sendPacket, createStreamingResultSet(),
                    (((PreparedQuery<?>) this.query).getParseInfo().getFirstStmtChar() == 'S'), cachedMetadata, false);

// 省略一些无关代码

 }

可以看出,对于mybatis foreach 批量插入,直接就是一次交互过程,跟是否开启rewriteBatchedStatements无关,唯一有关的就是max_allowed_packet的设置

最后

  1. mybatis原生foreach批量插入,抛开网络阻塞问题,其耗时并非想的差劲。但是,为何在大数据量批量插入场景下不推荐使用,可能就是考虑网络阻塞以及server端处理涉及到长事务问题吧;
  2. 使用mybatis-plus 批量插入,需要开启rewriteBatchedStatements,且合理设置batchsize,该参数会影响客户端与服务端通信交互次数。如果batchsize太大,超过最大packet,jdbc底层还是会再一次分批,反而影响性能;
  3. 批量操作用原生还是mybatis-plus?我觉得应该有个范围,比如实际场景一次批量插入最多也就几百条且size也比较小,那使用原生的问题也不大

0 人点赞