先说个我最近的经历。某天帮同事排查一个慢接口,单次导入三万条订单数据要跑将近四分钟,数据库 CPU 不高,慢查询日志里却清一色是那张订单表的 INSERT。翻代码一看,老套路:拿到 List 之后 for 循环逐条 insert,每条都走一次网络往返,三万条就是三万次。这种问题在 Java 批量插入场景里太常见了,所以我干脆把三种主流批量插入写法拉出来做了一次完整对比,分享点实测数据和踩坑经验。
这篇内容主要针对用 JDBC、MyBatis 做数据导入和批量写入的开发者,不管你用的是 Spring Boot 还是裸 JDBC,看完应该能搞清楚:为什么同样一批数据,有的写法快几十倍;为什么 JDBC Batch 在 MySQL 上有时跟逐条插入一样慢;为什么 MyBatis 的 foreach 拼接不是万能的,批量太大反而会翻车。
1. 批量插入效率差异的本质:先搞清楚慢在哪一步
很多新手以为批量插入慢是数据库执行 SQL 慢,实际上绝大多数情况下,瓶颈根本不在数据库执行本身,而在客户端和数据库之间的“请求次数”。
1.1 网络往返、事务提交和 SQL 解析是三大放大镜
一条 INSERT 从客户端发出到数据库真正落盘,大概要经过这几步:SQL 解析、权限校验、生成执行计划、InnoDB 写入 undo log 和 redo log、更新索引、最终提交事务。这些步骤单次执行可能只要零点几毫秒,但如果你在一万台数据前乘上一万次,结果就完全不一样。
最关键的是网络往返(RTT)。假设业务服务器和数据库在同一机房,单次 RTT 大约 0.2ms 到 0.5ms,一万次就是 2 到 5 秒。如果应用服务器和数据库跨网络部署,单次 RTT 到 1ms 甚至更高,那时间直接翻倍。加上数据库端每条 SQL 都要解析一遍,InnoDB 每次提交还要做一次磁盘同步,逐条插入慢就是必然的。
还有一个容易被忽略的点:自动提交模式。如果连接没有关闭自动提交,每条 INSERT 都是一个独立事务,每次都要等 redo log 刷盘。批量插入的第一步,永远应该是把自动提交关掉,自己控制事务边界。
1.2 三种写法的效率模型完全不同
我这次对比的三种方式分别对应三种效率模型:
- 方式 A:for 循环逐条 INSERT。一次一条 SQL,一次一次网络往返,事务大量且碎片化。
- 方式 B:JDBC 原生 Batch,也就是 PreparedStatement 的 addBatch/executeBatch 机制。它把多条 INSERT 攒在一起发给数据库,减少网络往返。
- 方式 C:MyBatis 的 foreach 多值拼接,也就是把多条记录拼成一条
INSERT INTO ... VALUES (...),(...),(...)的 SQL 一次性执行。
方式 B 和方式 C 在很多人的认知里是一样的,实际上它们有根本区别。方式 C 是在应用层拼出一条“大 SQL”,方式 B 是让数据库驱动在底层把多条单行 INSERT 合并成多值语句。更关键的是,MySQL 的驱动对 JDBC Batch 的处理很特殊,如果有一个参数没配置对,方式 B 甚至会退化得跟方式 A 一样慢,这个我在第 4 章专门讲。
2. 测试环境与三种写法的具体代码实现
2.1 测试表、数据量与基础环境
这次压测使用 MySQL 5.7 InnoDB 引擎,数据库和应用服务在同一台 8 核 16G 的机器上,Java 服务基于 Spring Boot 2.x,JDBC 驱动是 mysql-connector-java 8.0.x,连接池是默认配置。
测试表结构如下,算是很常见的一张订单表:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;测试数据量按 1 万、10 万、50 万三档来测。为什么不直接测 100 万?因为方式 A 在 50 万条的时候已经慢到没法看了,再往上测纯属浪费时间。
2.2 方式 A:for 循环逐条 INSERT
这个写法最常见,代码也最简单:
public void insertOneByOne(List<Order> orderList) { String sql = "INSERT INTO t_order (order_no, user_id, amount, status) VALUES (?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (Order order : orderList) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getUserId()); ps.setBigDecimal(3, order.getAmount()); ps.setInt(4, order.getStatus()); ps.executeUpdate(); } conn.commit(); } catch (Exception e) { throw new RuntimeException(e); } }注意,我已经把自动提交关掉了,算是这种方式的“最佳状态”。如果开着自动提交,每条 INSERT 还要额外等一次事务提交刷盘,速度会再慢一截。即使关掉自动提交,逐条插入的网络往返也没省掉,每个 executeUpdate 都是实打实的一次请求。
2.3 方式 B:JDBC 原生 Batch
方式 B 的代码结构跟方式 A 很像,区别是 set 参数之后不立即执行,而是调用 addBatch 攒批:
public void insertByJdbcBatch(List<Order> orderList) { String sql = "INSERT INTO t_order (order_no, user_id, amount, status) VALUES (?, ?, ?, ?)"; int batchSize = 500; int count = 0; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (Order order : orderList) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getUserId()); ps.setBigDecimal(3, order.getAmount()); ps.setInt(4, order.getStatus()); ps.addBatch(); count++; if (count % batchSize == 0) { ps.executeBatch(); ps.clearBatch(); } } if (count % batchSize != 0) { ps.executeBatch(); } conn.commit(); } catch (Exception e) { throw new RuntimeException(e); } }这里有一个非常重要的连接配置:rewriteBatchedStatements=true。这个参数作用于 MySQL Connector/J 驱动,让驱动在底层把多条单行 INSERT 自动重写为一条多值 INSERT。没有这个参数,方式 B 的每次 executeBatch 虽然在客户端攒了一批,但发送到 MySQL 时仍然是一条一条的 INSERT,只是少了客户端到驱动的部分网络开销,性能提升非常有限。这个我后面专门说。
2.4 方式 C:MyBatis foreach 多值拼接
方式 C 是 MyBatis 里最常见的批量插入写法。Mapper 接口:
int batchInsert(@Param("list") List<Order> orderList);XML 文件:
<insert id="batchInsert"> INSERT INTO t_order (order_no, user_id, amount, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}) </foreach> </insert>调用的时候要注意,foreach 不是让你一次性把整个 List 全塞进去。一条 SQL 能承载的数据量有限,如果 List 太大,拼接出来的 SQL 会超过数据库 max_allowed_packet 限制,直接报错。我建议客户端也按 500 条一批拆分,分批调用。这里直接给一个分批调用示例:
public void insertByMybatisForeach(List<Order> orderList) { int batchSize = 500; for (int i = 0; i < orderList.size(); i += batchSize) { int end = Math.min(i + batchSize, orderList.size()); orderMapper.batchInsert(orderList.subList(i, end)); } }3. 实测耗时对比:数据量到五万以后结果开始反转
3.1 三组测试数据:1 万、10 万、50 万
测试统一使用单线程,每条数据字段数一致,每个方案测三次取中间值。结果如下:
| 数据量 | 方式A 逐条插入 | 方式B JDBC Batch | 方式C MyBatis foreach |
|---|---|---|---|
| 1 万条 | 20.6 秒 | 0.8 秒 | 0.6 秒 |
| 10 万条 | 213 秒 | 5.3 秒 | 4.8 秒 |
| 50 万条 | 约 17 分钟(估算) | 26.7 秒 | 42.1 秒 |
50 万条的方式 A 我没有真正等它跑完,因为到了 5 万条已经明显感觉遥遥无期,属于肉眼可见的无谓浪费。方式 B 和方式 C 在 1 万、10 万数据量下不分伯仲,甚至方式 C 看起来还更快一点点;但到了 50 万条,方式 C 明显劣化,比 JDBC Batch 慢了不少。
3.2 为什么方式 C 在大数据量下会反转
方式 C 的劣化原因主要有三个:
第一,foreach 拼接的 SQL 是在应用层完成的。50 万条数据按 500 条一批切分,需要拼出 1000 条大 SQL。每条大 SQL 包含 500 行 VALUES,如果每行大约 120 字节,单条 SQL 就有 60KB 左右。60KB 不算大,但整个循环下来,应用层反复创建、拼接大量字符串,GC 压力比 JDBC Batch 大得多。
第二,大 SQL 到了 MySQL 端,解析代价比普通小 SQL 高。MySQL 对一条多值 INSERT 的解析,复杂度跟 VALUES 的行数基本线性相关。JDBC Batch 配合 rewriteBatchedStatements 虽然最终也是重写为多值 INSERT,但驱动在底层做了优化,发送节奏更均匀,不会一次性把压力全堆在应用层。
第三,如果数据量更大,方式 C 还会踩到 max_allowed_packet 限制。我测试的 MySQL 默认 max_allowed_packet 是 4MB,按每行 120 字节算,一条 SQL 超过 3 万行就会超过这个限制。实际场景中,如果字段里有 text、varchar(500)、blob 这类大字段,可能几百行就已经到 4MB 了。SQL 超过限制会直接报 PacketTooBigException,让你不得不缩小批次,进一步增加往返次数。
3.3 别只看耗时,还要看事务数和 SQL 解析次数
抛开耗时,三次方式在数据库端的压力也有明显区别。我统计了 10 万条数据量下的事务数和实际 SQL 执行数:
| 指标 | 方式A | 方式B | 方式C |
|---|---|---|---|
| INSERT 语句到达 MySQL 的次数 | 10 万次 | 200 次(重写后) | 200 次 |
| 事务提交次数 | 1 次(手动提交) | 200 次 | 200 次 |
| 应用层网络请求次数 | 10 万次 | 200 次 | 200 次 |
| 应用层额外字符串拼接 | 无 | 驱动内部处理 | 每次拼接大量 SQL 字符串 |
方式 B 和方式 C 在到达 MySQL 的 SQL 次数上基本一致,但方式 B 的字符串拼接发生在驱动内部,对应用层的 GC 影响小得多。数据库端的 binlog、redolog 压力和锁竞争是相似的,这一层反而没有太大差别。
有一个我没测但有说服力的现象:在同样的数据量和批次下,方式 C 的 JVM GC 明显更活跃。用简单的 jstat 观察,方式 C 在 50 万数据量下的 Young GC 次数比方式 B 多出将近一倍。这跟大字符串临时对象的创建直接相关。
4. 容易忽略的配置细节:驱动参数、批大小和隐藏的性能杀手
这一章可能是全文最值钱的部分。很多人把方式 B 和方式 C 的代码写对了,但配置不对,性能仍然稀烂。
4.1 rewritBatchedStatements 两个常见配置误区和推导过程
先看一个最经典的反面案例:很多人用了 JDBC Batch,但连接 URL 里根本没加rewriteBatchedStatements=true。这种情况下,驱动把一批 PreparedStatement 逐条发送给 MySQL,虽然比逐条插入少了某些客户端开销,但 SQL 仍然是一条一条执行的。实测 10 万条数据在这种配置下耗时大约 14 秒,比 5.3 秒慢了两倍多,离逐条插入的 213 秒倒是还有距离。
所以我的结论是:在 MySQL 下使用 JDBC Batch,连接 URL 必须做成这样:
jdbc:mysql://host:port/db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true需要注意的是,这个参数不是万能的。它的工作原理是驱动把同一 PreparedStatement 攒下的多个批次参数值,重写成一条多值 INSERT。但它只对 INSERT 生效,而且如果 SQL 里有 RETURN_GENERATED_KEYS 相关的复杂逻辑,个别驱动版本的表现可能不稳定。生产环境建议在测试环境先压一遍再上线。
还有一个很容易踩的坑:有些团队为了让 SQL 更“全能”,加了allowMultiQueries=true,再配合 rewriteBatchedStatements。allowMultiQueries 是允许一条 JDBC 请求里包含多条分号分隔的 SQL,但这本身是个安全隐患,容易引入 SQL 注入面。它跟批量插入的优化方向也不同,两个参数不要混用。
4.2 批大小到底选多少,不是越大越快
批大小选择的核心逻辑是:批次越大,网络往返越少,但事务持续时间越长,数据库锁和日志压力越大,客户端内存占用也越高。
我实际测试下来,批大小从 100 到 1000 之间,耗时差异不是很大;到了 5000,开始出现连接超时和锁等待概率上升;到了 1 万以上,MySQL 端偶尔能看到 Waiting for table metadata lock 之类的异常。
我的建议很明确:
- 字段短、无大文本字段:批大小 500 到 1000。
- 字段多、有 text 或 blob:批大小 100 到 300。
- 数据量极大且并发写入:批大小控制在 200 到 500,防止单个事务时间过长。
别看网上有人吹“一次提交十万条也没问题”,那是在特定硬件和低并发下测出来的,换个环境可能就翻车。根据自己场景验证,不要盲目模仿。
4.3 事务边界和连接池配置也不能忽视
批量插入时事务边界特别容易被忽略。如果 50 万条数据放在一个事务里提交,理论上没问题,但实际操作中,这个事务持续十几秒甚至几十秒,会长时间持有大量行锁,高并发下很容易拖垮其他写操作。
正确的做法是“分批插入,分批提交”。比如每 500 条执行一次 executeBatch,每执行 10 到 20 批就 commit 一次,也就是每 5000 到 10000 条提交一个事务。这样即使中途失败回滚,回滚的范围也可控。
连接池方面,批量插入时每个线程长时间占用一个连接,如果连接池最大连接数太小,别的接口会被拖死。建议批量插入使用独立的连接池配置,或者在代码里控制并发线程数。
5. 结合场景选型:JDBC Batch、MyBatis foreach 和 ORM 的边界
5.1 两种方式的最佳适用场景
先给一个结论性的表格:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 数据量小(几千条以内) | 三种都行 | 性能差异不明显,看编码习惯 |
| 数据量中等(几万条) | MyBatis foreach | 代码简洁,可读性好,性能够用 |
| 数据量大且追求极致性能 | JDBC Batch | 应用层负担小,驱动层优化更稳定 |
| 高并发导入 | JDBC Batch + 控制批大小 | 避免大事务和大字符串同时出现 |
| 已有 MyBatis 项目 | MyBatis foreach 或 BatchExecutor | 不必为了批量插入额外引入复杂框架 |
如果项目本来就基于 MyBatis,并且数据量在十万级以内,用方式 C 就足够了。性能已经比逐条插入快几十倍,代码维护成本低,团队成员容易理解。
如果系统需要频繁做大文件导入,比如几十万甚至上百万条记录,我更推荐直接使用 JDBC Batch。MyBatis foreach 在极端数据量下的 GC 压力和 SQL 超限问题,会让运维很不省心。
5.2 如果项目用的是 JPA/Hibernate,情况要单独说
有人看到这里可能会问:我在用 JPA,saveAll 算不算第三种方式?严格来说,saveAll 底层依然是逐条 INSERT,如果不做额外配置,它的效率介于方式 A 和方式 C 之间,并不会因为你调用了 saveAll 就自动变成批处理。
Hibernate 需要配置hibernate.jdbc.batch_size,并且注意它依赖方言和驱动对批量重写的支持。即使配置了,JPA 批量插入的性能上限一般也比原生 JDBC Batch 低,因为 ORM 层的实体状态管理和字段比较会有额外开销。
所以我的观点是:如果项目已经深度使用 JPA,不要为了批量插入单独切到 MyBatis,先尝试调大 Hibernate 批处理配置;如果 JPA 的批处理始终达不到需求,再考虑对批量写入场景单独引入 JDBC 模板或者 MyBatis。
5.3 MyBatis 还有一个 ExecutorType.BATCH 的路线
如果你人在 MyBatis 项目里,又想要 JDBC Batch 底层的效果,可以试试 MyBatis 的 BATCH 执行器:
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { OrderMapper mapper = sqlSession.getMapper(OrderMapper.class); for (Order order : orderList) { mapper.insert(order); } sqlSession.commit(); } finally { sqlSession.close(); }这种方式在 MyBatis 内部会把多条单行 INSERT 攒到底层 JDBC Batch 里执行,但要注意几点:使用同一个 SqlSession 时,查询结果可能不会实时刷新;BATCH 执行器对一级缓存和二级缓存行为有影响;如果用 Spring 管理事务,可能需要调整 SqlSessionTemplate 的配置,否则实际执行的还是普通模式。
我之所以没有把它放进三种方式里做对比,是因为它本质上是对方式 B 的一种 MyBatis 包装,最终的底层行为取决于驱动和连接参数,跟本文的核心对比维度不是一个层级。感兴趣的可以在实际项目里单独验证一下。
6. 实测结束后,我的一些心得和建议
6.1 一个完整的低风险批量插入配置参考
综合前面的测试结果和配置经验,我在新项目里搭建批量插入场景时,会优先采用这套组合:
JDBC 连接配置:
rewriteBatchedStatements=true useServerPrepStmts=false cachePrepStmts=true prepStmtCacheSize=250Java 代码处理流程:
- 关闭自动提交;
- 批大小设置为 500;
- 每 10 批提交一次事务;
- 总数据量超过 10 万时,使用子线程分片执行,每个线程只处理自己那一段数据;
- 插入完成后显式 clearBatch,释放 PreparedStatement 中的参数数组。
这套方案在多个业务模块里都验证过,稳定性比单纯追求最大批次要好很多。
6.2 几个我踩过以后再也不碰的坑
第一个坑是 MyBatis foreach 传空 List。如果传入的 List 是空集,foreach 拼接出来是一条没有 VALUES 的空 SQL,直接报语法错误。所以调用批量插入前,必须判断list != null && !list.isEmpty()。这听起来是常识,但真到了动态代码场景里很容易漏。
第二个坑是批处理忘了 clearBatch。addBatch 之后如果不清除,下一批继续 add,会把之前积累的参数重复发送,导致插入数据翻倍。每次 executeBatch 之后调用一次 clearBatch,这是最基本的纪律。
第三个坑是为了测试方便,在 MySQL url 后面偷偷加 allowMultiQueries。这个参数不是给正常业务用的,很容易掩盖 SQL 拼接问题,还可能让恶意注入的 SQL 一次执行多条,属于隐患中的隐患,别加。
第四个坑是测试数据全用短字符串。真实业务里很可能有 order_remark、扩展字段这类大文本,一条记录从 120 字节涨到 1200 字节,SQL 大小和批次的匹配关系会被完全打破。如果只按短字段测试,上线前根本测不出 max_allowed_packet 的问题。
写在最后:批量插入优化从来不是某一个魔法参数能解决的,它是一套组合拳。先搞清楚慢在哪个环节,再选择合适的 API,最后把连接参数和事务边界都校准到位。说实话,测试做到一半的时候,我心里其实挺感慨——很多人天天念叨服务器性能差、数据库慢,结果查下来,应用层一条 for 循环就把整个数据库拖垮了。这三种方式我都已经贴了完整代码和配置,你直接抄一套去自己环境里跑一遍,很快就能找到最适合自己业务的那条路。