一、为什么你的数据库总是加班到深夜?
咱们程序员在日常工作中,十有八九都会遇到需要批量写入数据库的场景。上周我帮朋友优化一个订单系统,单日100万条数据的插入操作,原始方案每次insert都单独执行,数据库简直成了晚高峰的北京三环路——堵得水泄不通。
这里有个真实案例:某电商平台的订单明细表,在双十一期间需要每秒处理5000+订单记录。使用常规的单条插入方法,数据库连接池直接爆满,整个系统响应延迟飙升到3秒以上,最终不得不临时关闭部分功能。
二、磨刀不误砍柴工:准备你的开发工具箱
技术栈选择:
- Java 11 + MySQL 8.0
- JDBC驱动:mysql-connector-java 8.0.28
- 数据库连接池:HikariCP 4.0.3
- 测试数据生成:Java Faker 1.0.2
先在MySQL中创建我们的战场:
CREATE TABLE user_behavior (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(20) NOT NULL,
action_type ENUM('click','view','purchase') NOT NULL,
device_id CHAR(16),
event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_action (user_id, action_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
三、基础招式:JDBC的批处理武术
新手常见错误示范:
// 典型错误示例:循环单条插入
try (Connection conn = dataSource.getConnection()) {
for (UserBehavior behavior : behaviorList) {
String sql = "INSERT INTO user_behavior (user_id, action_type, device_id) VALUES (?,?,?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, behavior.getUserId());
pstmt.setString(2, behavior.getActionType().name());
pstmt.setString(3, behavior.getDeviceId());
pstmt.executeUpdate(); // 每次都单独执行
}
}
正确的基础批处理写法:
public void batchInsertBasic(List<UserBehavior> behaviors) throws SQLException {
String sql = "INSERT INTO user_behavior (user_id, action_type, device_id) VALUES (?,?,?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
for (UserBehavior behavior : behaviors) {
pstmt.setString(1, behavior.getUserId());
pstmt.setString(2, behavior.getActionType().name());
pstmt.setString(3, behavior.getDeviceId());
pstmt.addBatch(); // 加入批处理包
// 每1000条执行一次
if (behaviorList.indexOf(behavior) % 1000 == 0) {
pstmt.executeBatch();
pstmt.clearBatch();
}
}
pstmt.executeBatch(); // 处理残余数据
}
}
这里的关键点就像搬家时用纸箱打包小物件,而不是每次只拿一个杯子下楼。实测5万条数据的插入时间从原来的85秒缩短到6.3秒,但还有更大的优化空间。
四、性能特攻队
4.1 连接池的正确打开方式
HikariCP配置示例:
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/test_db");
config.setUsername("root");
config.setPassword("securePassword");
config.setMaximumPoolSize(20); // 根据CPU核心数调整
config.setMinimumIdle(5);
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
4.2 改写SQL语句的魔法
原生写法:
INSERT INTO table (col1,col2) VALUES (v1,v2);
INSERT INTO table (col1,col2) VALUES (v3,v4);
...
优化后的批量化写法:
INSERT INTO user_behavior (user_id, action_type, device_id)
VALUES
(?,?,?),
(?,?,?),
(?,?,?),
... -- 500-1000组值
Java实现:
public void batchInsertOptimized(List<UserBehavior> behaviors, int batchSize) {
StringBuilder sql = new StringBuilder(
"INSERT INTO user_behavior (user_id, action_type, device_id) VALUES ");
// 动态构造VALUES部分
for (int i = 0; i < batchSize; i++) {
sql.append(i == 0 ? "(?,?,?)" : ",(?,?,?)");
}
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql.toString())) {
int paramIndex = 1;
for (UserBehavior behavior : behaviors) {
pstmt.setString(paramIndex++, behavior.getUserId());
pstmt.setString(paramIndex++, behavior.getActionType().name());
pstmt.setString(paramIndex++, behavior.getDeviceId());
if (paramIndex > batchSize * 3) {
pstmt.executeUpdate();
paramIndex = 1;
}
}
if (paramIndex > 1) {
pstmt.executeUpdate();
}
}
}
这种写法相当于把多个插入请求打包成一个HTTP请求,实测10万条数据插入时间从6.3秒降到1.8秒。
五、高并发下的特种作战
当遇到百万级数据插入时,我们需要更激进的优化策略:
5.1 Load Data大法
public void loadDataInfile(List<UserBehavior> behaviors) throws IOException, SQLException {
File csvFile = File.createTempFile("batch-", ".csv");
// 生成CSV文件
try (BufferedWriter writer = new BufferedWriter(new FileWriter(csvFile))) {
for (UserBehavior behavior : behaviors) {
writer.write(String.join(",",
behavior.getUserId(),
behavior.getActionType().name(),
behavior.getDeviceId()
));
writer.newLine();
}
}
// 执行LOAD DATA命令
String sql = "LOAD DATA LOCAL INFILE '" + csvFile.getAbsolutePath() +
"' INTO TABLE user_behavior FIELDS TERMINATED BY ','";
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute(sql);
}
}
这种方式就像用快递整车送货,而不是零散包裹。实测100万条数据仅需7秒,但需要注意文件格式和路径权限问题。
六、避坑指南:这些地雷千万别踩
- 事务隔离陷阱:长时间未提交的事务会导致版本链膨胀
conn.setAutoCommit(false); // 开始事务
try {
// 批处理操作
conn.commit(); // 每批处理提交一次
} catch (SQLException e) {
conn.rollback(); // 异常回滚
}
- 索引的双刃剑:在批量插入前禁用非必要索引
ALTER TABLE user_behavior DISABLE KEYS;
-- 批量插入操作 --
ALTER TABLE user_behavior ENABLE KEYS;
- 内存溢出警报:控制批处理包的大小
int batchCount = 0;
for (UserBehavior behavior : behaviors) {
// 添加参数
if (++batchCount % 1000 == 0) {
pstmt.executeBatch();
pstmt.clearBatch();
}
}
七、实战性能对比表
| 方法 | 10万数据耗时 | 100万数据耗时 | 资源消耗 |
|---|---|---|---|
| 单条插入 | 85秒 | 未完成 | 高 |
| 基础批处理 | 6.3秒 | 63秒 | 中 |
| 优化批处理 | 1.8秒 | 18秒 | 中 |
| LOAD DATA方式 | 0.7秒 | 7秒 | 低 |
八、终极战场选择指南
- 实时交易系统:采用优化批处理 + 事务分批提交
- 数据分析场景:直接使用LOAD DATA INFILE
- 混合读写系统:配合读写分离架构实施
- 物联网数据:考虑使用Kafka做缓冲队列
评论