资讯详情

MySQL批量插入性能对比:JDBC Batch vs MyBatis foreach

📅 2026/10/13 10:59:41 | 华诺云谱 👁 阅读
MySQL批量插入性能对比:JDBC Batch vs MyBatis foreach
先说个我最近的经历。某天帮同事排查一个慢接口单次导入三万条订单数据要跑将近四分钟数据库 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 三种写法的效率模型完全不同我这次对比的三种方式分别对应三种效率模型方式 Afor 循环逐条 INSERT。一次一条 SQL一次一次网络往返事务大量且碎片化。方式 BJDBC 原生 Batch也就是 PreparedStatement 的 addBatch/executeBatch 机制。它把多条 INSERT 攒在一起发给数据库减少网络往返。方式 CMyBatis 的 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.xJDBC 驱动是 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;测试数据量按 1 万、10 万、50 万三档来测。为什么不直接测 100 万因为方式 A 在 50 万条的时候已经慢到没法看了再往上测纯属浪费时间。2.2 方式 Afor 循环逐条 INSERT这个写法最常见代码也最简单public void insertOneByOne(ListOrder 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 方式 BJDBC 原生 Batch方式 B 的代码结构跟方式 A 很像区别是 set 参数之后不立即执行而是调用 addBatch 攒批public void insertByJdbcBatch(ListOrder 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); } }这里有一个非常重要的连接配置rewriteBatchedStatementstrue。这个参数作用于 MySQL Connector/J 驱动让驱动在底层把多条单行 INSERT 自动重写为一条多值 INSERT。没有这个参数方式 B 的每次 executeBatch 虽然在客户端攒了一批但发送到 MySQL 时仍然是一条一条的 INSERT只是少了客户端到驱动的部分网络开销性能提升非常有限。这个我后面专门说。2.4 方式 CMyBatis foreach 多值拼接方式 C 是 MyBatis 里最常见的批量插入写法。Mapper 接口int batchInsert(Param(list) ListOrder orderList);XML 文件insert idbatchInsert INSERT INTO t_order (order_no, user_id, amount, status) VALUES foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}) /foreach /insert调用的时候要注意foreach 不是让你一次性把整个 List 全塞进去。一条 SQL 能承载的数据量有限如果 List 太大拼接出来的 SQL 会超过数据库 max_allowed_packet 限制直接报错。我建议客户端也按 500 条一批拆分分批调用。这里直接给一个分批调用示例public void insertByMybatisForeach(ListOrder 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 foreach1 万条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方式CINSERT 语句到达 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 里根本没加rewriteBatchedStatementstrue。这种情况下驱动把一批 PreparedStatement 逐条发送给 MySQL虽然比逐条插入少了某些客户端开销但 SQL 仍然是一条一条执行的。实测 10 万条数据在这种配置下耗时大约 14 秒比 5.3 秒慢了两倍多离逐条插入的 213 秒倒是还有距离。所以我的结论是在 MySQL 下使用 JDBC Batch连接 URL 必须做成这样jdbc:mysql://host:port/db?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue需要注意的是这个参数不是万能的。它的工作原理是驱动把同一 PreparedStatement 攒下的多个批次参数值重写成一条多值 INSERT。但它只对 INSERT 生效而且如果 SQL 里有 RETURN_GENERATED_KEYS 相关的复杂逻辑个别驱动版本的表现可能不稳定。生产环境建议在测试环境先压一遍再上线。还有一个很容易踩的坑有些团队为了让 SQL 更“全能”加了allowMultiQueriestrue再配合 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情况要单独说有人看到这里可能会问我在用 JPAsaveAll 算不算第三种方式严格来说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 连接配置rewriteBatchedStatementstrue useServerPrepStmtsfalse cachePrepStmtstrue prepStmtCacheSize250Java 代码处理流程关闭自动提交批大小设置为 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 循环就把整个数据库拖垮了。这三种方式我都已经贴了完整代码和配置你直接抄一套去自己环境里跑一遍很快就能找到最适合自己业务的那条路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑