写这篇东西的起因很简单:前阵子给一个数据中台项目做历史数据迁移,单表一千多万行,从Oracle往MySQL搬。一开始用最原始的JDBC循环insert,跑了四十分钟才插进去两百万行,按这个速度得等三个多小时,肯定不行。后来把批量插入、事务切分、MySQL端参数全部调了一遍,最终把全量导入压到了20分钟以内。这篇文章就是把这次调优过程中真正起作用的细节整理出来,包括为什么批量插入会快、JDBC和MyBatis两种写法的正确姿势、以及从"能跑"到"跑得快"之间那些容易踩的坑。
如果你正在做数据迁移、报表数据初始化,或者线下数据导入线上库这类工作,这篇文章应该能帮你省掉不少试错时间。代码层面的写法、驱动参数的设置、MySQL服务端参数的调整,我会逐个拆开讲。
1. 批量插入效率的瓶颈到底卡在哪里:从一次插入一条说起
很多人一提批量插入,第一反应就是"把多条INSERT拼成一条SQL"。这确实是批量插入最直观的形态,但它只是表象。真正决定导入速度的是三个层面的东西:网络往返次数、SQL解析与执行计划的重复开销、事务提交时的刷盘动作。如果你只改了SQL形态而没动这三样,效率提升会非常有限。
1.1 单条插入慢,慢在哪里
先看最基本的场景:用JDBC一条一条执行INSERT,就像这样:
java复制Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement ps = conn.prepareStatement("INSERT INTO t_user (name, age, address) VALUES (?, ?, ?)");
for (User user : userList) {
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.setString(3, user.getAddress());
ps.executeUpdate(); // 每次都是一次完整的网络往返
}
conn.commit();
这种写法每一行数据都要经历:客户端把SQL发给MySQL服务器、MySQL做词法分析、语法分析、生成执行计划、执行插入、返回结果,然后客户端再发下一条。一次往返的耗时哪怕只有0.5毫秒,一千条就是500毫秒,一百万条就是500秒,光网络开销就把时间吃掉了。
更重要的是,默认情况下MySQL的autocommit是开启的,executeUpdate()每执行一次就会隐式提交一次事务。而事务提交是重操作——InnoDB需要把redo log刷到磁盘,还需要释放行锁、更新相关的内部状态。频繁提交事务带来的开销,很多时候比INSERT本身还要大。
1.2 批量插入为什么能快:三个关键因素的改变
批量插入的效率来源,本质上就是针对上面三个瓶颈逐一拆解:
减少网络往返。 这是最直观的一点。把一千条数据打包成一条SQL或者用JDBC的批量机制一次发给MySQL,网络交互从一千次变成一次或者几十次。对于跨机房、跨网段的数据库,这个收益尤其明显。
减少SQL解析与执行计划生成。 MySQL拿到一条多VALUES的INSERT语句,虽然VALUES数量多了,但SQL模板只有一个,词法分析、语法分析只做一遍,执行计划也只生成一次。对比一下,一千条独立INSERT需要做一千遍完整解析。这个在高并发插入场景下是实打实的CPU开销。
事务提交次数大幅下降。 如果你用一条SQL插入一千行,整个操作在一个事务内完成,只需要在最后提交一次。一次事务提交的redo log刷盘成本被一千条数据摊薄了,单位数据的提交成本瞬间降了几个数量级。
这里可以用一个日常生活里的类比:单条插入像寄快递,每个包裹跑一趟快递站;批量插入像叫了一辆大卡车把一堆包裹一次性拉走。卡车的调度成本确实比单个包裹高一点,但均摊到每个包裹上就微不足道了。
1.3 一个容易被忽略的隐藏瓶颈:索引维护
如果说上面三条是批量插入的"主要矛盾",那还有一条"次要矛盾"经常被忽略:索引维护。
InnoDB的二级索引不是顺序插入的。对于非自增主键或者二级索引,每次插入都要在B+树中定位插入位置,如果索引列的值随机分布,还会引发频繁的页分裂和页合并。数据量一上来,索引维护成本可能超过数据本身写入成本。
这也是为什么很多数据导入实战里,面对一个大表会先删掉非必要索引、导入完成后再重建。原因就是让数据插入阶段尽可能减少随机写入,等数据落盘后再通过一次性的索引构建来组织数据结构。实际项目中,这种做法往往能带来30%到50%的额外提速,尤其当表里有三四个二级索引时。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JDBC批量插入的正确姿势:rewriteBatchedStatements与addBatch实战
Java后端开发做批量插入,最常见的两种实现思路是:原生JDBC的addBatch/executeBatch,以及MyBatis的foreach拼接。两种方式底层逻辑不一样,性能和坑也完全不同。这一节先把原生JDBC讲透。
2.1 一个坑先说明白:没设rewriteBatchedStatements的addBatch是假的
网上很多教程说JDBC批量插入只要用addBatch就行,实测下来根本不是这么回事。在MySQL的JDBC驱动里,如果你连接串上没有设置rewriteBatchedStatements=true,那么executeBatch()仍然会把每条INSERT单独发到服务器,只不过客户端少了几次网络往返的编码开销而已,服务器端该解析的解析、该提交的提交,一样不少。
我做过一个对比测试,插入10万条数据,表结构是三个字段、两个索引:
| 写入方式 | 耗时 |
|---|---|
| 单条executeUpdate | 42秒 |
| addBatch,未设置rewriteBatchedStatements | 35秒 |
| addBatch,设置rewriteBatchedStatements=true | 9秒 |
仅仅加了一个连接参数,性能从35秒提升到9秒,接近4倍差距。原因就是rewriteBatchedStatements=true会把一批INSERT INTO t (a,b) VALUES (?,?)自动重写成一条多VALUES的语句,示例效果等价于:
sql复制INSERT INTO t_user (name, age, address) VALUES ('张三', 25, '北京'), ('李四', 30, '上海'), ('王五', 28, '广州');
重写之后,网络往返变成一次,SQL解析也变成一次,效果立竿见影。
正确写法是连接串加上参数:
java复制String url = "jdbc:mysql://127.0.0.1:3306/test_db?
