MyBatis-plus 批量插入的通用方法使用

     阅读:60

1. MyBatis-plus 的批量保存方法

MyBatis-plus 中默认提供了一个批量保存数据到数据库的方法,也就是 IService#saveBatch() 接口方法。这个方法的实现为 ServiceImpl#saveBatch(),其源码实际处理的关键如下,从中可以知道 IService#saveBatch() 并不是一个真正的批量插入数据的方法

  1. 调用 ServiceImpl#sqlStatement() 使用 SqlMethod.INSERT_ONE 枚举结合实体类确定一个全路径方法名称,这个名称将用于匹配实体对应的库表的单个插入方法的 MappedStatement 对象
  2. 调用 ServiceImpl#executeBatch() 方法遍历 Entity 的集合,使用单个插入的方法为每个实体组装一个 INSERT INTO 语句,遍历结束后 flush,一次性将所有生成的 INSERT INTO 语句推给数据库执行

举例来说,如果调用 IService#saveBatch() 方法保存有2个元素的实体集合 List<Node> 数据到数据库,其执行的 SQL 语句如下

  • 存在 2 条:
    INSERT INTO node (name, version) VALUES (‘nathan’,1);
    INSERT INTO node (name, version) VALUES (‘bob’,1);

而如果是数据库批量插入,其执行的 SQL 语句应该如下

  • 只有 1 条:
    INSERT INTO node (name, version) VALUES (‘nathan’,1), (‘bob’,1);
    @Transactional(rollbackFor = Exception.class)
    @Override
    public boolean saveBatch(Collection<T> entityList, int batchSize) {
        String sqlStatement = sqlStatement(SqlMethod.INSERT_ONE);
        return executeBatch(entityList, batchSize, (sqlSession, entity) -> sqlSession.insert(sqlStatement, entity));
    }
    
    protected String sqlStatement(SqlMethod sqlMethod) {
        return SqlHelper.table(entityClass).getSqlStatement(sqlMethod.getMethod());
    }
    
    protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
        Assert.isFalse(batchSize < 1, "batchSize must not be less than one");
        return !CollectionUtils.isEmpty(list) && executeBatch(sqlSession -> {
            int size = list.size();
            int i = 1;
            for (E element : list) {
                consumer.accept(sqlSession, element);
                if ((i % batchSize == 0) || i == size) {
                    sqlSession.flushStatements();
                }
                i++;
            }
        });
    }

2. MyBatis-plus 的批量插入方法

2.1 通用批量插入方法 InsertBatchSomeColumn

事实上 MyBatis-plus 提供了真正的批量插入方法 InsertBatchSomeColumn,只不过这个方法只在 MySQL 数据库下测试过,所以没有将其作为默认通用方法添加到 SqlMethod

从其源码实现不难看出,InsertBatchSomeColumn 其实就是提供了一个使用 foreach 标签的 SQL 脚本,不了解这个标签的读者参考 自定义批量插入 大致理解即可

/**
 * 批量新增数据,自选字段 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
 */
@NoArgsConstructor
@AllArgsConstructor
public class InsertBatchSomeColumn extends AbstractMethod {

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

    @SuppressWarnings("Duplicates")
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        KeyGenerator keyGenerator = new NoKeyGenerator();
        SqlMethod sqlMethod = SqlMethod.INSERT_ONE;
        List<TableFieldInfo> fieldList = tableInfo.getFieldList();
        String insertSqlColumn = tableInfo.getKeyInsertSqlColumn(false) +
            this.filterTableFieldInfo(fieldList, predicate, TableFieldInfo::getInsertSqlColumn, EMPTY);
        String columnScript = LEFT_BRACKET + insertSqlColumn.substring(0, insertSqlColumn.length() - 1) + RIGHT_BRACKET;
        String insertSqlProperty = tableInfo.getKeyInsertSqlProperty(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 (StringUtils.isNotBlank(tableInfo.getKeyProperty())) {
            if (tableInfo.getIdType() == IdType.AUTO) {
                /* 自增主键 */
                keyGenerator = new Jdbc3KeyGenerator();
                keyProperty = tableInfo.getKeyProperty();
                keyColumn = tableInfo.getKeyColumn();
            } else {
                if (null != tableInfo.getKeySequence()) {
                    keyGenerator = TableInfoHelper.genKeyGenerator(getMethod(sqlMethod), tableInfo, builderAssistant);
                    keyProperty = tableInfo.getKeyProperty();
                    keyColumn = tableInfo.getKeyColumn();
                }
            }
        }
        String sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), columnScript, valuesScript);
        SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
        return this.addInsertMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource, keyGenerator, keyProperty, keyColumn);
    }

    @Override
    public String getMethod(SqlMethod sqlMethod) {
        // 自定义 mapper 方法名
        return "insertBatchSomeColumn";
    }
}

2.2 InsertBatchSomeColumn 的使用

由于InsertBatchSomeColumn 是框架已经定义好的通用方法,所以使用者只要引入即可,简单来说只需要进行以下几个步骤:

  1. 新增 SQL 注入器
  2. 新增配置类将 SQL 注入器添加到容器
  3. 新增基类 Mapper,注意这个基类中的批量插入方法名称要和 InsertBatchSomeColumn#getMethod() 方法返回的字符串一致,也就是 insertBatchSomeColumn

具体做法读者请参考 MyBatis-plus 自定义通用方法及其实现原理,本文不再赘述

经过以上配置,最终具体的业务类 Mapper 只要继承新增的基类 Mapper 就具备了批量插入的功能,笔者习惯将 Mapper 封装在一个 RepositoryService 中对外提供能力,则各个业务类只需要实现类似如下的 NodeRepositoryServiceImpl#insertBatch() 方法即可以对外提供批量插入的功能

    @Override
    public int insertBatch(List<Node> entityList) {
        if (CollectionUtils.isEmpty(entityList)) {
            return 0;
        }
        return getBaseMapper().insertBatchSomeColumn(entityList);
    }

3. 批量插入 MySQL 数据库的坑

3.1 MySQL 对非 NULL 字段插入 NULL 值的处理

使用 MyBatis-plus 批量插入的方法插入 MySQL 记录时需要注意,调用批量插入的方法一定要保证确实是要插入多条数据,如果调用批量插入的方法只插入了单条数据,非常有可能遇到非 NULL 字段插入 NULL 值的错误

com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Column 'xxx_name' cannot be null

这是因为我们借助 Entity 插入数据时经常会忽略一些表中有默认值的非 NULL 字段对应的属性的赋值,而从批量插入的 SQL 语句的执行角度来看,这样做也就是往非 NULL 字段插入了 NULL 值。实际上 MySQL 对于非 NULL 字段插入 NULL 值是有兼容处理的,感兴趣的读者可前往 官方传送门,本文摘录如下:

简单来说,对于插入 NULL 值到非 NULL 字段的情况分为两种处理方式:

  1. 如果是批量插入多条数据,则会将 NULL 值转化为默认值插到非 NULL 字段(也就是本文批量插入方法插入多条数据的情形
  2. 如果是单条数据插入,则抛出异常,失败结束(对应本文批量插入方法只插入了单条数据的情形
Inserting NULL into a column that has been declared NOT NULL. For multiple-row INSERT statements or 
INSERT INTO ... SELECT statements, the column is set to the implicit default value for the column
data type. This is 0 for numeric types, the empty string ('') for string types, and the “zero” value
for date and time types. INSERT INTO ... SELECT statements are handled the same way as multiple-row
inserts because the server does not examine the result set from the SELECT to see whether it returns
a single row. (For a single-row INSERT, no warning occurs when NULL is inserted into a NOT NULL column.
Instead, the statement fails with an error.)

3.2 解决方法

解决方法很简单,只要在批量插入的时候判断一下 Entity 集合的大小即可,如果集合中只有一条数据,则调用插入单条数据的方法

  • MyBatis-plus 单条数据插入之所以不会有往非 NULL 字段插入 NULL 值的问题,是因为其单条插入数据的 SQL 脚本能根据 Entity 的属性赋值情况动态调整,对于 Entity 中值为 NULL 的属性,默认不会将其对应的字段添加到执行的 SQL 语句中

举例来说,如 Node 含有两个属性,分别是 nameversion,则对于属性值不同的情况最终执行的 SQL 语句也不一样

  1. version 为 NULL
    INSERT INTO node (name) VALUES (‘nathan’);
  2. version 不为 NULL
    INSERT INTO node (name, version) VALUES (‘nathan’,1);
    @Override
    public int insertBatch(List<Node> entityList) {
        if (CollectionUtils.isEmpty(entityList)) {
            return 0;
        }
        if (1 == entityList.size()) {
            return getBaseMapper().insert(entityList.get(0));
        }
        return getBaseMapper().insertBatchSomeColumn(entityList);
    }