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 IGNORE或ON 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不生效?
检查清单:
- URL参数拼写正确(区分大小写)
- MySQL驱动版本 ≥ 5.1.13(推荐8.0+)
- 使用了
ExecutorType.BATCH而非SIMPLE - 调用了
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模式循环插入 - 大数据量时考虑禁用索引、延迟唯一性检查
- 记录失败数据,实现补偿机制
- 监控插入速率和错误率,设置熔断降级