原生JDBC批量插入实战:List切割与批次大小优化指南 1. 为什么批量插入这件事值得单独拿出来聊做后端开发的人几乎都绕不开一个场景手里攒了一大批对象需要写进数据库。数据量小的时候循环里一条一条insert也能跑代码写起来还特别顺手。可一旦数据量上去比如几万条、几十万条这种写法就会立刻暴露出问题——网络往返次数太多、事务开销太大、数据库连接被长时间占用最终结果就是接口响应时间从几百毫秒飙到几十秒甚至直接把连接池打满。我见过不少项目在这个环节上吃过亏。有的是定时任务跑批数据量一大就超时有的是数据迁移脚本跑到一半报连接超时还有的是导入功能用户上传一个几万行的表格后台处理直接卡死。这些问题的根子往往不在业务逻辑而在于数据写入方式本身就不适合大批量场景。这篇内容要聊的就是怎么用原生 JDBC把一批List数据切割成合适大小的批次再批量插入数据库。关键词很明确原生 JDBC、List 切割、批量插入。它解决的核心问题是在不引入 MyBatis、Hibernate 这类 ORM 框架的前提下用最底层、最可控的方式把大批量数据高效、稳定地写进数据库。适合谁来读如果你已经会写基本的 JDBC 代码但对批量操作只停留在“听说过addBatch”的阶段那这篇会很适合你。如果你正在做数据导入、跑批、数据同步这类功能需要一套能直接落地的方案那更值得往下看。我会把参数怎么定、批次怎么切、异常怎么处理、性能怎么验证都拆开讲清楚尽量让你看完就能照着改自己的代码。需要先说明一点下面涉及的具体批次大小、连接参数等数值都是基于常见实践给出的参考值不是放之四海皆准的“标准答案”。真实项目里你得结合自己的数据库类型、网络状况、单条数据大小去实测调整。这一点在后面会反复强调。2. 逐条插入到底慢在哪先把性能账算明白2.1 一次 insert 背后发生了什么很多人觉得“循环里执行executeUpdate”就是插入数据没什么可深究的。但从数据库的角度看每执行一次insert大致要经历这么几步应用端把 SQL 语句通过网络发给数据库数据库解析 SQL、生成执行计划执行插入、写日志把结果返回给应用端。这一整套流程哪怕 SQL 再简单也有固定的开销。假设你要插入 10000 条数据用逐条插入的方式那就是 10000 次网络往返、10000 次 SQL 解析、10000 次日志写入动作。网络往返的延迟哪怕在内网只有 0.5 毫秒10000 次加起来也是 5 秒。这还没算数据库端的解析和执行开销。数据量再大一点时间就成倍往上翻。2.2 批量插入省掉的到底是什么批量插入的思路是把多条insert合并成一次网络传输、一次提交。JDBC 提供了addBatch()和executeBatch()这套 API你可以把多条 SQL 先攒在本地攒到一定数量再一次性发给数据库。这样一来网络往返次数从“数据条数”降到了“批次数”。举个例子10000 条数据如果每 1000 条一批那网络往返就从 10000 次降到 10 次。这个差距是数量级的。数据库端的 SQL 解析次数也会相应减少因为很多数据库对批量语句有优化处理。日志写入虽然还是每条都要记但整体事务提交次数大幅下降开销自然就小了。2.3 为什么还要“切割 List”既然批量插入这么好为什么不把所有数据一次性addBatch然后executeBatch原因有几个。第一内存。你把 10 万条数据的 SQL 参数全攒在Batch里应用端内存会明显上涨极端情况下可能 OOM。第二数据库对单次批量语句的大小是有限制的比如某些数据库对 SQL 包大小、参数个数有上限超了直接报错。第三一旦这一大批里有一条数据有问题整个批次可能全部失败回滚代价很大。所以“切割 List”不是多此一举而是把大批量拆成一个个可控的小批次在性能和稳定性之间找平衡。批次太小省不下多少网络往返批次太大内存和失败风险又上来了。这个平衡点就是后面要重点讨论的批次大小。2.4 一个直观的对比为了让你对差距有个概念我列一个基于常见内网环境的粗略对比。注意这只是量级参考不是精确 benchmark插入方式10000 条耗时量级网络往返次数主要瓶颈逐条 executeUpdate数秒到十几秒约 10000 次网络往返、SQL 解析批量插入1000/批数百毫秒到 1 秒约 10 次数据库写入、日志批量插入5000/批数百毫秒约 2 次内存、单批失败风险从表里能看出来从逐条到批量是质变从 1000 一批到 5000 一批提升就没那么明显了反而风险在增加。这就是为什么批次大小要“适度”而不是“越大越好”。3. 原生 JDBC 批量插入的骨架代码与关键 API3.1 最核心的三个方法原生 JDBC 做批量插入核心就三个方法addBatch()、executeBatch()、clearBatch()。它们都定义在Statement和PreparedStatement上。用PreparedStatement是更好的选择因为它能预编译 SQL还能避免拼接字符串带来的注入风险。基本流程是这样的先准备好一条带占位符的insert语句比如insert into t_user (name, age) values (?, ?)然后循环给占位符赋值每赋一次值就调用一次addBatch()攒够一批后调用executeBatch()提交提交完调用clearBatch()清空本地缓存继续下一批。3.2 一个最小可运行的示例下面这段代码是把一个List切割后批量插入的骨架。为了聚焦批量逻辑我把连接获取、异常关闭这些做了简化实际项目里你需要用连接池并确保资源释放。public void batchInsert(ListUser userList, int batchSize) throws SQLException { String sql insert into t_user (name, age) values (?, ?); try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); int count 0; for (User user : userList) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.addBatch(); count; if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } // 处理最后不足一批的剩余数据 if (count % batchSize ! 0) { ps.executeBatch(); ps.clearBatch(); } conn.commit(); } }这段代码里有两个细节值得说。第一setAutoCommit(false)是必须的批量插入如果不关自动提交每条语句都可能触发一次提交批量就失去意义了。第二循环结束后要单独处理“最后一批不足 batchSize”的剩余数据这是新手最容易漏的地方漏了就会丢数据。3.3 为什么用 PreparedStatement 而不是 Statement用Statement也能做批量但你需要自己拼接 SQL 字符串比如insert into t_user values (张三, 20)。这样做有两个问题一是字符串拼接容易出错字段里有单引号就崩了二是无法利用预编译数据库每次都要重新解析 SQL。PreparedStatement的占位符机制既安全又高效是批量插入的标配。3.4 切割逻辑的两种写法切割List有两种常见写法。一种是上面那种“边遍历边计数”攒够一批就提交逻辑简单内存占用也低因为不需要额外生成子列表。另一种是先按批次把List切成若干子List再对每个子列表做批量插入。后者的好处是切割逻辑独立便于复用和测试但会多占一点内存。如果你追求极致的内存效率用第一种如果你希望代码结构更清晰、切割逻辑能单独抽成工具方法用第二种。两种写法在性能上没有本质差别选你团队更习惯的那种就行。4. 批次大小怎么定一个需要实测的平衡点4.1 批次太小和太大的代价批次大小是这套方案里最需要拿捏的参数。批次太小比如 10 条一批那 10000 条数据还是要 1000 次网络往返批量带来的收益被大幅稀释。批次太大比如 50000 条一批应用端要一次性攒下 50000 组参数内存压力大而且一旦这批里有一条数据违反约束整个批次都会失败排查起来很痛苦。所以批次大小的选择本质是在“减少网络往返”和“控制内存与失败风险”之间找平衡。这个平衡点不是固定的它跟你的单条数据大小、数据库类型、网络延迟都有关系。4.2 常见参考值与调整方向基于常见实践下面这些参考值可以作为一个起点单条数据大小建议起始批次调整方向很小几个字段1000 到 2000可适当调大中等十几字段500 到 1000实测微调较大含大文本100 到 500不宜过大这些数字不是让你照抄而是给你一个起点。正确的做法是先用一个保守值跑通然后逐步调大观察耗时和内存变化找到耗时不再明显下降、但内存还在涨的那个临界点就停在前一档。4.3 怎么用实测确定批次实测方法很简单。准备一批测试数据比如 5 万条然后分别用 100、500、1000、2000、5000 的批次大小各跑一遍记录总耗时和峰值内存。你会看到一条曲线批次从 100 涨到 1000 时耗时下降很明显从 1000 涨到 5000 时耗时下降变缓但内存上升明显。那个“拐点”附近就是适合你的批次大小。注意测试时要用和生产环境同类型的数据库、相近的网络环境否则测出来的数字参考价值有限。本地直连数据库测出来的结果往往比跨网络环境乐观很多。4.4 批次大小和数据库的关系不同数据库对批量插入的支持程度不一样。有的数据库对批量语句有专门优化批次可以开得大一些有的数据库对单次 SQL 包大小限制较严批次就得小一点。另外数据库的max_allowed_packet之类的参数也会影响你能开多大的批次。这些都需要你在实测时留意遇到报错先查数据库端的限制参数。5. 把切割和插入封装成可复用的工具5.1 为什么要封装如果每个需要批量插入的地方都手写一遍循环、计数、提交代码会重复得很难看而且容易漏掉“最后一批”这种细节。把切割和批量插入封装成一个通用工具方法既能保证逻辑一致又方便统一调整批次大小和异常处理策略。5.2 一个通用的切割工具方法先看切割。下面这个方法把一个List按指定大小切成若干子Listpublic static T ListListT splitList(ListT source, int batchSize) { ListListT result new ArrayList(); if (source null || source.isEmpty()) { return result; } int total source.size(); for (int i 0; i total; i batchSize) { int end Math.min(i batchSize, total); result.add(new ArrayList(source.subList(i, end))); } return result; }这里用subList配合new ArrayList生成独立子列表避免子列表和原列表共享底层数组带来的隐患。如果你只是临时用一下、不长期持有子列表直接用subList的视图也可以但要注意原列表被修改后视图会失效。5.3 把插入逻辑参数化封装插入时可以把 SQL、批次大小、参数设置逻辑作为参数传进来。Java 里可以用函数式接口来处理“怎么给 PreparedStatement 赋值”这一步这样工具方法就能适配不同的表和字段。public T void batchInsert(ListT data, String sql, int batchSize, BiConsumerPreparedStatement, T setter) throws SQLException { try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); int count 0; for (T item : data) { setter.accept(ps, item); ps.addBatch(); if (count % batchSize 0) { ps.executeBatch(); ps.clearBatch(); } } if (count % batchSize ! 0) { ps.executeBatch(); ps.clearBatch(); } conn.commit(); } }调用的时候把“怎么赋值”用 lambda 传进去就行。这样一套工具能覆盖大部分批量插入场景改起来也集中。5.4 封装时容易忽略的点封装成工具后有几个点要特别注意。第一连接不能写死在工具里应该由调用方传入或用连接池获取否则事务边界不好控制。第二异常处理要明确是整批回滚还是跳过错误行这个策略要在工具层面定清楚。第三clearBatch()别忘了虽然有些驱动在executeBatch()后会自动清但显式调用更稳妥。6. 异常、事务与那些容易踩的坑6.1 一批失败是全部回滚还是继续批量插入最让人纠结的就是异常处理。假设一批 1000 条第 500 条违反了唯一约束这时候怎么办默认情况下executeBatch()抛异常这一批里已经执行的部分可能已经生效也可能没生效取决于数据库和驱动。如果你开了事务且没提交可以整体回滚保证一致性但代价是前面 499 条白插了。另一种策略是“逐条降级”批量失败后把这一批拆成单条逐条插入跳过失败的那条记录日志。这样能最大化保留成功数据但实现复杂一些。选哪种取决于你的业务对一致性和成功率的取舍。数据同步类任务通常倾向后者而强一致要求的场景倾向整体回滚。6.2 事务边界要卡在哪事务边界建议卡在“一个批次”或者“整个任务”上。如果卡在每条那批量就没意义了。如果卡在整个任务一旦中途失败回滚的数据量可能很大。比较稳妥的做法是每个批次一个事务批次内要么全成功要么全回滚批次之间互不影响。这样失败影响面可控也便于断点续传。6.3 几个高频踩坑点第一个坑是忘了关自动提交导致批量退化成逐条。第二个坑是漏了最后一批不足 batchSize 的数据。第三个坑是addBatch之后没有clearBatch导致内存持续增长。第四个坑是批次开得太大触发数据库的包大小限制报错信息往往还不太直观。还有一个容易被忽略的点PreparedStatement的setXxx方法在addBatch之后、下一次赋值之前参数是会被覆盖的所以不用担心参数串味。但如果你在addBatch之后又改了参数却没重新addBatch那改的就不算数。这个细节在调试时容易让人困惑。6.4 怎么验证批量真的生效了验证批量是否生效最直接的办法是看耗时和网络往返。你也可以在数据库端开启慢查询或通用日志观察实际收到的 SQL 条数。如果日志里看到的是一次收到多条insert说明批量生效了如果还是一条一条来那多半是自动提交没关或者驱动不支持真正的批量。7. 性能验证用数据说话而不是凭感觉7.1 一个可复现的测试思路要验证批量插入的效果可以设计一个简单测试生成 5 万条测试数据分别用逐条插入和不同批次大小的批量插入跑一遍记录耗时。测试时要注意每次跑之前清空表避免数据累积影响结果数据库连接要用同一种方式获取避免连接池预热差异干扰。7.2 结果怎么看跑完之后你会得到一组耗时数据。重点看两个趋势一是逐条和批量的差距这个差距通常是数量级的二是不同批次大小之间的差距这个差距会随着批次增大而收窄。如果批次从 1000 加到 5000耗时只降了 10%但内存涨了一倍那就没必要用 5000。7.3 别忘了看数据库端应用端耗时只是故事的一半。批量插入时数据库端的 CPU、IO、日志写入压力也会变化。如果应用端很快但数据库端压力很大说明瓶颈转移到了数据库这时候继续加大批次可能适得其反。有条件的话测试时同步观察数据库的监控指标。7.4 实测中的经验值根据我在几类常见场景下的实测内网环境下单条数据十几个字段时批次在 500 到 1000 之间通常是比较舒服的区间。数据量特别大时我会把批次控制在 1000配合每批一个事务既能保证速度失败影响面也可控。当然这只是一个起点你的环境可能完全不同务必自己测一遍。8. 从能跑到好用几个进阶优化方向8.1 用连接池管理连接上面的示例里连接是每次获取的。真实项目里应该用连接池避免频繁创建销毁连接的开销。连接池还能帮你控制并发连接数防止批量任务把连接占满影响其他业务。用连接池时注意批量任务结束后及时归还连接别让连接泄漏。8.2 考虑数据库特有的批量优化不同数据库对批量插入有各自的优化手段。有的支持rewriteBatchedStatements这类连接参数开启后能把多条insert真正合并成一条多值语句性能提升明显。这类参数通常加在 JDBC URL 上具体名称和效果因数据库而异用之前查一下对应数据库的文档。8.3 大任务拆分与断点续传如果数据量特别大比如上百万条单次任务跑完可能要好几分钟。这时候可以考虑把任务拆成多个子任务每个子任务处理一部分数据并记录处理进度。一旦中途失败可以从上次的进度继续而不是从头再来。这个思路在数据迁移和跑批场景里特别实用。8.4 监控与日志批量任务最好加上日志记录每批处理了多少条、耗时多少、有没有失败。这些日志在排查问题时非常有用。如果任务比较关键还可以加上简单的监控指标比如处理速率、失败条数方便及时发现异常。9. 我在实际项目里踩过的几个真实教训说几个我自己踩过的坑都是文档里不太会写、但实际很要命的。第一个是关于批次大小的。我曾经想当然地把批次设成 10000本地测试跑得飞快结果上线后遇到大文本字段直接触发数据库包大小限制任务失败。后来把批次降到 500问题消失。教训是批次大小一定要结合真实数据特征测不能只看条数。第二个是关于事务的。有次为了图省事把整个任务放在一个事务里结果跑到 80% 时失败前面所有数据全部回滚白跑一场。后来改成每批一个事务失败只影响当前批重跑成本低很多。第三个是关于clearBatch的。早期代码里我漏了这一步小数据量时没感觉数据量一大就发现内存一直涨最后 OOM。加上clearBatch后内存曲线就平稳了。第四个是关于最后一批的。这个坑我见过不止一个同事踩循环里只处理了整批忘了处理余数导致每次插入都少几条数据还特别难查。后来我把“处理余数”写进了工具方法从根上避免。这些教训归结起来就一句话批量插入的代码不长但细节很多每一个细节都可能在数据量放大后变成大问题。写的时候多留个心眼比事后排查省事得多。10. 收尾一点个人体会原生 JDBC 的批量插入说到底就是把“攒一批、发一批”这个朴素思路用代码实现出来。它不复杂但很考验对细节的把控。批次大小、事务边界、异常策略、资源释放每一项都需要结合自己的场景去权衡。我个人在实际操作中的体会是不要一上来就追求“最优参数”先用一个保守的批次把功能跑通再拿真实数据去压测让数据告诉你该调到多少。很多时候1000 一批和 2000 一批的差距远没有“有没有关自动提交”这个细节带来的差距大。把基础做扎实比抠参数更有价值。如果你正在做数据导入或跑批功能不妨把上面这套骨架拿去改一改先跑起来再慢慢优化。遇到问题多半能在“批次大小、事务、资源释放”这三块里找到原因。