【抽丝剥茧知识讲解】【沉浸式解决问题】自定义MyBatis-Plus 3.5.12中的BaseMapper,并实现真正的批量插入

目录

前言

在Spring Cloud项目中使用了MyBatis-Plus的BaseMapper,在执行mp自带的insert方法时,速度非常慢,发现即使传入一个list,其底层是通过循环遍历list依次插入的,并没有实现mysql支持的insert 多个 value连接的批量插入方式。
同时我还需要自定义创建一些公共方法,所以选择创建一个自定义的MyBaseMapper,增加批量插入、截断表等方法。

环境版本

  • idea:IntelliJ IDEA 2019.3.4 (Ultimate Edition)
  • Java:1.8
  • mysql:5.7.33
  • mysql-connector-j:9.3.0
  • spring-boot-starter:2.7.18
  • mybatis-plus-boot-starter:3.5.12

一、BaseMapper 伪批量插入

在使用BaseMapper的insert方法时,传入了一个List列表(3.5.7新增),但是刷新数据库,看到数据库的数量是一点点增长的,数据量一直都不是整数,所以就猜测MyBatis-Plus的insert是伪批量插入,下面一起查看一下源代码

1. 测试代码

@SpringBootTest
class UserTests {
    @Autowired
    UserMapper userMapper;

    @Test
    void test1() {
        List<User> userList = new ArrayList<>();
        userMapper.insert(userList);
    }
}

由于后面都是源码,用截图更方便一点,按住ctrl点击insert进行跳转
在这里插入图片描述

2. BaseMapper

可以看到默认批处理大小为1000,通过重载到下面的全参数方法上,这是一种常见的设置默认值的思路,接下来通过一个批处理工具类执行,继续点击下面方法的execute
在这里插入图片描述

3. MybatisBatchUtils

这里创建了一个批处理对象,又补充了一些执行参数,继续点击execute
在这里插入图片描述

4. MybatisBatch

终于到了执行方法了,重载到完整参数方法上,可以看到有两层for循环,第一层是分批后的结果列表,第二层是每一批内部进行遍历,取出每个对象后调用sqlSession.update方法,一批数据flush一次
在这里插入图片描述


二、IService 批量插入

那你可能会想到,mp的IService中也提供了批量插入方法,还是明确的叫saveBatch方法,那么它的底层是不是真批量插入呢,先测试一下看看日志

1. 测试代码

    @Autowired
    IUserService userService;

    @Test
    void test4() {
        List<User> userList = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            User user = new User();
            user.setName("姓名"+i);
            userList.add(user);
        }
        userService.saveBatch(userList,2);
    }

【沉浸式解决问题】baseMapper can not be null
这里遇到两个新的问题,解决如下:
【沉浸式解决问题】Property ‘sqlSessionFactory‘ or ‘sqlSessionTemplate‘ are required

开启mybatis-plus日志

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

可以看到虽然5条数据分了3批,但每批都使用的预处理语句,参数依次传输,正常就是依次执行
在这里插入图片描述
再带大家看一下源码,按住ctrl点击saveBatch方法
在这里插入图片描述

2. IService

IService继承了IRepository,添加默认批处理大小1000,代入IRepository中的重载方法
在这里插入图片描述

3. IRepository

这是父接口了,再往下看要找它的实现方法,点击方法左边的绿色箭头
在这里插入图片描述

4. CrudRepository

添加一个sql语句函数,再代入它继承的AbstractRepository类中的批处理执行器
在这里插入图片描述

5. AbstractRepository

再传递给SqlHelper的批处理执行器
在这里插入图片描述

6. SqlHelper

外面一层for循环遍历列表,内部if判断当达到批处理大小时就flush一批数据,具体就是交给mybatis通过预处理语句去实现了,后面再更新一下批处理大小,用于调整最后一批。
在这里插入图片描述


三、JDBC源码追踪

最开始找到了mp提供的sql注入器,可以在自定义BaseMapper中注入自定义方法;
又尝试了创建一个 MyBaseMapper.xml,在里面实现自定义方法;
最后找到了通过jdbc参数实现真正批量;
这里为了更有逻辑把顺序反过来讲解。

这一步其实刚进入mybatis,还没有真正发给jdbc进行执行,中间还有很多过程,有没有可能再中间把它转换成真正的批处理呢,像insert value这种形式,让我们继续深入。
紧接着上面的代码调用,继续往下看,点击上一步中sqlSessionflushStatements()方法,开始跟踪

1. SqlSession

SqlSession是sql会话的接口类,点击方法左边的箭头显示实现方法,有三个实现类,一个是默认的,一个是管理的,一个是模板,猜也能猜出来是第一个DefaultSqlSession
在这里插入图片描述

那如果不知道是哪个怎么办呢,正常可以通过查看接口注入的地方,但是框架一般比较复杂,不太容易一下找到具体实现位置,我们用一个简单的办法,通过断点来判断,下面演示一下

2. DefaultSqlSession

在方法的第一行打上断点
在这里插入图片描述
在测试方法左边选择Debug
在这里插入图片描述
可以看到成功的停在断点处了,说明我们走的确实是这个实现类
在这里插入图片描述
会话调用执行器,继续点击执行器的flushStatements()方法
在这里插入图片描述

3. Executor

执行器同样先是一个接口类,点击方法左侧,又是两个实现类,一个是基础,一个是缓存,不用看,肯定是base,也可以通过断点的方法验证
在这里插入图片描述

4. BaseExecutor

进入BaseExecutor,第一个方法代入一个回滚参数,传入重载方法,再次点击doFlushStatements
在这里插入图片描述
还在BaseExecutor类中,doFlushStatements是一个抽象方法,有四个子类都实现了它,根据意思判断应该就是第一个批处理执行器,但是这个就没那么肯定了
在这里插入图片描述

5. BatchExecutor

打断点验证一下,没什么问题。这里经过一些判断,最核心的是stmt.executeBatch(),而stmt是jdbc的Statement对象,点击executeBatch()方法
在这里插入图片描述

6. Statement

进入Statement接口就正式从mybaits到了jdbc的部分了,大家最开始从0学Java的时候应该都写过用jdbc访问数据库
点击左边的箭头查看实现类,这里就没办法直接判断了,给每个类都打断点后发现走的是ProxyStatement,一个代理类,点击它
在这里插入图片描述

7. ProxyStatement

进入代理类后无法继续追踪了,点击方法中的delegate.executeBatch()会回到Statement接口,
点击左边查看子类,三个子类都不进入,只能通过debug查看了
在这里插入图片描述
打上断点,直接执行到这一步
在这里插入图片描述
F7或者点击进入方法,同时在这里可以看到,代理走的是Hikar连接池的预处理语句,也就是刚刚三个子类中的第二个
在这里插入图片描述
暂停查看一下HikariProxyPreparedStatement类,打上断点后提示Line numbers info is not available,应该是行序号不匹配的原因,点击右上角下载资源也不行
在这里插入图片描述

8. StatementImpl

刚刚按F7或者点击进入方法,来到了sql语句的实现类
继续点击debug窗口的Step Into,选择进入第二个方法
在这里插入图片描述

9. ClientPreparedStatement

来到了预处理语句的客户端,先贴个executeBatchInternal()源代码展示一下

    @Override
    protected long[] executeBatchInternal() throws SQLException {
        Lock connectionLock = checkClosed().getConnectionLock();
        connectionLock.lock();
        try {
            TelemetrySpan span = getSession().getTelemetryHandler().startSpan(TelemetrySpanName.STMT_EXECUTE_BATCH_PREPARED);
            try (TelemetryScope scope = span.makeCurrent()) {
                span.setAttribute(TelemetryAttribute.DB_NAME, this::getCurrentDatabase);
                span.setAttribute(TelemetryAttribute.DB_OPERATION, TelemetryAttribute.OPERATION_BATCH);
                span.setAttribute(TelemetryAttribute.DB_STATEMENT, TelemetryAttribute.OPERATION_BATCH);
                span.setAttribute(TelemetryAttribute.DB_SYSTEM, TelemetryAttribute.DB_SYSTEM_DEFAULT);
                span.setAttribute(TelemetryAttribute.DB_USER, () -> this.connection.getUser());
                span.setAttribute(TelemetryAttribute.THREAD_ID, () -> Thread.currentThread().getId());
                span.setAttribute(TelemetryAttribute.THREAD_NAME, () -> Thread.currentThread().getName());

                if (this.connection.isReadOnly()) {
                    throw new SQLException(Messages.getString("PreparedStatement.25") + Messages.getString("PreparedStatement.26"),
                            MysqlErrorNumbers.SQLSTATE_CONNJ_ILLEGAL_ARGUMENT);
                }

                if (this.query.getBatchedArgs() == null || this.query.getBatchedArgs().size() == 0) {
                    return new long[0];
                }

                // we timeout the entire batch, not individual statements
                long batchTimeout = getTimeoutInMillis();
                setTimeoutInMillis(0);

                resetCancelledState();

                try {
                    statementBegins();

                    clearWarnings();

                    if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {

                        if (getQueryInfo().isRewritableWithMultiValuesClause()) {
                            return executeBatchWithMultiValuesClause(batchTimeout);
                        }

                        if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
                                && this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
                            return executePreparedBatchAsMultiStatement(batchTimeout);
                        }
                    }

                    return executeBatchSerially(batchTimeout);
                } finally {
                    this.query.getStatementExecuting().set(false);
                    setTimeoutInMillis(batchTimeout);
                    clearBatch();
                }
            } catch (Throwable t) {
                span.setError(t);
                throw t;
            } finally {
                span.end();
            }
        } finally {
            connectionLock.unlock();
        }
    }

前面都是一些参数设置和判断,我们直接找到最中心的执行部分
一共有三个return,分不同情况继续调用执行方法

  1. multi-values:批量插入,调用executeBatchWithMultiValuesClause
  2. multi-statement:多语句批处理,调用executePreparedBatchAsMultiStatement
  3. one-by-one:串行批处理,调用executeBatchSerially

找了一篇对这三种方法进行详解的文章,想要了解的点击查看:
MySQL JDBC 实战: PreparedStatement rewriteBatchedStatements 实现原理
在这里插入图片描述
到这里我们就可以发现,JDBC实际上是提供了真正的批量插入的,通过batchHasPlainStatements参数和rewriteBatchedStatements进行判断,上图可以看到,正常就是false,前面有个感叹号非运算之后就是true,就只剩下rewriteBatchedStatements参数了,就是重写批处理语句的意思,查阅得知这个是在url中进行设置即可

这里点击下一步就跳到了串行批处理
在这里插入图片描述


四、JDBC 批量插入

那么我们就可以通过jdbc实现真批量插入了,由于BaseMapper.insert(Collection<T> entityList)IService.(Collection<T> entityList)都是调用了mybatis的SqlSession.flushStatements(),所以设置了这个参数以后两个方法就都实现真正的批量插入了,当然也包括我们直接调用jdbc的预处理语句。

在yml中修改url,添加&rewriteBatchedStatements=true

spring:
  # 数据库信息
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/ybws_test?useSSL=false&rewriteBatchedStatements=true
    username: root
    password: root

注意事项:

  • 事务一致性‌:批量操作仍受事务管理,失败时整体回滚。‌‌
  • SQL长度限制‌:合并后SQL可能超max_allowed_packet,需调整该参数。‌‌

都调试到这里了,索性继续深入,看看JDBC内部是怎么实现重写的

1. ClientPreparedStatement

1.1 executeBatchInternal() 420行

修改url后取消前面的断点,debug执行可以看到进入了重写语句的return
在这里插入图片描述

1.2 executeBatchWithMultiValuesClause() 687行

点击Step Into进入批量处理方法,翻译一下方法注释,可以看到除了批量insert插入还可以批量replace更新

Rewrites the already prepared statement into a multi-values clause INSERT/REPLACE statement and executes the entire batch using this new statement
将已准备好的语句重写为多值子句INSERT/REPLACE语句,并使用此新语句执行整个批处理。

往下看,先是创建了一个批处理语句对象
在这里插入图片描述
然后对它初始化,传入批处理大小
在这里插入图片描述
把这个变量添加到Watches区域,点击右上角的View,可以看到已经处理成空白语句,参数位置有默认的1000个括号
在这里插入图片描述
再往下就是重写部分了,先循环1000个参数

  1. 判断是否到达一批1000,达到就执行一批,粗略的看感觉一般用不上,i是从0开始的,不是特殊情况不会到达最大值
  2. 没有到达就一个个替换参数
    在这里插入图片描述
    我们往下执行进入setOneBatchedParameterSet方法查看一下
    在这里插入图片描述

1.3 setOneBatchedParameterSet() 647行

进入后执行一步,创建一个BindValue[]数组,用来存放一行数据各个字段对应的值,下面的for循环对各个字段依次进行赋值
在这里插入图片描述
查看变量区域,由于我们就测试了一个字段,所以只有一个姓名0
在这里插入图片描述

1.2 executeBatchWithMultiValuesClause() 687行

往下执行返回到上一步的方法,再次查看batchedStatement,可以看到已经合并了第一个
在这里插入图片描述
取消for循环的断点,快进到for循环之后,一批参数都完成合并
在这里插入图片描述
再次查看batchedStatement,可以看到已经变成了insert values的格式,此时再执行就是真正的批量插入了
在这里插入图片描述
重写的代码就看完啦,往后再看一步,F7继续往下走

1.4 executeLargeUpdate() 1449行

继续F7进入
在这里插入图片描述

1.5 executeUpdateInternal() 1094行

在重载方法处打上断点,F9快进到这里,然后F7选择executeUpdateInternal
在这里插入图片描述

1.6 executeUpdateInternal() 1122行

前面设置了很多东西,我们在执行处打上断点,F9快进到这里,然后F7进入
在这里插入图片描述

1.7 ResultSetInternalMethods() 942行

找到执行的地方打上断点,F9快进到这里,然后F7进入execSQL
在这里插入图片描述

2. NativeSession

接下来就是和mysql关系不大了,再往深就是通信部分了,我们找到核心语句
调用this.protocol.sendQueryPacket发送查询数据包,里面使用BufferedOutputStream输出数据流,感兴趣的可以自行深入一下
在这里插入图片描述
这里额外提一句,protocol是协议,用的什么协议呢,翻到NativeSession类的最上面
没错,正是Socket协议,JDBC的底层就是使用Socket进行连接数据库的,所以后面就是使用OutputStream,再通过TCP发送到MySQL的server,有时间写一篇JDBC通信解析。
在这里插入图片描述
【TODO】JDBC底层通信原理解析


五、MyBaseMapper.xml

通过JDBC实现批量插入的探索就告一段落了,接下来回到正题,除了批量插入还有很多别的公共方法,比如最简单的截断表,所以想尝试下能否通过创建MyBaseMapper.classMyBaseMapper.xml实现,在MyBaseMapper.xml中编写自定义方法。

1. 方案1

1.1 MyBaseMapper.class

先创建个自定义的BaseMapper,继承Mybatis-Plus的BaseMapper,添加一个截断方法

package com.baomidou.base;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;

public interface MyBaseMapper<T> extends BaseMapper<T> {
    // 截断表
    void truncateTable();
}

1.2 MyBaseMapper.xml

无论是kimi还是deepseek,都提到了可以动态获取表名,MyBatis-Plus会自动获取实体类上的注解并进行替换,只不过我没找到这一说法的来源。。。。。。
在这里插入图片描述
先这么写试试

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.baomidou.base.MyBaseMapper">
    <!-- 清空表-->
    <update id="truncateTable">
        TRUNCATE TABLE ${tableName}
    </update>
</mapper>

1.3 User.class

package com.baomidou.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("user")
public class User {
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    @TableField("name")
    private String name;
}

1.4 UserMapper.class

package com.baomidou.mapper;

import com.baomidou.base.MyBaseMapper;
import com.baomidou.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends MyBaseMapper<User> {
}

1.5 UserMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.baomidou.mapper.UserMapper">
</mapper>

1.6 DemoApplication.class

package com.baomidou;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
}

1.7 UserTest.class

package com.baomidou;

import com.baomidou.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class UserTest {
    @Autowired
    UserMapper userMapper;

    @Test
    void test1() {
        userMapper.truncateTable();
    }
}

运行test1()后报错sql语句不正确,可以看到${tableName}并没有自动替换,也就是说整体逻辑是可行的,只是不能自动替换表名,那就没法做公共方法
在这里插入图片描述
多次尝试后,找到一种解决方案

2. 方案2

缺省的部分和方案1相同

2.1 MyBaseMapper.class

添加一个参数传递实体类

package com.baomidou.base;

import com.baomidou.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;


public interface MyBaseMapper<T> extends BaseMapper<T> {
    // 截断表
    void truncateTable(@Param("entityClass") Class<T> entityClass);
}

2.2 MyBaseMapper.xml

通过参数获取实体类,通过mp的方法获取表名,再代入${tableName}

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.baomidou.base.MyBaseMapper">
    <!-- 清空表-->
    <update id="truncateTable">
    	<bind name="tableName" value="@com.baomidou.mybatisplus.core.metadata.TableInfoHelper@getTableInfo(entityClass).tableName"/>
        TRUNCATE TABLE ${tableName}
    </update>
</mapper>

2.7 UserTest.class

package com.baomidou;

import com.baomidou.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class UserTest {
    @Autowired
    UserMapper userMapper;

    @Test
    void test1() {
        userMapper.truncateTable(User.class);
    }
}

运行后日志正确,数据库也完成清空
在这里插入图片描述
尝试后没有找到其他获取表名的方法,所以如果都采用方案2,那就需要所有的方法都传入实体类,这肯定不太合理,你UserMapper继承MyBaseMapper的时候就传递了泛型,这时候还要我再传一遍,其实等价于传入一个表名了,如果我还要考虑逻辑删除,不可能我再传入一个参数

3. 方案3

提一下注解的方式,也无法实现,连通过实体类获取表名都做不到了。

六、MyBatis-Plus SQL注入器

以上方法核心的问题是在xml中无法获得表的相关信息,那你肯定能想到,我们在Java中编写不就好了,如你所想,MyBatis-Plus官方提供了SQL注入器,这通过 sqlInjector 全局配置实现。通过实现 ISqlInjector 接口或继承 AbstractSqlInjector 抽象类,你可以注入自定义的通用方法到 MyBatis 容器中。

MyBatis-Plus官网 SQL注入器
包含以下四个步骤:

    1. 自定义 BaseMapper
    1. 自定义 SQL方法
    1. 自定义 SQL注入器,注册自定义方法
    1. 配置 SQL注入器

1. 自定义 BaseMapper

创建自定义BaseMapper如MyBaseMapper,继承BaseMapper,声明自定义SQL方法

package com.baomidou.base;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface MyBaseMapper<T> extends BaseMapper<T> {

    // 截断表
    void truncateTable();

    // 批量插入(返回成功插入行数)
    int insertBatch(@Param("list") List<T> entityList);
}

2. 自定义 SQL方法

每个自定义SQL方法需要创建一个实现类,继承AbstractMethod,在里面重写SQL的实现代码

2.1 截断表 TruncateTableMethod

package com.baomidou.method;

import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

public class TruncateTableMethod extends AbstractMethod {
    public TruncateTableMethod() {
        // 设置方法名为 MyBaseMapper 中对应自定义SQL方法名
        super("truncateTable");
    }

    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 自定义SQL语句
        String sql = "TRUNCATE TABLE " + tableInfo.getTableName();
        // 把SQL语句用 <script> 标签包起来创建一个sql源对象,平常 mybatis 用来读取xml中的SQL语句
        SqlSource sqlSource = super.createSqlSource(configuration, "<script>" + sql + "</script>", modelClass);
        // this.methodName 如果在构造方法中设置了方法名,这里可以省略;如果没有,这里必须传入 MyBaseMapper 中对应自定义SQL方法名
        return this.addDeleteMappedStatement(mapperClass, this.methodName, sqlSource);
    }
}

2.2 批量插入 MyInsertBatchMethod

package com.baomidou.method;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils;
import org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator;
import org.apache.ibatis.executor.keygen.KeyGenerator;
import org.apache.ibatis.executor.keygen.NoKeyGenerator;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

import java.util.stream.Collectors;

public class MyInsertBatchMethod extends AbstractMethod {
    public MyInsertBatchMethod() {
        // 设置方法名为 MyBaseMapper 中对应自定义SQL方法名
        super("myInsertBatch");
    }

    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 定义SQL语句 (主键+普通字段)  insert into table(....) values(....),(....)
        String sql = "INSERT INTO " + tableInfo.getTableName() + "(" + tableInfo.getKeyColumn() + "," +
                tableInfo.getFieldList().stream().map(TableFieldInfo::getColumn).collect(Collectors.joining(",")) + ") VALUES ";
        String value = "(" + "#{" + ENTITY + DOT + tableInfo.getKeyProperty() + "}" + ","
                + tableInfo.getFieldList().stream().map(tableFieldInfo -> "#{" + ENTITY + DOT + tableFieldInfo.getProperty() + "}")
                .collect(Collectors.joining(",")) + ")";
        String valuesScript = SqlScriptUtils.convertForeach(value, "list", null, ENTITY, COMMA);
        SqlSource sqlSource = super.createSqlSource(configuration, "<script>" + sql + valuesScript + "</script>", modelClass);
        KeyGenerator keyGenerator = tableInfo.getIdType() == IdType.AUTO ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
        // 第三个参数必须和 baseMapper 的自定义方法名一致
        return this.addInsertMappedStatement(mapperClass, modelClass, this.methodName, sqlSource, keyGenerator,tableInfo.getKeyProperty(), tableInfo.getKeyColumn());

    }
}

3. 自定义 SQL注入器,注册自定义方法

接下来,你需要创建一个自定义 SQL注入器类,通过继承DefaultSqlInjector,并重写getMethodList方法来注册你的自定义方法。

package com.baomidou.injector;

import com.baomidou.method.MyInsertBatchMethod;
import com.baomidou.method.TruncateTableMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import org.apache.ibatis.session.Configuration;

import java.util.List;

public class MySqlInjector extends DefaultSqlInjector {

    @Override
    public List<AbstractMethod> getMethodList(Configuration configuration, Class<?> mapperClass, TableInfo tableInfo) {
        List<AbstractMethod> methodList = super.getMethodList(configuration, mapperClass, tableInfo);
        methodList.add(new TruncateTableMethod());
        methodList.add(new MyInsertBatchMethod());
        return methodList;
    }
}

4. 配置 SQL注入器

到目前为止,自定义SQL方法还都只是在静态类中,还不能被mybatis识别到,以下三种配置方式选一种即可

4.1 MyBatisPlusConfig

通过@Bean注解将自定义的 Injector 注入 Spring 容器中,以替换默认的 Injector

package com.baomidou.config;

import com.baomidou.injector.MySqlInjector;
import com.baomidou.mybatisplus.core.injector.ISqlInjector;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {
    // 如果自定义了sqlSessionFactory,需要在sqlSessionFactory中进行设置。
    @Bean
    public ISqlInjector sqlInjector() {
        return new MySqlInjector();
    }
}

4.2 application.yml

mybatis-plus:
  global-config:
    sql-injector: com.example.MyLogicSqlInjector

4.3 application.properties

mybatis-plus.global-config.sql-injector=com.example.MyLogicSqlInjector

5. 注意事项

  • 在定义自定义方法时,确保方法名与注入的SQL语句中的ID一致。
  • 在使用自定义的批量插入和自动填充功能时,确保在Mapper方法的参数上使用@Param注解,并且命名符合MyBatis-Plus的默认支持(list, collection, array)。
  • 自定义的SQL语句应该根据你的业务需求来编写,确保它能够正确地执行你想要的操作。

6. 测试

package com.baomidou;

import com.baomidou.entity.User;
import com.baomidou.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.ArrayList;
import java.util.List;

@SpringBootTest
public class UserTest {
    @Autowired
    UserMapper userMapper;

    @Test
    void test1() {
        userMapper.truncateTable();
        List<User> userList = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            User user = new User();
            user.setName("姓名"+i);
            userList.add(user);
        }
        userMapper.myInsertBatch(userList);
    }
}

自定义方法截断表执行成功,自定义方法批量插入也是一次性传递所有参数
在这里插入图片描述

7. InsertBatchSomeColumn

在查找过程中,还发现了 Mybatis Plus 内部提供的默认批量插入,只不过这个方法作者只在 MySQL 数据测试过,所以没有将它作为通用方法供外部调用

package com.baomidou.mybatisplus.extension.injector.methods;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.sql.SqlInjectionUtils;
import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator;
import org.apache.ibatis.executor.keygen.KeyGenerator;
import org.apache.ibatis.executor.keygen.NoKeyGenerator;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

import java.util.List;
import java.util.function.Predicate;

/**
 * 批量新增数据,自选字段 insert
 * <p> 不同的数据库支持度不一样!!!  只在 mysql 下测试过!!!  只在 mysql 下测试过!!!  只在 mysql 下测试过!!! </p>
 * <p> 除了主键是 <strong> 数据库自增的未测试 </strong> 外理论上都可以使用!!! </p>
 * <p> 如果你使用自增有报错或主键值无法回写到entity,就不要跑来问为什么了,因为我也不知道!!! </p>
 * <p>
 * 自己的通用 mapper 如下使用:
 * <pre>
 * int insertBatchSomeColumn(List<T> entityList);
 * </pre>
 * </p>
 *
 * <li> 注意: 这是自选字段 insert !!,如果个别字段在 entity 里为 null 但是数据库中有配置默认值, insert 后数据库字段是为 null 而不是默认值 </li>
 *
 * <p>
 * 常用的 {@link Predicate}:
 * </p>
 *
 * <li> 例1: t -> !t.isLogicDelete() , 表示不要逻辑删除字段 </li>
 * <li> 例2: t -> !t.getProperty().equals("version") , 表示不要字段名为 version 的字段 </li>
 * <li> 例3: t -> t.getFieldFill() != FieldFill.UPDATE) , 表示不要填充策略为 UPDATE 的字段 </li>
 *
 * @author miemie
 * @since 2018-11-29
 */
public class InsertBatchSomeColumn extends AbstractMethod {

    /**
     * 字段筛选条件
     */
    @Setter
    @Accessors(chain = true)
    private Predicate<TableFieldInfo> predicate;

    /**
     * 默认方法名
     */
    public InsertBatchSomeColumn() {
        super("insertBatchSomeColumn");
    }

    /**
     * 默认方法名
     *
     * @param predicate 字段筛选条件
     */
    public InsertBatchSomeColumn(Predicate<TableFieldInfo> predicate) {
        super("insertBatchSomeColumn");
        this.predicate = predicate;
    }

    /**
     * @param name      方法名
     * @param predicate 字段筛选条件
     * @since 3.5.0
     */
    public InsertBatchSomeColumn(String name, Predicate<TableFieldInfo> predicate) {
        super(name);
        this.predicate = predicate;
    }

    @SuppressWarnings("Duplicates")
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        KeyGenerator keyGenerator = NoKeyGenerator.INSTANCE;
        SqlMethod sqlMethod = SqlMethod.INSERT_ONE;
        List<TableFieldInfo> fieldList = tableInfo.getFieldList();
        String insertSqlColumn = tableInfo.getKeyInsertSqlColumn(true, null, false) +
            this.filterTableFieldInfo(fieldList, predicate, TableFieldInfo::getInsertSqlColumn, EMPTY);
        String columnScript = LEFT_BRACKET + insertSqlColumn.substring(0, insertSqlColumn.length() - 1) + RIGHT_BRACKET;
        String insertSqlProperty = tableInfo.getKeyInsertSqlProperty(true, ENTITY_DOT, false) +
            this.filterTableFieldInfo(fieldList, predicate, i -> i.getInsertSqlProperty(ENTITY_DOT), EMPTY);
        insertSqlProperty = LEFT_BRACKET + insertSqlProperty.substring(0, insertSqlProperty.length() - 1) + RIGHT_BRACKET;
        String valuesScript = SqlScriptUtils.convertForeach(insertSqlProperty, "list", null, ENTITY, COMMA);
        String keyProperty = null;
        String keyColumn = null;
        // 表包含主键处理逻辑,如果不包含主键当普通字段处理
        if (tableInfo.havePK()) {
            if (tableInfo.getIdType() == IdType.AUTO) {
                /* 自增主键 */
                keyGenerator = Jdbc3KeyGenerator.INSTANCE;
                keyProperty = tableInfo.getKeyProperty();
                // 去除转义符
                keyColumn = SqlInjectionUtils.removeEscapeCharacter(tableInfo.getKeyColumn());
            } else {
                if (null != tableInfo.getKeySequence()) {
                    keyGenerator = TableInfoHelper.genKeyGenerator(this.methodName, tableInfo, builderAssistant);
                    keyProperty = tableInfo.getKeyProperty();
                    keyColumn = tableInfo.getKeyColumn();
                }
            }
        }
        String sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), columnScript, valuesScript);
        SqlSource sqlSource = super.createSqlSource(configuration, sql, modelClass);
        return this.addInsertMappedStatement(mapperClass, modelClass, methodName, sqlSource, keyGenerator, keyProperty, keyColumn);
    }

}

想使用它的话需要先在MyBaseMapper中声明
在这里插入图片描述

再在我们刚刚自定义的SQL注入器里面注册这个方法就行
在这里插入图片描述

8. 注入原理分析

偷个懒,搜索一篇放上来:
MyBatis-Plus BaseMapper 实现原理(SQL 注入器的使用及原理解析)

七、性能对比

总结一下前面提到过的所用批量插入方法:

  • for循环插入
  • BaseMapper.insert
  • IService.saveBatch
  • 自定义批量插入
  • InsertBatchSomeColumn

同时JDBC的rewriteBatchedStatements参数有true/false两种选择
最后进行测试对比一下时间,测试之前关闭mybatis-plus日志,避免控制台打印造成的影响

1. user表

为了提高难度并且更符合实际生产,给user表添加四个字段,创建时间默认CURRENT_TIMESTAMP,创建人id默认1,更新时间勾选根据当前时间戳更新,更新人id默认为null
在这里插入图片描述

2. 测试类

@SpringBootTest
public class UserTest {
    @Autowired
    UserMapper userMapper;
    
    @Autowired
    IUserService userService;

    void truncate() {
        userMapper.truncateTable(); // 每次测试前情况表
    }

    List<User> userList() {
        List<User> userList = new ArrayList<>();
        for (int i = 0; i < 300000; i++) {
            User user = new User();
            user.setName("姓名"+i);
            userList.add(user);
        }
        return userList;
    }
}

3. for循环插入

    @Test
    void test1() {
        truncate();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        for (User user : userList()) {
            userMapper.insert(user);
        }
        stopWatch.stop();
        System.out.println(stopWatch.getTotalTimeMillis());
    }

4. BaseMapper.insert

    @Test
    void test2() {
        truncate();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        userMapper.insert(userList());
        stopWatch.stop();
        System.out.println(stopWatch.getTotalTimeMillis());
    }

5. IService.saveBatch

    @Test
    void test3() {
        truncate();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        userService.saveBatch(userList());
        stopWatch.stop();
        System.out.println(stopWatch.getTotalTimeMillis());
    }

6. 自定义批量插入

    @Test
    void test4() {
        truncate();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        List<User> userList = userList();
        int batchSize = 1000; // 每批插入的大小
        int totalBatches = (int) Math.ceil((double) userList.size() / batchSize); // 计算批次数量
        for (int i = 0; i < totalBatches; i++) { // 按批次插入数据
            int start = i * batchSize; // 计算当前批次的起始和结束索引
            int end = Math.min(start + batchSize, userList.size());
            List<User> batch = userList.subList(start, end); // 获取当前批次的数据
            userMapper.myInsertBatch(batch);
        }
        stopWatch.stop();
        System.out.println(stopWatch.getTotalTimeMillis());
    }

7. InsertBatchSomeColumn

    @Test
    void test5() {
        truncate();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        List<User> userList = userList();
        int batchSize = 1000; // 每批插入的大小
        int totalBatches = (int) Math.ceil((double) userList.size() / batchSize); // 计算批次数量
        for (int i = 0; i < totalBatches; i++) { // 按批次插入数据
            int start = i * batchSize; // 计算当前批次的起始和结束索引
            int end = Math.min(start + batchSize, userList.size());
            List<User> batch = userList.subList(start, end); // 获取当前批次的数据
            userMapper.insertBatchSomeColumn(batch);
        }
        stopWatch.stop();
        System.out.println(stopWatch.getTotalTimeMillis());
    }

8. 耗时统计(单位:ms)

插入方法正常url添加rewtire参数
for循环755266-
BaseMapper.insert7494833828
IService.saveBatch159133178
自定义批量插入42774231
InsertBatchSomeColumn36393586

以上数据仅测试一次,但数量级比较明显,具有对比性,

可以看出BaseMapper普通的insert根本就是和for循环一样,只不过for循环需要每次发送一遍语句,BaseMapper.insert是每次发送一个参数;
IService.saveBatch还是起到了一定的批处理效果,应该是一次发送了所有参数,只不过mysql仍然是一条条执行,这样也大量节省了通信时间;
自定义方法则没有太大区别,自己写的比官方的稍微慢不到一秒,有需要还是用官方的好

加上rewriteBatchedStatements参数后,for循环太长了就没测试,也影响不到它,其余四种就基本一样了

八、总结

如果只是批量插入,那么使用BaseMapper.insert和url设置rewriteBatchedStatements=true即可
不建议用IService,参考:【抽丝剥茧知识讲解】引入mybtis-plus后,mapper实现方式
如果需要自定义公共SQL方法,那么按照第六部分的教程进行注入即可

应用到微服务项目,单独创建mybatisplus模块,文件目录如下
在这里插入图片描述
想要在其他模块引入使用,还需要创建resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,在里面添加配置类相对路径,具体请看:
【沉浸式解决问题】微服务子模块引入公共模块的依赖后无法bean未注入


参考文献

MyBatis-Plus官网 SQL注入器
【MyBatis】mybatis-plus 批量插入 性能优化
关于jdbc批量操作(addBatch, executeBatch)的测试【转载】
MySQL JDBC 实战: PreparedStatement rewriteBatchedStatements 实现原理
Mybatis Plus 批量 Insert_新增数据(图文讲解)
MyBatis-Plus BaseMapper 实现原理(SQL 注入器的使用及原理解析)


后记

这是写博客以来最详细最细致的一篇了,花了好几个晚上,断断续续终于是完成了。写这么细不单单是分享自定义BaseMapper,更是想通过研究一个简单的问题,分享研究方法,解决能力,逻辑思维,希望能给大家带来一点收获和启发,谢谢!

喜欢的点个赞和收藏吧><!祝你永无bug!

/*
                   _ooOoo_
                  o8888888o
                  88" . "88
                  (| -_- |)
                  O\  =  /O
               ____/`---'\____
             .'  \\|     |//  `.
            /  \\|||  :  |||//  \
           /  _||||| -:- |||||-  \
           |   | \\\  -  /// |   |
           | \_|  ''\---/''  |   |
           \  .-\__  `-`  ___/-. /
         ___`. .'  /--.--\  `. . __
      ."" '<  `.___\_<|>_/___.'  >'"".
     | | :  `- \`.;`\ _ /`;.`/ - ` : | |
     \  \ `-.   \_ __\ /__ _/   .-` /  /
======`-.____`-.___\_____/___.-`____.-'======
                   `=---='
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            佛祖保佑       永无BUG
*/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

荔枝吻

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值