• 别再用Mybatis Plus 的伪批量新增了!


    什么是批量插入?优势在哪里?

    先抛出一个问题:假设老板给你下了个任务,向数据库中添加 100 万条数据,并且不能耗时太久!

    通常来说,我们向 MySQL 中新增一条记录,SQL 语句类似如下:

    INSERT INTO `t_user` (`name`, `age`, `gender`) VALUES ('test'01);
    

    如果你需要添加 100 万条数据,就需要多次执行此语句,这就意味着频繁地 IO 操作(网络 IO、磁盘 IO),并且每一次数据库执行 SQL 都需要进行解析、优化等操作,都会导致非常耗时。

    幸运的是,MySQL 支持一条 SQL 语句可以批量插入多条记录,格式如下:

    INSERT INTO `t_user` (`name`, `age`, `gender`) VALUES ('test'01), ('test0'01), ('test1'01);
    

    和常规的 INSERT 语句不同的是,VALUES 支持多条记录,通过 , 逗号隔开。这样,可以实现一次性插入多条记录。

    数据量不多的情况下,常规 INSERT 和批量插入性能差距不大,但是,一旦数量级上去后,执行耗时差距就拉开了,在后面我们会实测一下它们之间的耗时对比。

    表与实体类

    先创建一个测试表 t_user, 执行脚本如下:

    1. DROP TABLE IF EXISTS user;
    2. CREATE TABLE `t_user` (
    3.   `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    4.   `name` varchar(30NOT NULL DEFAULT '' COMMENT '姓名',
    5.   `age` int(11NULL DEFAULT NULL COMMENT '年龄',
    6.   `gender` tinyint(2NOT NULL DEFAULT 0 COMMENT '性别,0:女 1:男',
    7.   PRIMARY KEY (`id`)
    8. ) COMMENT = '用户表';

    再定义一个名为 User 实体类:

    1. @Data
    2. @TableName("t_user")
    3. public class User {
    4.     /**
    5.      * 主键 ID, @TableId 注解定义字段为表的主键,type 表示主键类型,IdType.AUTO 表示随着数据库 ID 自增
    6.      */
    7.     @TableId(type = IdType.AUTO)
    8.     private Long id;
    9.     /**
    10.      * 姓名
    11.      */
    12.     private String name;
    13.     /**
    14.      * 年龄
    15.      */
    16.     private Integer age;
    17.     /**
    18.      * 性别
    19.      */
    20.     private Integer gender;
    21. }

    TIP:@Data 是 Lombok 注解,偷懒用的,加上它即可免写繁杂的 getXXX/setXXX 相关方法,不了解的小伙伴可自行搜索一下如何使用。

    Mybatis Plus 伪批量插入

    在前面《新增数据》小节中,我们已经知道了 Mybatis Plus 内部封装的批量插入 savaBatch() 是个假的批量插入,示例代码如下:

    1. List users = new ArrayList<>();
    2. for (int i = 0; i < 5; i++) {
    3.     User user = new User();
    4.     user.setName("test" + i);
    5.     user.setAge(i);
    6.     user.setGender(1);
    7.     users.add(user);
    8. }
    9. // 批量插入
    10. boolean isSuccess = userService.saveBatch(users);
    11. System.out.println("isSuccess:" + isSuccess);

    通过打印实际执行 SQL , 我们发现还是一条一条的执行 INSERT:

    并且还带着大家看了内部实现的源码,这种方式比起自己 for 循环一条一条 INSERT 插入数据性能要更高,原因是在会话这块做了优化,虽然实际执行并不是真的批量插入。

    利用 SQL 注入器实现真的批量插入

    接下来,就手把手带你通过 Mybatis Plus 框架的 SQL 注入器实现一个真的批量插入。

    新建批量插入 SQL 注入器

    在工程 config 目录下创建一个 SQL 注入器 InsertBatchSqlInjector  :

    1. public class InsertBatchSqlInjector extends DefaultSqlInjector {
    2.     @Override
    3.     public List getMethodList(Class mapperClass, TableInfo tableInfo) {
    4.      // super.getMethodList() 保留 Mybatis Plus 自带的方法
    5.         List methodList = super.getMethodList(mapperClass, tableInfo);
    6.         // 添加自定义方法:批量插入,方法名为 insertBatchSomeColumn
    7.         methodList.add(new InsertBatchSomeColumn());
    8.         return methodList;
    9.     }
    10. }
    说说 InsertBatchSomeColumn

    InsertBatchSomeColumn 是 Mybatis Plus 内部提供的默认批量插入,只不过这个方法作者只在 MySQL 数据测试过,所以没有将它作为通用方法供外部调用,注意看注释:

    源码复制出来,如下:

    1. /**
    2.  * 批量新增数据,自选字段 insert
    3.  * 

       不同的数据库支持度不一样!!!  只在 mysql 下测试过!!!  只在 mysql 下测试过!!!  只在 mysql 下测试过!!! 

    4.  * 

       除了主键是  数据库自增的未测试  外理论上都可以使用!!! 

    5.  * 

       如果你使用自增有报错或主键值无法回写到entity,就不要跑来问为什么了,因为我也不知道!!! 

    6.  * 

    7.  * 自己的通用 mapper 如下使用:
    8.  * 
    9.  * int insertBatchSomeColumn(List entityList);
    10.  * 
  •  * 

  •  *
  •  * 
  •  注意: 这是自选字段 insert !!,如果个别字段在 entity 里为 null 但是数据库中有配置默认值, insert 后数据库字段是为 null 而不是默认值 
  •  *
  •  * 

  •  * 常用的 {@link Predicate}:
  •  * 

  •  *
  •  * 
  •  例1: t -> !t.isLogicDelete() , 表示不要逻辑删除字段 
  •  * 
  •  例2: t -> !t.getProperty().equals("version") , 表示不要字段名为 version 的字段 
  •  * 
  •  例3: t -> t.getFieldFill() != FieldFill.UPDATE) , 表示不要填充策略为 UPDATE 的字段 
  •  *
  •  * @author miemie
  •  * @since 2018-11-29
  •  */
  • @SuppressWarnings("serial")
  • public class InsertBatchSomeColumn extends AbstractMethod {
  •     /**
  •      * 字段筛选条件
  •      */
  •     @Setter
  •     @Accessors(chain = true)
  •     private Predicate predicate;
  •     /**
  •      * 默认方法名
  •      */
  •     public InsertBatchSomeColumn() {
  •      // 方法名
  •         super("insertBatchSomeColumn");
  •     }
  •     /**
  •      * 默认方法名
  •      *
  •      * @param predicate 字段筛选条件
  •      */
  •     public InsertBatchSomeColumn(Predicate predicate) {
  •         super("insertBatchSomeColumn");
  •         this.predicate = predicate;
  •     }
  •     /**
  •      * @param name      方法名
  •      * @param predicate 字段筛选条件
  •      * @since 3.5.0
  •      */
  •     public InsertBatchSomeColumn(String name, Predicate 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 fieldList = tableInfo.getFieldList();
  •         String insertSqlColumn = tableInfo.getKeyInsertSqlColumn(truefalse) +
  •             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 = 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 = languageDriver.createSqlSource(configuration, sql, modelClass);
  •         return this.addInsertMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource, keyGenerator, keyProperty, keyColumn);
  •     }
  • }
  • 配置 SQL 注入器

    在 config 包下创建 MybatisPlusConfig 配置类:

    1. @Configuration
    2. @MapperScan("com.quanxiaoha.mybatisplusdemo.mapper")
    3. public class MybatisPlusConfig {
    4.     /**
    5.      * 自定义批量插入 SQL 注入器
    6.      */
    7.     @Bean
    8.     public InsertBatchSqlInjector insertBatchSqlInjector() {
    9.         return new InsertBatchSqlInjector();
    10.     }
    11. }
    新建 MyBaseMapper

    在 config 包下创建 MyBaseMapper 接口,让其继承自 Mybatis Plus 提供的 BaseMapper, 并定义批量插入方法:

    1. public interface MyBaseMapper extends BaseMapper {
    2.  // 批量插入
    3.     int insertBatchSomeColumn(@Param("list") List batchList);
    4. }

    注意:方法名必须为 insertBatchSomeColumn, 和 InsertBatchSomeColumn 内部定义好的方法名保持一致。

    新建 UserMapper

    在 mapper 包下创建 UserMapper 接口,注意继承刚刚自定义的 MyBaseMapper, 而不是 BaseMapper :

    1. public interface UserMapper extends MyBaseMapper {
    2. }
    测试批量插入

    完成上面这些工作后,就可以使用 Mybatis Plus 提供的批量插入功能了。我们新建一个单元测试,并注入 UserMapper :

    1. @Autowired
    2. private UserMapper userMapper;

    单元测试如下:

    1. @Test
    2. void testInsertBatch() {
    3.     List users = new ArrayList<>();
    4.     for (int i = 0; i < 3; i++) {
    5.         User user = new User();
    6.         user.setName("test" + i);
    7.         user.setAge(i);
    8.         user.setGender(1);
    9.         users.add(user);
    10.     }
    11.     userMapper.insertBatchSomeColumn(users);
    12. }

    控制台实际执行 SQL 如下:

    可以看到这次是真实的批量插入了,舒服了~

    性能对比

    我们来测试一下插入 105000  条数据,分别使用 for 循环插入数据、savaBatch() 伪批量插入、与真实批量插入三种模式,看看耗时差距多少。

    for 循环插入

    单元测试代码如下:

    1. @Test
    2. void testInsert1() {
    3.     // 总耗时:722963 ms, 约 12 分钟
    4.     long start = System.currentTimeMillis();
    5.     for (int i = 0; i < 105000; i++) {
    6.         User user = new User();
    7.         user.setName("test" + i);
    8.         user.setAge(i);
    9.         user.setGender(1);
    10.         userMapper.insert(user);
    11.     }
    12.     System.out.println(String.format("总耗时:%s ms", System.currentTimeMillis() - start));
    13. }
    savaBatch() 伪批量插入

    单元测试代码如下:

    1. @Test
    2. void testInsert2() {
    3.     // 总耗时:95864 ms, 约一分钟30秒左右
    4.     long start = System.currentTimeMillis();
    5.     List users = new ArrayList<>();
    6.     for (int i = 0; i < 105000; i++) {
    7.         User user = new User();
    8.         user.setName("test" + i);
    9.         user.setAge(i);
    10.         user.setGender(1);
    11.         users.add(user);
    12.     }
    13.     userService.saveBatch(users);
    14.     System.out.println(String.format("总耗时:%s ms", System.currentTimeMillis() - start));
    15. }
    真实批量插入

    注意,真实业务场景下,也不可能会将 10 万多条记录组装成一条 SQL 进行批量插入,因为数据库对执行 SQL 大小是有限制的(这个数值可以自行设置),还是需要分片插入,比如取 1000 条执行一次批量插入,单元测试代码如下:

    1. @Test
    2.     void testInsertBatch1() {
    3.         // 总耗时:6320 ms, 约 6 秒
    4.         long start = System.currentTimeMillis();
    5.         List users = new ArrayList<>();
    6.         for (int i = 0; i < 105006; i++) {
    7.             User user = new User();
    8.             user.setName("test" + i);
    9.             user.setAge(i);
    10.             user.setGender(1);
    11.             users.add(user);
    12.         }
    13.         // 分片插入(每 1000 条执行一次批量插入)
    14.         int batchSize = 1000;
    15.         int total = users.size();
    16.         // 需要执行的次数
    17.         int insertTimes = total / batchSize;
    18.         // 最后一次执行需要提交的记录数(防止可能不足 1000 条)
    19.         int lastSize = batchSize;
    20.         if (total % batchSize != 0) {
    21.             insertTimes++;
    22.             lastSize = total%batchSize;
    23.         }
    24.         for (int j = 0; j < insertTimes; j++) {
    25.             if (insertTimes == j+1) {
    26.                 batchSize = lastSize;
    27.             }
    28.             // 分片执行批量插入
    29.             userMapper.insertBatchSomeColumn(users.subList(j*batchSize, (j*batchSize+batchSize)));
    30.         }
    31.         System.out.println(String.format("总耗时:%s ms", System.currentTimeMillis() - start));
    32.     }
    耗时对比
    方式总耗时
    for 循环插入722963 ms, 约 12 分钟
    savaBatch() 伪批量插入95864 ms, 约一分钟30秒左右
    真实批量插入6320 ms, 约 6 秒

    耗时对比非常直观,在大批量数据新增的场景下,批量插入性能最高。

    结语

    本小节中,我们学习了如何通过 Mybatis Plus 的 SQL 注入器实现真实的批量插入,同时最后还对比了三种不同方式插入 10 万多数据的耗时,很直观的看到在海量数据场景下,批量插入的性能是最强的。

  • 相关阅读:
    修改/etc/fstab文件导致Linux无法正常启动解决方法
    iOS开发 - 抛开表面看本质之iOS常用架构(MVC,MVP,MVVM)
    Cassandra笔记
    chrome窗口
    1123. 最深叶节点的最近公共祖先
    C语言指针快速入门
    STM32基于hal库的adc以DMA的多通道采样以及所遇问题解决
    (六)正则表达式——PHP
    CPU乱序执行基础 —— Tomasulo算法及执行过程
    恒峰|高压森林应急消防泵|守护森林安全
  • 原文地址:https://blog.csdn.net/wcj_java/article/details/132806350