PreparedStatement批量插入性能优化:从慢如蜗牛到秒级导入的实战指南
先把话说在前头如果你现在还在用for循环一条一条执行INSERT数据量一旦过万等待你的基本就是分钟级甚至小时级的耗时。我之前接过一个流水数据迁移任务40 万条记录要写入一张分表最初就是用 PreparedStatement 做单条循环插入跑了一个多小时没跑完改成批量提交之后整个导入在 3 分钟内结束代码改动不到十行。差别就是这么大而且不是玄学是每次网络往返、每次 SQL 编译、每次事务提交在叠加。这篇文章不讲那些标准文档里已经写烂的 API 介绍而是把我实际用 PreparedStatement 做批量操作时踩过的坑、验证过的参数、总结出来的写法一次性讲清楚包括addBatch/executeBatch/clearBatch的正确协作、批量大小怎么定、MySQL 连接参数里那些默认不生效的开关以及和 MyBatis 配合时经常被忽略的边界问题。适合正在做数据导入导出、定时同步任务、批量初始化数据的开发者参考。1. 一条条插入为什么这么慢性能瓶颈到底卡在哪先别急着上代码把为什么慢这件事搞清楚后面选方案才不会选错。1.1 单条循环里隐藏的三重开销我用PreparedStatement单条循环插入的时候表面上看每一条 SQL 都执行成功了耗时却高得吓人。拆开看每条 SQL 的执行路径上至少有三笔固定开销第一笔是网络往返。程序在应用服务器数据库在另一台机器每执行一次executeUpdate()都要走一次 TCP 往返。1 万条数据就是 1 万次往返哪怕每次只有 0.5 毫秒的 RTT光网络延迟就有 5 秒真实环境下这个数字还会放大。第二笔是 SQL 的解析和优化。虽然PreparedStatement会缓存执行计划但前提是驱动真的启用了服务端预编译。早期 MySQL 驱动默认走客户端预编译SQL 文本照样要在数据库端重新解析即使服务端预编译生效每一条 SQL 从网络包解析到执行计划复用依然有固定开销。第三笔是事务提交。很多人忽略了autoCommit默认是true也就是说你的每一条INSERT都自成事务每次提交都要刷一次日志、做一次事务记录。数据量大的时候磁盘 fsync 的开销会直接把你压垮。1.2 5万条数据的实测对比我当时在一张简单表上做了个对比测试写入 5 万条记录三种方式的耗时差别非常直观写入方式5万条耗时主要瓶颈Statement单条循环约 65 秒SQL 每次编译 网络往返 自动提交PreparedStatement单条循环约 58 秒省了部分编译网络往返和提交还在PreparedStatement批处理未开 rewrite约 20 秒驱动逐条发送仅省了部分往返PreparedStatement批处理 rewriteBatchedStatements约 3 秒合并为多条 VALUES 一次发送我自己的 40 万条流水迁移从单条循环的一个多小时压到 3 分钟靠的就是最后一行的效果。注意倒数第二行很多人以为用了addBatch()就算批量了实际上 MySQL 驱动默认不会把批处理合并成一条多值 INSERT 发送后面的第五章会专门讲这个参数。从这张表也能看出来批量操作解决的核心问题是减少网络往返次数和减少事务提交次数至于 SQL 编译那是预编译机制顺带解决的第三个问题。2. 预编译机制和批量 API 的正确理解知道批量为什么要用 PreparedStatement 之后还得把预编译机制和批处理 API 的底层逻辑理清楚否则很多坑你都不知道是为什么踩进去的。2.1 预编译到底帮你省掉了什么PreparedStatement和Statement的最大区别在于SQL 骨架在prepareStatement()阶段就确定下来了后面每次setXxx()只是在替换占位符上的参数SQL 文本本身没有变化。数据库收到一条 SQL 后正常流程是解析 SQL 语法、做语义检查、生成执行计划、执行。如果执行计划可以被缓存复用那后面几次执行就省掉了前三步的大部分开销。PreparedStatement的意义就在于让数据库知道这个 SQL 我还会再执行很多次参数不同而已从而把执行计划缓存起来。实际在 MySQL 连接串里加不加useServerPrepStmtstrue效果差别很大。不加这个参数预编译只是驱动在客户端做的一个模拟SQL 到了数据库端还是要重新解析加了之后驱动会真的让数据库端准备一条语句后续参数绑定和批处理都基于这条已准备的语句进行。这也是为什么我后面单独把连接参数列成一章。2.2 addBatch、executeBatch、clearBatch 三者的协作关系批处理的核心思路是先把参数反复绑定到同一个 PreparedStatement 上调用addBatch()把当前这组参数加入命令队列攒到一定数量后调用executeBatch()一次性发出然后清空队列继续下一轮。PreparedStatement ps conn.prepareStatement(insertSql); for (OrderDO order : orders) { ps.setString(1, order.getUserCode()); ps.setBigDecimal(2, order.getAmount()); ps.addBatch(); // 攒够一批就发出去 if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } // 最后不足一批的也要发送 ps.executeBatch();这里有一个关键约定同一批addBatch()内的 SQL 模板必须完全一致。因为数据库是按同一个语句模板来复用执行计划的一旦 SQL 文本变了驱动要么重新准备语句要么把当前批先执行掉再去处理新的 SQL。我见过有人在一个批循环里根据条件动态拼表名结果批处理被驱动悄悄拆成了一条条执行性能直接回到解放前。clearBatch()的作用是清空已经加入队列的命令缓冲。虽然部分驱动在executeBatch()之后会自动清空但我建议你养成显式调用的习惯语义更清晰也可以防止在同一个 PreparedStatement 上继续复用参数时发生残留问题。它只清命令不影响你已经绑定好的参数下一轮 set 参数直接覆盖就行。2.3 为什么不用 Statement 做批量有人会问Statement也有addBatch()和executeBatch()用它行不行行是行但性能逻辑完全不同。Statement的批处理要求你把每条完整 SQL 文本都拼好再addBatch()驱动收到的是 N 条独立的 SQL 文本。数据库端没有同一模板复用执行计划的机会每条 SQL 还是要单独解析、优化。而PreparedStatement只需要准备一次语句后面全是参数替换解析开销几乎可以忽略。此外用Statement拼 SQL 还有个致命问题字符串拼接困在了转义和注入上。用户输入里带个单引号你的 SQL 就毁了。PreparedStatement的参数绑定是类型化传递setString()自动处理转义不需要你关心引号规则。所以只要参数数量不确定或参数来自外部输入就老老实实用 PreparedStatement。3. 完整代码和事务边界批量操作的正确姿势这一章直接给可复制的写法顺便把事务边界、批大小、返回值这些容易被忽略的细节讲透。3.1 准备一张测试表先建一张测试表下面的例子都基于它CREATE TABLE batch_test ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_code VARCHAR(32) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;表结构越简单越能看清批处理本身的性能差异字段多了会引入网络包大小和内存开销的干扰因子。3.2 批量插入的标准写法下面是我在实际项目里沉淀下来的批量插入模板依赖注入和数据源获取按你项目的实际情况替换public void batchInsert(ListOrderDO orders) throws SQLException { String sql INSERT INTO batch_test (user_code, amount, status, create_time) VALUES (?, ?, ?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 关闭自动提交让一批数据成为一个事务 conn.setAutoCommit(false); int batchSize 500; int count 0; for (OrderDO order : orders) { ps.setString(1, order.getUserCode()); ps.setBigDecimal(2, order.getAmount()); ps.setInt(3, order.getStatus()); ps.setTimestamp(4, new Timestamp(order.getCreateTime().getTime())); ps.addBatch(); count; if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } // 处理最后不足一批的剩余数据 ps.executeBatch(); conn.commit(); } catch (SQLException e) { // 实际项目中需要回滚并把异常信息记录下来 throw new RuntimeException(批量插入失败, e); } }几个细节说一下。try-with-resources确保连接和语句最终释放setAutoCommit(false)一定要在批处理之前设置否则每个批执行后连接会自动提交事务边界就乱了最后一批executeBatch()不能省因为数据量不是总能被批大小整除。3.3 事务、批大小与内存的三角关系批大小不是越大越好这是很多人容易犯的错。批处理时参数值是在应用内存里攒着的批越大单次占用的内存和网络包就越大。一次发 5 万条记录光参数数组就够呛更别说数据库端要处理的锁和日志量。我个人的经验是普通行每行几个字段、单字段几百字节以内批大小取500 到 1000比较稳妥。字段多、单行数据大比如带大文本或 JSON就降到 200 到 500字段少、单行特别短的纯流水表可以放到 2000超出这个值网络包和内存收益就开始递减了。事务也是同理。很多人为了让数据安全把所有数据塞进一个大事务最后回滚日志爆炸、锁竞争加剧。正确的做法是把批和事务绑定每一轮executeBatch()结束后提交或者每 2000 条提交一次让一个失败只影响一小批数据重试成本低得多。而且 MySQL 默认max_allowed_packet是 64MB老版本是 4MB一批 SQL 合并后的网络包如果超过这个值驱动会直接报错小批提交也能有效规避这个问题。3.4 拿到 executeBatch 的返回值后能做什么executeBatch()返回的是int[]数组里每个元素对应批中一条命令的执行结果规则如下返回值含义 0该命令影响的行数-2SUCCESS_NO_INFO执行成功但数据库没返回行数信息-3EXECUTE_FAILED执行失败MySQL 驱动在启用服务端预编译时经常返回-2这不代表失败只是驱动没拿到具体的行数你别看到-2就当异常处理了。真正要防的是-3和BatchUpdateException下一章会详细讲部分失败的场景。4. 批量更新与批量删除实现差异和处理边界批量不只是插入的专利批量更新和批量删除同样能减少网络往返但它们的写法和注意点跟批量插入不太一样。4.1 批量更新的场景和生效条件批量更新的标准写法是把更新条件作为参数绑到同一个 SQL 模板上String sql UPDATE batch_test SET status ? WHERE id ?; PreparedStatement ps conn.prepareStatement(sql); for (Long id : ids) { ps.setInt(1, 1); ps.setLong(2, id); ps.addBatch(); } ps.executeBatch();这种写法比逐条 UPDATE 快核心收益同样是减少网络往返和执行计划复用。但有几个坑要留意批量更新不加条件就是全表更新这是事故高发点上线前一定要确认 WHERE 条件的参数来源批量更新涉及的行如果带唯一索引冲突中间某条失败会导致整批回滚所以批大小要更保守另外MySQL 驱动对 UPDATE 的批处理合并效果并不像 INSERT 那么明显rewriteBatchedStatements主要针对 INSERT 做多值重写UPDATE 想进一步提速得靠改写 SQL 本身比如用 CASE WHEN 批量改值或者接受逐条执行的网络开销。4.2 批量删除IN 还是逐条 DELETE批量删除一般有两条路线很多人没想清楚就选了一条后面性能或 SQL 长度出问题才返工。路线 A 是拼一条DELETE FROM batch_test WHERE id IN (?, ?, ?, ...)把主键列表放进去。优点是只有一条 SQL逻辑直观缺点是占位符数量受限制。MySQL 的占位符数量本身没有硬性上限但 SQL 文本长度受max_allowed_packet约束而且 Oracle 这类数据库的 IN 列表有 1000 项限制。主键列表一旦上千这条 SQL 就变得不可控。路线 B 是走 PreparedStatement 批处理每一条删除语句的 WHERE 只匹配一个 idString sql DELETE FROM batch_test WHERE id ?; PreparedStatement ps conn.prepareStatement(sql); for (Long id : ids) { ps.setLong(1, id); ps.addBatch(); if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch();我的建议是主键数量在几百以内用 IN 方案省事数量大或者未来可能更大直接用批处理方案别在 SQL 长度上踩坑。批处理方案每条 DELETE 都要扫描一次主键索引数据量特别大时对比一下两种方案的实际耗时再定别只看网络往返。4.3 部分失败是最容易被忽略的坑批量操作和单条操作的一个本质区别是批里某一条失败时前面执行成功的命令可能已经落库了。JDBC 4.1 提供了BatchUpdateException异常对象里带getUpdateCounts()返回的是批中每条命令的执行结果数组你可以通过这个数组判断失败发生在第几条、前面几条成功了。处理策略取决于你的业务要求。如果一批数据必须原子成功要么失败就rollback()整批要么重新组织数据重试剩下未成功的部分。如果业务允许部分成功就要捕获BatchUpdateException用getUpdateCounts()找出失败位置把成功和失败的数据分开记录失败的去重后进重试队列或生成失败报告。这两种策略我都用过实际项目里大部分数据导入场景选整批回滚、分批重试更稳妥单批数据小回滚成本低代码也简单。5. MySQL 连接参数配合批量失效和性能瓶颈的真正元凶这一章我想把所有跟 MySQL 驱动相关的坑集中讲清楚因为我见过太多人代码写得没问题性能却上不去最后全卡在连接串参数上。5.1 为什么 rewriteBatchedStatements 是关键开关MySQL 的 Connector/J 驱动有个非常反直觉的默认行为你用addBatch()攒了一批 INSERT驱动默认不会把它们合并成一条多值 INSERT 发出去而是仍然一条一条地发送只是复用了连接而已。这样批量的收益就只剩参数绑定的那一点点网络往返一张没少。解决办法就是给 JDBC 连接串加上这个参数jdbc:mysql://localhost:3306/test?serverTimezoneAsia/ShanghaiuseSSLfalserewriteBatchedStatementstruerewriteBatchedStatementstrue告诉驱动当批处理达到一定规模时把批内的 INSERT 重写成一条带多个 VALUES 的 SQL 发送。比如 500 条插入会变成INSERT INTO batch_test (user_code, amount, status, create_time) VALUES (?, ?, ?, ?), (?, ?, ?, ?), ...注意它依然使用 PreparedStatement占位符和参数绑定逻辑不变只是网络发送层面做了合并。这就是为什么我第五章第一节要专门强调它不加这个参数你的批量插入在 MySQL 上可能只是心理安慰。5.2 useServerPrepStmts 和 cachePrepStmts 的搭配除了rewriteBatchedStatements还有两个参数经常一起出现在生产连接串里useServerPrepStmtstrue cachePrepStmtstrue prepStmtCacheSize500 prepStmtCacheSqlLimit2048useServerPrepStmtstrue让驱动使用服务端预编译真正的执行计划缓存才生效cachePrepStmtstrue让驱动把已预编译的语句缓存起来避免同样的 SQL 反复prepareStatement()配合prepStmtCacheSize和prepStmtCacheSqlLimit控制缓存容量和 SQL 长度阈值。这几个参数的组合是我在线下压测里反复验证过的。只开rewriteBatchedStatements不开服务端预编译批量插入性能已经不错把useServerPrepStmts和cachePrepStmts也打开之后长连接场景下的重复执行稳定性更好。有一点要提醒驱动版本升级后这些参数的默认行为可能变化特别是 8.x 系列升级完一定要用线上流量做回归别想当然。5.3 一次批量失效的完整排查过程讲个真实排查经历。某同步任务后台看到数据库连接数和负载一直降不下来代码里明明写了addBatch()统计出来每秒执行的 INSERT 数量却和单条循环没区别。我的排查链路是先看应用日志确认没有异常然后用SHOW PROCESSLIST观察任务执行期间的会话状态发现一批语句在极短时间内一条接一条出现完全没有多值 INSERT的特征。接着看连接串发现rewriteBatchedStatements参数没配——写代码的人只在配置中心改了部分参数应用实际拿到的是旧配置。把参数补上、灰度发布SQL 数量立刻降了几个数量级。这个排查过程给我留了个习惯凡是涉及批量性能的改动先确认驱动连接串的真实参数再怀疑代码逻辑。你可以在自己的监控面板里加一条 SQL 语句总数指标批量是否生效看这个指标最直观比盯着耗时判断准确得多。6. 与 MyBatis 配合批量操作的生产级实践最后一部分聊聊 MyBatis因为现在大多数项目的数据访问层都是 MyBatis 包了一层纯 JDBC 写法的场景反而少了。6.1 ExecutorType.BATCH 的正确打开方式MyBatis 内置了三种执行器默认是SIMPLE批量场景要用BATCH。手动获取 SqlSession 并指定执行器类型的写法如下try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { BatchTestMapper mapper session.getMapper(BatchTestMapper.class); for (OrderDO order : orders) { mapper.insert(order); } session.commit(); }ExecutorType.BATCH内部就是把每条 mapper 方法映射的 SQL 转成PreparedStatement.addBatch()攒到一定量或提交时统一执行。它省去了你手写 JDBC 的样板代码但本质上还是那套批处理机制所以前面讲的批大小、连接参数、SQL 模板一致的约束全部适用。6.2 容易踩的坑Spring 事务边界和 SQL 变化用 MyBatis 做批量最大的坑有两个。第一个坑来自 Spring。用SqlSessionTemplate时它默认的管理策略是每次 mapper 方法调用后关闭会话。如果你在方法里写一个for循环反复调用 mapper 方法期望 BATCH 执行器把语句攒到一起结果往往是每次调用都单独提交批处理根本没有累积起来。解决办法是让整个循环处在一个 Spring 事务边界内比如给 Service 方法加Transactional或者手动从TransactionSynchronizationManager里拿同一个会话。我见过不少人栽在这个坑里代码明明是 BATCH 执行器性能却和单条一样。第二个坑是 SQL 模板必须一致。BATCH 执行器按 SQL 文本区分命令如果循环里每次调用 Mapper 方法产生的 SQL 文本不一样比如foreach拼出来的 IN 数量每次不同执行器会先执行掉上一批再累积新的一批批处理就被拆散了。所以批量插入推荐用固定占位符的insert别在批量场景里搞动态列、动态表名。另外MyBatis 的foreach可以拼多值 INSERTinsert idbatchInsert parameterTypelist INSERT INTO batch_test (user_code, amount, status, create_time) VALUES foreach collectionlist itemitem separator, (#{item.userCode}, #{item.amount}, #{item.status}, #{item.createTime}) /foreach /insert这个写法的实现是拼出一条大 SQL本质是减少 SQL 条数跟addBatch()是两种不同的优化路径。我第一次用它导入 5 万条数据时直接把max_allowed_packet打满了所以我的经验是foreach多值插入适合一次几百条的场景单次数据量上几千还是老老实实用 BATCH 执行器SQL 长度可控出错也好排查。6.3 大数据量批量导入的扩展思路最后分享两个在真实生产环境里反复碰壁后总结出来的思路。一是做分片 分批提交。无论用 JDBC 批处理还是 MyBatis BATCH都不要把几百万条数据一次性塞进一个批或一个事务里。把数据按主键或时间分片每个分片内部再按 500 到 1000 条一批提交任何一个分片失败都只重跑那一片恢复成本低也不会把数据库的 undo 日志和锁撑爆。二是做目的地索引策略。大表批量写入时索引维护是最大的隐形开销之一。导入前把非必要索引临时删掉、导入后重建这个操作在数据量超过 100 万时收益非常明显但只能用在可控迁移窗口内线上服务不能这么干。批量操作本身只是减少交互次数真正扛大流量导入还得靠分片、延迟索引、批大小、连接池这几件事组合起来单一技巧解决不了所有问题。我自己的惯例是每次批量改造完都会跑一次前后对比用 SQL 总数、批耗时、CPU/IO 这几个指标确认收益再决定要不要继续调参。批量操作这东西判断标准永远是你实测出来的数据而不是代码看起来像不像批量。