MyBatis批量插入性能优化:从5分钟到3秒的工程化实践

MyBatis批量插入性能优化:从5分钟到3秒的工程化实践

10万条数据插入的极致性能调优:SQL优化、JDBC批处理与并行化策略


性能优化全景图

复制代码
MyBatis批量插入优化路径
├── 青铜级(5分钟)→ 白银级(30秒)
│   └── 单条循环 → 批量SQL(foreach)
├── 白银级(30秒)→ 黄金级(8秒)
│   └── 批量SQL → JDBC批处理(ExecutorType.BATCH + rewriteBatchedStatements)
├── 黄金级(8秒)→ 王者级(3秒)
│   └── 单线程 → 多线程并行 + 连接池优化
└── 王者级(3秒)→ 传说级(<1秒)
    └── 数据库层面:LOAD DATA INFILE、禁用索引、调整日志模式

一、问题诊断:为什么单条插入这么慢?

1.1 性能瓶颈分析

java 复制代码
// 青铜级写法:循环单条插入(5分钟)
for (User user : userList) {
    userMapper.insert(user);  // 每次循环都是一次完整的数据库交互
}

单次插入的完整开销

阶段 耗时 说明
网络往返(RTT) 0.5-2ms 应用服务器→数据库服务器
SQL解析与优化 0.3-1ms MySQL解析SQL、生成执行计划
事务日志写入(redo log) 1-2ms 保证ACID的磁盘I/O
数据页写入(buffer pool) 0.5-1ms 内存中的数据修改
事务提交(fsync) 2-5ms 强制刷盘保证持久性
总计 5-10ms/条 10万条 ≈ 500-1000秒

核心问题

  • 网络N次往返:10万次TCP交互
  • 事务N次提交:每次insert隐式事务都写磁盘
  • 日志N次fsync:redo log频繁刷盘

1.2 性能监控方法

sql 复制代码
-- MySQL侧监控:查看当前执行的SQL
SHOW PROCESSLIST;
SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep';

-- 查看慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 查看InnoDB状态(关注历史列表长度)
SHOW ENGINE INNODB STATUS\G
java 复制代码
// 应用侧监控:记录执行时间
StopWatch stopWatch = new StopWatch();
stopWatch.start("批量插入");

// 执行插入...

stopWatch.stop();
System.out.println(stopWatch.prettyPrint());

二、白银级优化:批量SQL(foreach)

2.1 实现方案

xml 复制代码
<!-- UserMapper.xml -->
<insert id="batchInsert" parameterType="java.util.List">
    INSERT INTO user (
        name, age, email, create_time
    ) VALUES
    <foreach collection="list" item="item" separator=",">
        (
            #{item.name},
            #{item.age},
            #{item.email},
            #{item.createTime, jdbcType=TIMESTAMP}
        )
    </foreach>
</insert>
java 复制代码
// 分批插入,避免SQL过长
@Service
public class BatchInsertService {
    
    private static final int BATCH_SIZE = 1000;  // 经验值:500-2000
    
    public void batchInsert(List<User> userList) {
        // 手动分页
        List<List<User>> partitions = Lists.partition(userList, BATCH_SIZE);
        
        for (List<User> batch : partitions) {
            userMapper.batchInsert(batch);
        }
    }
}

2.2 性能提升原理

复制代码
优化前(单条):
INSERT INTO user VALUES ('张三', 20, 'zs@qq.com');
INSERT INTO user VALUES ('李四', 25, 'ls@qq.com');
... 10万条独立SQL

优化后(批量):
INSERT INTO user VALUES ('张三', 20, 'zs@qq.com'), 
                       ('李四', 25, 'ls@qq.com'),
                       ... 1000条;

收益

  • 网络往返:10万次 → 100次(1000倍减少)
  • 事务提交:10万次 → 100次(1000倍减少)
  • SQL解析:10万次 → 100次(1000倍减少)

2.3 潜在风险与规避

风险 原因 解决方案
SQL超长 max_allowed_packet默认4MB 控制BATCH_SIZE为500-1000
内存溢出 大List在内存中 使用流式读取 + 分批处理
主键返回失败 useGeneratedKeys与批量冲突 升级MySQL驱动或改用UUID
唯一键冲突 批量插入中断 使用INSERT IGNOREON DUPLICATE KEY UPDATE
xml 复制代码
<!-- 增强版:支持主键返回和冲突处理 -->
<insert id="batchInsert" 
        useGeneratedKeys="true" 
        keyProperty="id"
        keyColumn="id">
    INSERT INTO user (name, age, email) VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.name}, #{item.age}, #{item.email})
    </foreach>
    ON DUPLICATE KEY UPDATE
    age = VALUES(age),
    email = VALUES(email)
</insert>

三、黄金级优化:JDBC批处理模式

3.1 核心参数:rewriteBatchedStatements

MySQL驱动魔法参数

properties 复制代码
# JDBC URL配置
jdbc.url=jdbc:mysql://localhost:3306/test?\
  rewriteBatchedStatements=true&\
  useServerPrepStmts=true&\
  cachePrepStmts=true&\
  prepStmtCacheSize=250&\
  prepStmtCacheSqlLimit=2048

参数详解

参数 作用 推荐值
rewriteBatchedStatements 将多条INSERT合并为单条MULTI-INSERT true(必开)
useServerPrepStmts 使用服务端预处理语句 true
cachePrepStmts 缓存预处理语句 true
prepStmtCacheSize 预处理语句缓存大小 250
prepStmtCacheSqlLimit 单条SQL缓存长度限制 2048

3.2 MyBatis批处理执行器

java 复制代码
@Service
public class JdbcBatchService {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    /**
     * JDBC批处理模式插入
     * 原理:ExecutorType.BATCH缓存SQL,flushStatements时批量发送
     */
    public void batchInsertWithJdbcBatch(List<User> userList) {
        // 关键:指定ExecutorType.BATCH
        try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
            UserMapper mapper = sqlSession.getMapper(UserMapper.class);
            
            int batchSize = 1000;
            for (int i = 0; i < userList.size(); i++) {
                mapper.insert(userList.get(i));
                
                // 达到批次大小时刷新
                if ((i + 1) % batchSize == 0) {
                    sqlSession.flushStatements();  // 发送批量SQL
                    sqlSession.clearCache();       // 清理一级缓存,防OOM
                }
            }
            
            // 处理剩余数据
            sqlSession.flushStatements();
            sqlSession.commit();  // 手动提交
        }
    }
}

3.3 底层原理深度解析

复制代码
传统模式(ExecutorType.SIMPLE):
insert1 → 网络发送 → 执行 → 返回
insert2 → 网络发送 → 执行 → 返回
...

批处理模式(ExecutorType.BATCH):
insert1 → 缓存到JDBC Statement
insert2 → 缓存到JDBC Statement
...
insert1000 → 缓存到JDBC Statement
flushStatements() → 一次性网络发送 → MySQL执行

rewriteBatchedStatements=true时的MySQL驱动优化:
驱动层将 "INSERT...; INSERT...; INSERT..." 
重写为 "INSERT... VALUES (...), (...), (...)"

性能对比

模式 10万条耗时 网络往返次数 事务提交次数
单条插入 300s 100,000 100,000
批量SQL 30s 100 100
JDBC批处理 8s 10 1

四、王者级优化:多线程并行

4.1 并行化策略设计

java 复制代码
@Service
public class ParallelBatchService {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    // 线程数 = min(CPU核心数, 数据库连接池大小/2)
    private static final int THREAD_COUNT = 4;
    private static final int BATCH_SIZE = 1000;
    
    /**
     * 多线程并行批量插入
     * 注意:仅适用于无事务一致性要求的场景
     */
    public void parallelBatchInsert(List<User> userList) {
        if (CollectionUtils.isEmpty(userList)) {
            return;
        }
        
        // 数据分区
        List<List<User>> partitions = Lists.partition(userList, 
            (int) Math.ceil((double) userList.size() / THREAD_COUNT));
        
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(partitions.size());
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);
        
        for (List<User> partition : partitions) {
            executor.submit(() -> {
                try {
                    doBatchInsert(partition);
                    successCount.addAndGet(partition.size());
                } catch (Exception e) {
                    log.error("批次插入失败", e);
                    failCount.addAndGet(partition.size());
                    // 失败处理:记录到重试队列或死信表
                } finally {
                    latch.countDown();
                }
            });
        }
        
        try {
            latch.await(5, TimeUnit.MINUTES);  // 超时控制
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("插入中断", e);
        } finally {
            executor.shutdown();
        }
        
        log.info("插入完成:成功{}条,失败{}条", successCount.get(), failCount.get());
    }
    
    private void doBatchInsert(List<User> userList) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
            UserMapper mapper = sqlSession.getMapper(UserMapper.class);
            
            for (int i = 0; i < userList.size(); i++) {
                mapper.insert(userList.get(i));
                
                if ((i + 1) % BATCH_SIZE == 0) {
                    sqlSession.flushStatements();
                    sqlSession.clearCache();
                }
            }
            
            sqlSession.flushStatements();
            sqlSession.commit();
        }
    }
}

4.2 连接池配置优化

yaml 复制代码
# HikariCP配置(推荐)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20          # 最大连接数 = 线程数 * 2 + 余量
      minimum-idle: 10               # 最小空闲连接
      connection-timeout: 30000      # 连接超时30秒
      idle-timeout: 600000           # 空闲连接存活10分钟
      max-lifetime: 1800000          # 连接最大生命周期30分钟
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true
        useLocalSessionState: true
        rewriteBatchedStatements: true
        cacheResultSetMetadata: true
        cacheServerConfiguration: true
        elideSetAutoCommits: true
        maintainTimeStats: false

4.3 线程安全与数据一致性

场景 方案 实现
无需事务 多线程并行 上述代码
需要事务 单线程批处理 去掉多线程,保持BATCH模式
部分失败重试 记录失败ID + 补偿机制 失败数据写入Redis/MQ,异步重试
全局事务 分布式事务(Seata) 性能下降,慎用

五、传说级优化:数据库层面调优

5.1 LOAD DATA INFILE(终极方案)

java 复制代码
/**
 * MySQL LOAD DATA INFILE方式
 * 适用:超大数据量(百万级以上),可接受短暂锁表
 * 性能:10万条 < 1秒
 */
@Service
public class LoadDataInfileService {
    
    @Autowired
    private DataSource dataSource;
    
    public void loadDataInfile(List<User> userList) throws Exception {
        // 1. 生成CSV临时文件
        File tempFile = File.createTempFile("user_data", ".csv");
        
        try (BufferedWriter writer = Files.newBufferedWriter(tempFile.toPath())) {
            for (User user : userList) {
                writer.write(String.format("%s,%d,%s\n",
                    escapeCsv(user.getName()),
                    user.getAge(),
                    escapeCsv(user.getEmail())
                ));
            }
        }
        
        // 2. 执行LOAD DATA INFILE
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            
            String sql = String.format(
                "LOAD DATA LOCAL INFILE '%s' INTO TABLE user " +
                "FIELDS TERMINATED BY ',' " +
                "LINES TERMINATED BY '\\n' " +
                "(name, age, email)",
                tempFile.getAbsolutePath()
            );
            
            stmt.execute(sql);
        } finally {
            tempFile.delete();  // 清理临时文件
        }
    }
    
    private String escapeCsv(String value) {
        if (value == null) return "\\N";
        if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
            return "\"" + value.replace("\"", "\"\"") + "\"";
        }
        return value;
    }
}

5.2 插入时禁用索引与日志

sql 复制代码
-- 会话级优化(仅当前连接有效)
SET SESSION unique_checks = 0;           -- 禁用唯一性检查
SET SESSION foreign_key_checks = 0;      -- 禁用外键检查
SET SESSION sql_log_bin = 0;             -- 禁用binlog(主从架构慎用)
SET SESSION autocommit = 0;              -- 手动控制事务

-- 表级优化
ALTER TABLE user DISABLE KEYS;           -- MyISAM有效,InnoDB忽略

-- 执行批量插入...

-- 恢复设置
SET SESSION unique_checks = 1;
SET SESSION foreign_key_checks = 1;
SET SESSION sql_log_bin = 1;
COMMIT;

ALTER TABLE user ENABLE KEYS;

5.3 表结构优化

sql 复制代码
-- 1. 使用自增主键(避免UUID随机插入导致的页分裂)
CREATE TABLE user (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    age TINYINT UNSIGNED,
    email VARCHAR(100),
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_create_time (create_time)  -- 二级索引
) ENGINE=InnoDB 
  ROW_FORMAT=COMPRESSED  -- 压缩存储,减少I/O
  KEY_BLOCK_SIZE=8;

-- 2. 插入时延迟索引构建(MySQL 8.0+)
ALTER TABLE user DROP INDEX idx_create_time;
-- 批量插入...
ALTER TABLE user ADD INDEX idx_create_time (create_time);

六、完整优化方案代码

6.1 分层优化策略类

java 复制代码
@Component
@Slf4j
public class OptimizedBatchInserter {
    
    @Autowired
    private SqlSessionFactory sqlSessionFactory;
    
    // 策略枚举
    public enum InsertStrategy {
        SIMPLE_BATCH,      // 批量SQL(<1万条)
        JDBC_BATCH,        // JDBC批处理(1-10万条)
        PARALLEL_BATCH,    // 并行批处理(10-50万条)
        LOAD_DATA_INFILE   // 文件加载(>50万条)
    }
    
    /**
     * 智能选择策略
     */
    public void smartBatchInsert(List<User> userList) {
        int size = userList.size();
        InsertStrategy strategy;
        
        if (size < 10000) {
            strategy = InsertStrategy.SIMPLE_BATCH;
        } else if (size < 100000) {
            strategy = InsertStrategy.JDBC_BATCH;
        } else if (size < 500000) {
            strategy = InsertStrategy.PARALLEL_BATCH;
        } else {
            strategy = InsertStrategy.LOAD_DATA_INFILE;
        }
        
        log.info("数据量:{},选择策略:{}", size, strategy);
        
        switch (strategy) {
            case SIMPLE_BATCH:
                simpleBatchInsert(userList);
                break;
            case JDBC_BATCH:
                jdbcBatchInsert(userList);
                break;
            case PARALLEL_BATCH:
                parallelBatchInsert(userList);
                break;
            case LOAD_DATA_INFILE:
                try {
                    loadDataInfile(userList);
                } catch (Exception e) {
                    throw new RuntimeException("LOAD DATA失败", e);
                }
                break;
        }
    }
    
    // 各策略实现...
}

6.2 监控与熔断机制

java 复制代码
@Component
public class BatchInsertMonitor {
    
    private final MeterRegistry meterRegistry;
    
    public void recordMetrics(int totalCount, long durationMs, int failCount) {
        // 吞吐量
        meterRegistry.counter("batch.insert.total").increment(totalCount);
        
        // 耗时分布
        meterRegistry.timer("batch.insert.duration").record(durationMs, TimeUnit.MILLISECONDS);
        
        // 错误率
        if (failCount > 0) {
            meterRegistry.counter("batch.insert.failures").increment(failCount);
        }
        
        // 熔断判断:错误率>5%或单批>10秒,降级为单线程
        double errorRate = (double) failCount / totalCount;
        if (errorRate > 0.05 || durationMs > 10000) {
            log.warn("触发熔断,降级处理");
            // 发送告警,切换策略
        }
    }
}

七、性能对比总结

优化级别 实现方式 10万条耗时 适用场景 注意事项
青铜 单条循环 300s 开发测试 生产环境禁用
白银 批量SQL 30s 小数据量、简单场景 控制SQL长度
黄金 JDBC批处理 8s 常规业务(推荐) 开启rewriteBatchedStatements
王者 多线程并行 3s 无事务要求的大数据量 控制线程数和连接池
传说 LOAD DATA INFILE <1s 超大数据量、可接受锁表 需要文件操作权限

八、常见问题排查手册

8.1 rewriteBatchedStatements不生效?

检查清单

  1. URL参数拼写正确(区分大小写)
  2. MySQL驱动版本 ≥ 5.1.13(推荐8.0+)
  3. 使用了ExecutorType.BATCH而非SIMPLE
  4. 调用了flushStatements()触发实际发送
java 复制代码
// 验证方法:开启MySQL查询日志
SET GLOBAL general_log = 'ON';
-- 查看日志中是否为MULTI-INSERT格式

8.2 内存溢出(OOM)

java 复制代码
// 解决方案:流式读取 + 批量插入
public void streamInsert() {
    try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
        // 使用游标查询,避免全量加载
        Cursor<User> cursor = sqlSession.getMapper(UserMapper.class).selectAllCursor();
        
        List<User> buffer = new ArrayList<>(1000);
        for (User user : cursor) {
            buffer.add(user);
            if (buffer.size() >= 1000) {
                batchInsert(buffer);
                buffer.clear();  // 及时清理
            }
        }
        
        if (!buffer.isEmpty()) {
            batchInsert(buffer);
        }
    }
}

8.3 主键返回问题

java 复制代码
// 方案1:使用UUID(推荐分布式场景)
user.setId(UUID.randomUUID().toString());
mapper.insert(user);  // 无需返回主键

// 方案2:批量插入后查询(折中)
batchInsert(users);
// 根据业务唯一键查询ID列表

// 方案3:升级MySQL驱动到8.0.17+(支持批量返回自增ID)

九、最佳实践 checklist

  • JDBC URL必加rewriteBatchedStatements=true
  • 根据数据量选择合适策略(<1万简单批量,>10万JDBC批处理,>50万LOAD DATA)
  • 多线程时线程数 ≤ 连接池大小 / 2
  • 每1000条flush一次,防止内存溢出
  • 生产环境禁用SIMPLE模式循环插入
  • 大数据量时考虑禁用索引、延迟唯一性检查
  • 记录失败数据,实现补偿机制
  • 监控插入速率和错误率,设置熔断降级
相关推荐
前端 贾公子1 小时前
深入理解 Vue3 的 v-model 及自定义指令的实现原理(中)
前端·html
王德印2 小时前
工作踩坑之导入数据库报错:Got a packet bigger than ‘max_allowed_packet‘ bytes
java·数据库·后端·mysql·云原生·运维开发
Never_Satisfied2 小时前
在HTML & CSS中,img标签固定宽度时,img和图片保持比例缩放
前端·css·html
Cache技术分享2 小时前
327. Java Stream API - 实现 joining() 收集器:从简单到进阶
前端·后端
人工智能先锋2 小时前
从零部署你的24小时AI管家:OpenClaw完整实战指南(附踩坑记录)
前端·github
不是株2 小时前
苍穹外卖(前端)
前端
zheshiyangyang2 小时前
前端面试基础知识整理【Day-6】
前端·面试·职场和发展
工业HMI实战笔记2 小时前
工业机器人HMI:协作机器人的人机交互界面
人工智能·ui·性能优化·机器人·自动化·人机交互·交互
星火开发设计2 小时前
关联式容器:set 与 multiset 的有序存储
java·开发语言·前端·c++·算法