mybatis+springboot+MySQL批量插入 1w 条数据——探讨

前言

传统的单条 INSERT 语句逐行插入方式,在处理 1 万条数据时往往需要数秒,这不仅会导致事务锁竞争加剧,更可能引发连接超时等系统性风险

那么如何优化这种批量插入的场景呢?让我们一起看看吧!

传统插入的性能瓶颈

  • 网络延迟:每次插入都独立发往数据库执行,那么每个 SQL 语句的网络通信都可能造成延迟
  • 事务控制:每次插入通常会涉及一次事务的开启和提交(如果没有显式地控制事务)
  • 锁争用和并发性:如果大量插入数据时,逐个插入的方式会增加锁的争用,尤其是当数据表上有多个事务并发访问时,插入的性能会进一步下降

优化批量插入

扩大事务粒度

在进行批量插入时,大事务(即将多条 INSERT 语句放在一个事务中)比独立事务(即每一条 INSERT 语句都单独使用一个事务)通常性能更好,原因是:

  • 事务提交开销

    • 每个独立事务需要执行 COMMIT 操作,这会触发 MySQL 的日志持久化(如 redo log 的 fsync 操作),导致磁盘 I/O 开销。同时,多次发送开启事务和提交事务的操作,带来了额外的网络开销
    • 大事务只需一次 COMMIT,减少了日志刷盘的次数,从而显著降低 I/O 等待时间
  • 锁竞争与锁释放

    • 独立事务每次提交会释放行锁,下一次插入时需重新获取锁,增加了锁竞争的开销
    • 大事务在整个过程中持有锁(如行锁),减少了锁的重复获取和释放,提高并发效率
  • 日志写入优化

    • 对于 InnoDB 引擎,事务日志(redo log)以顺序追加方式写入
    • 大事务的日志批量写入比多次小事务的分散写入更高效,减少了日志缓冲区切换和磁盘寻址开销

合并多条 INSERT

将多条 INSERT 语句合并成一条 INSERT 语句(例如 INSERT INTO table (col1, col2) VALUES (val1, val2), (val3, val4), ...)也能提高性能,原因包括:

  • 减少网络开销:合并为单条 INSERT 语句后,客户端与数据库服务器的交互次数减少,降低了网络延迟的影响
  • 降低 SQL 解析成本:每条 SQL 都需要经历语法解析、优化器处理等步骤。合并后只需一次解析,节省 CPU 资源
  • 减少锁开销: 每条独立的 INSERT 都需要进行锁定和解锁操作,而合并为一条 INSERT 操作时,从而减少了锁相关的性能损失

实现 INSERT 合并

  • 手动拼接 SQL。比如通过 MyBatis foreach 标签
  • JDBC BatchInsert。在 Connection 中,添加参数 rewriteBatchedStatements=truejdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true。有 JDBC 帮我们完成 SQL 的合并

预生成 ID

InnoDB 为保证自增 ID 的全局唯一性,在分配自增值时会持有自增锁(AUTO-INC Lock) 。在高并发情况下,多个插入操作会频繁争抢数据库的自增 ID,这可能导致锁的竞争和性能瓶颈

使用预生成 ID 会有更好的性能表现,比如预先生成雪花 ID。避免在数据库层面加锁解锁影响性能

批量插入方案性能测试

环境准备

applicatoin.yml 配置(关键在于rewriteBatchedStatements=true):

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true&useServerPrepStmts=false
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

为了方便测试,使用数据库的自增 ID,生成数据库表 user:

sql 复制代码
CREATE TABLE `user` (
                        `id` bigint(20) NOT NULL AUTO_INCREMENT,
                        `name` varchar(100) DEFAULT NULL,
                        `age` int(11) DEFAULT NULL,
                        `email` varchar(100) DEFAULT NULL,
                        PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

java User 实体类:

java 复制代码
@Data
@TableName("user")
public class User {
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

生成 1w 条测试数据:

java 复制代码
private List<User> prepareTestData(int count) {
    List<User> users = new ArrayList<>(count);
    for (int i = 0; i < count; i++) {
        User user = new User();
        user.setId(null);  // 自增ID
        user.setName("test" + i);
        user.setAge(20 + i % 50);
        user.setEmail("test" + i + "@test.com");
        users.add(user);
    }
    return users;
}

每次测试后,清空数据,避免对下次测试的影响:

sql 复制代码
TRUNCATE TABLE user;
ALTER TABLE user AUTO_INCREMENT = 1;

测试

独立事务循环插入

使用 for 循环,逐个 INSERT。每个 INSERT 都会隐式独立地开启并提交事务,一条 INSERT 语句都单独使用一个事务。如图:

java 复制代码
@Test
public void testSingleTransactionInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    for (User user : users) {
        userMapper.insert(user);
    }

    stopWatch.stop();
    System.out.println("独立事务循环插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: 独立事务循环插入耗时: 12485ms

特点

  • N 次 INSERT + N 次 COMMIT → 高 I/O、高锁竞争
  • 当某条 INSERT 失败,只回滚它自身,降低执行失败造成的影响

大事务循环插入

显式使用@Transactional开启事务,让执行的 SQL 都在同一个大事务中,这样只需要开启和提交一次事务,如图:

java 复制代码
@Test
@Transactional // 开始事务
public void testBigTransactionInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    for (User user : users) {
        userMapper.insert(user);
    }

    stopWatch.stop();
    System.out.println("大事务循环插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: 大事务循环插入耗时: 9565ms

特点

  • N 次 INSERT + 1 次 COMMIT → 锁竞争可控
  • 过大的事务可能导致 Undo Log 膨胀或长事务阻塞其他操作
  • 假设某个数据插入失败,所有数据都要回滚

MyBatis foreach

通过 MyBatis foreach 标签,将多条 INSERT 语句合并为一条 INSERT 语句,如图:

java 复制代码
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 方便测试,直接将 SQL 写到注解
    @Insert("<script>" +
            "INSERT INTO user (name, age, email) VALUES " +
            "<foreach collection='users' item='user' separator=','>" +
            "(#{user.name}, #{user.age}, #{user.email})" +
            "</foreach>" +
            "</script>")
    void batchInsert(@Param("users") List<User> users);
}

@Test
public void testMybatisForeachInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    userMapper.batchInsert(users);

    stopWatch.stop();
    System.out.println("MyBatis foreach批量插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: MyBatis foreach批量插入耗时: 891ms

特点

  • 1 次多值 INSERT + 1 次 COMMIT → 最小化网络、解析、I/O 开销
  • 需要对 foreach 标签进行解析,可能带来额外的开销
  • 自己拼接 SQL,不依赖参数rewriteBatchedStatements=true和 JDBC 批量插入机制

JDBC BatchInsert

需要开启 rewriteBatchedStatements=true

JDBC BatchInsert 的 SQL 和 INSERT 单条数据的语法一样,JDBC 会帮我们像上面 MyBatis foreach 一样,将多个 INSERT 聚合为一条 INSERT 语句

java 复制代码
@Test
public void testJdbcBatchInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    jdbcTemplate.batchUpdate("INSERT INTO user (name, age, email) VALUES (?, ?, ?)",
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    User user = users.get(i);
                    ps.setString(1, user.getName());
                    ps.setInt(2, user.getAge());
                    ps.setString(3, user.getEmail());
                }

                @Override
                public int getBatchSize() {
                    return users.size();
                }
            });

    stopWatch.stop();
    System.out.println("JDBC batch插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: JDBC batch插入耗时: 587ms

特点

  • 需要开启 rewriteBatchedStatements=true。由 JDBC 帮我们进行批处理,不用我们手动合并 INSERT
  • 单条 SQL 长度受 max_allowed_packet 参数限制,需避免超出阈值
  • JDBC 硬编码,使用字符串拼接 SQL,不方便维护

MyBatis SqlSession 批量插入

需要开启 rewriteBatchedStatements=true

获取 MyBatis 的 ExecutorType.BATCH 的 SqlSession,执行批量插入,底层依赖 JDBC BatchInsert,将多条 SQL 合并成一条

java 复制代码
@Test
public void testMybatisBatchInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        for (User user : users) {
            mapper.insert(user);
        }
        sqlSession.commit();
    }

    stopWatch.stop();
    System.out.println("MyBatis SqlSession批量插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: MyBatis SqlSession批量插入耗时: 825

特点

  • 底层依赖 JDBC BatchInsert 实现,需要开启 rewriteBatchedStatements=true

MyBatis-Plus saveBatch

需要开启 rewriteBatchedStatements=true

使用 MyBatis-Plus saveBatch 将多个 SQL 合并成一条

java 复制代码
@Test
public void testMybatisPlusBatchInsert() {
    List<User> users = prepareTestData(10000);
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    userService.saveBatch(users, 1000);

    stopWatch.stop();
    System.out.println("MyBatis-Plus批量插入耗时: " + stopWatch.getTotalTimeMillis() + "ms");
}

测试结果: MyBatis-Plus批量插入耗时: 860ms

特点

  • 底层依赖 JDBC BatchInsert 实现,需要开启 rewriteBatchedStatements=true
  • saveBatch 内部封装了对分片处理(默认 1000) 和分批提交事务的操作

结果分析

方法 耗时(ms)
独立事务循环插入 12485
大事务循环插入 9565
MyBatis foreach 891
JDBC BatchInsert 587
MyBatis SqlSession 批量插入 825
MyBatis-Plus saveBatch 860

性能排名:

  1. JDBC BatchInsert
  2. MyBatis SqlSession 批量插入
  3. MyBatis-Plus saveBatch
  4. MyBatis foreach
  5. 大事务循环插入
  6. 独立事务循环插入

如何选择?

都需要开启rewriteBatchedStatements=true

  • 极致性能:JDBC BatchInsert。不过需要手写 SQL,导致硬编码
  • 通用方案:优先选择 MyBatis-Plus saveBatch,可以很方便地分片处理和分批提交事务。其次选择 MyBatis SqlSession 批量插入

测试的局限性

  1. 需要记录每种方案在不同数据量、不同字段数和不同字段大小对批量插入的影响
  2. 考虑并发场景下的性能,是否会涉及大量锁竞争
  3. 测试是在本地运行,没有考虑网络带宽的影响

如何将批量插入应用到业务中?

问题的核心在于:如何"攒一波"数据来实现批量插入?

推荐批量插入与 MQ 配合使用。将需要插入的数据的消息发送给 MQ,生产者需要保证 MQ 的消息发送和本地事务的原子性 。对于消费者,一次性拉取多个消息进行批量插入。当消息消费失败时,可以让 MQ 重新投递消息并重新消费

不过 MQ 的引入和积攒数据,带来的数据插入的延迟 是不可避免的,同时只能保证最终一致性而不是强一致

结语

只需要开启rewriteBatchedStatements=true并使用 MyBatis-Plus saveBatch,就可以大幅提高批量插入的性能。如果可以离线导入数据,使用LOAD DATA也是一种选择

不过一致性与性能难以兼得 ,一旦某个 INSERT 执行失败,整个事务的插入操作都要回滚。如果是在不同分片的事务,分片与分片之间的一致性也无法保证。同时,数据的插入可能会延后一段时间。所以不建议强一致的场景使用批量插入


相关推荐
uzong24 分钟前
技术故障复盘模版
后端
GetcharZp1 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程1 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研1 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy3 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
喂完待续3 小时前
Apache Hudi:数据湖的实时革命
大数据·数据仓库·分布式·架构·apache·数据库架构
AntBlack3 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt
bobz9654 小时前
pip install 已经不再安全
后端