MyBatis Plus批处理优化


MyBatis Plus 作为 MyBatis 的增强版为单表提供了丰富的读写操作,可极大降低代码工程量。

而今天,就让我们一并深入了解如何在 MyBatis Plus 中最大化批量操作性能。

下面就以具体的工程示例让介绍具体的批量操作调优。

一、项目示例

1. 数据构建

在具体的代码开始前首先准备测试表,建表脚本如下:

CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `email` varchar(100) DEFAULT NULL,
  `address` varchar(100) DEFAULT NULL,
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
);

完成建表之后,按照上述数据结构模拟生成构建测试数据。

逻辑并不复杂这里不展开描述,代码如下:

private List<List<User>> mockData(Integer size) {
    // 构造测试数据
    LocalDateTime now = LocalDateTime.now();
    List<User> users = new ArrayList<>(size);
    for (int i = 0; i < size; i++) {
        User user = new User();
        user.setName("name-" + i);
        user.setAge(i);
        user.setEmail("email-" + i);
        user.setAddress("address-" + i);
        user.setCreateBy(0L);
        user.setCreateTime(now);
        users.add(user);
    }

    // 按 500 拆分多批次
    return partition(users, 500);
}

2. 批量保存

那么以批量保存为例,一并来看如何在 Mybatis Plus 中如何实现数据插入。

这里略去其余无关配置项,直入主题可以看到 MPservice 父级封装类中提供了 saveBatch() 批量保存方法。

同时,在操作批量插入时为了更优的性能常分批进行保存提交,至于其缘由后续再进行阐述。

public class UserController {

    private final UserService userService;

    @GetMapping("insert1")
    public void insert1(Integer size) {
        List<List<User>> lists = mockData(size);
        long l = System.currentTimeMillis();
        for (List<User> batch : lists) {
            userService.saveBatch(batch);
        }
        log.info("insert1 time: {} ms", System.currentTimeMillis() - l);
    }
}

3. 原生插入

同理,利用 SQL 中默认的 insert values 语法,则可在原生的 MyBatis 中利用 foreach 语法在 xml 中手动编写语句从而实现批量插入。

<insert id="insertBatch" keyProperty="id" useGeneratedKeys="true">
    insert into user(name, age, email, address, create_by, create_time)
    values
    <foreach collection="entities" item="entity" separator=",">
        (#{entity.name}, #{entity.age}, #{entity.email}, #{entity.address}, #{entity.createBy}, #{entity.createTime})
    </foreach>
</insert>

基于上述原生 SQL 语法,其对应的批量数据保存示例如下,同样采取了分批提交的方式。

public class UserController {

    private final UserDao userDao;

    @GetMapping("insert2")
    public void insert1(Integer size) {
        List<List<User>> lists = mockData(size);
        long l = System.currentTimeMillis();
        for (List<User> batch : lists) {
            userDao.insertBatch(batch);
        }
        log.info("insert2 time: {} ms", System.currentTimeMillis() - l);
    }
}

4. 性能分析

基于上述的两个示例,以 1w 条数据为例分别执行两个代码块,日志统计如下所示。

从日志结果可以看到 MP 中的批量操作相较于原生的批量操作更为耗时。

com.example.controller.UserController    : insert1 time: 3135 ms

com.example.controller.UserController    : insert2 time: 607 ms

继续扩大测试集,以 10w 条记录插入为例,从日志可以看到 MP 中的批量耗时仍远超于原生操作。

com.example.controller.UserController    : insert1 time: 22871 ms

com.example.controller.UserController    : insert2 time: 5927 ms

二、机制剖析

1. 源码解读

那么,造成这一现象的原因又是什么呢?

查看 MP 中的 saveBatch() 实现代码可以看到,其批量操作的核心在于开启长会话 sqlSession(),在会话中遍历数据执行插入等数据操作,当达到批次大小时通过 flushStatements() 将本批次的数据提交至数据库。

基于此模式下,与数据库的网络请求交互则可大大下降。以 1w 条数据为例且批次大小为 1k 为例,与数据库的网络 IO 则从 1w 次下降至 10 次,所带来的提供不言而喻。

public static <E> boolean executeBatch(SqlSessionFactory sqlSessionFactory, Log log, Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
    Assert.isFalse(batchSize < 1, "batchSize must not be less than one", new Object[0]);
    return !CollectionUtils.isEmpty(list) && executeBatch(sqlSessionFactory, log, (sqlSession) -> {
        int size = list.size();
        int idxLimit = Math.min(batchSize, size);
        int i = 1;

        for(Iterator var7 = list.iterator(); var7.hasNext(); ++i) {
            E element = var7.next();
            consumer.accept(sqlSession, element);
            if (i == idxLimit) {
                sqlSession.flushStatements();
                idxLimit = Math.min(idxLimit + batchSize, size);
            }
        }
    });
}

看到这你也许有个疑问,既然降低了网络开销那相较于原生 SQL 为什么仍无法取得相同的性能表现。

这其中的差异之处则是由于数据库自身的实现所带来的,虽然在 MP 中虽然通过长会话节省了网络开销,但数据库层面接受到的仍然是独立的 n 条插入语句,仍需要逐条执行。

而在原生的 SQL 中通过拼接后数据库所接收到的语句数则为 记录数/批量数。因此,随着数据的递增二者的差异将更为明显,故在大批量的数据操作中更推荐原生的 SQL 方式。

2. 批量更新

了解二者背后的机制后,我们知道在大批量数据下 MP 自带的批量并非最优解。

而在批量插入中则可利用 insert values 实现,批量删除同样可以通过预处理后 in 条件实现。那么,针对批量更新则应当如何处理呢?

虽然在 MySQL 等数据库中针对更新没有类似的 insert values 的语法,但提供了取巧的方式支持 SQL 合并提供,通过在连接信息中添加 allowMultiQueries=true 参数允许多语句提交。

jdbc:mysql://127.0.0.1:3306/database?allowMultiQueries=true

那么,即可在 xml 中通过 foreach 标签拼接合并更新语句从而实现性能最大化。

<update id="updateBatch" parameterType="java.util.List" >
    <foreach collection="list" item="item" open="" close="" separator=";">
        update user
        <set>
            <if test="item.name != null" >
                name = #{item.name},
            </if>
            <if test="item.age != null" >
                age = #{item.age},
            </if>
            <if test="item.email != null" >
                email = #{item.email},
            </if>
            <if test="item.address != null" >
                address = #{item.address},
            </if>
            <if test="item.createBy != null" >
                create_by = #{item.createBy},
            </if>
            <if test="item.createTime != null" >
                create_time = #{item.createTime}
            </if>
        </set>
        where id = #{item.id}
    </foreach>
</update>

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录