首页 / 资讯中心 / 文章详情

原生JDBC批量插入实战:List切割与性能优化

原生JDBC批量插入实战:List切割与性能优化 ★ FEATURED ARTICLE
1. 批量插入这件事为什么值得单独拎出来聊做后端开发的人早晚都会撞上“把一批数据写进数据库”这个场景。少则几百条多则几十万条来源可能是Excel解析结果、第三方接口返回的集合、定时任务捞出来的待处理记录。很多人的第一反应是打开ORM框架用saveBatch或者foreach拼SQL跑一遍发现慢得离谱再回头查日志发现是一条一条发出去的。这时候才意识到批量插入这件事框架替你做的封装未必是好事。我这次要聊的是不依赖任何ORM、纯用原生JDBC把一个List集合切割成若干批次再批量写入数据库的完整做法。核心关键词就三个原生JDBC、List切割、批量插入。它解决的是大批量数据写入时性能差、内存占用高、单条SQL过长导致报错的问题。适合有一定Java基础、正在做数据导入导出、定时批处理、数据迁移这类工作的开发者参考。哪怕你平时用MyBatis或者JPA理解底层这套机制也能帮你在框架失灵的时候快速定位瓶颈。先说清楚一个前提批量插入不是银弹。数据量小的时候单条插入反而更简单直接数据量大到一定程度还要考虑事务、索引、主键策略、数据库参数。所以这篇文章不会只给你一段代码就完事而是把“为什么这么切”“切多大合适”“怎么保证不出错”这些真正影响结果的问题讲透。下面从整体设计思路开始拆。2. 整体设计思路与方案选型拆解2.1 为什么不用ORM的批量方法很多人会问MyBatis的foreach、Hibernate的saveBatch不也能批量吗为什么还要自己写原生JDBC。这里要分两种情况看。第一种框架的批量本质上是把多条insert拼成一条大SQL比如insert into t values (...),(...),(...)。这种写法在数据量可控时没问题但一旦List有十万条拼出来的SQL字符串可能几兆甚至几十兆数据库解析这条SQL的开销极大还容易触发max_allowed_packet限制直接报错。第二种框架的批量走的是JDBC的addBatch但很多封装默认没有开启rewrite实际执行时依然是一条条发。原生JDBC的好处在于你能完全控制PreparedStatement的addBatch和executeBatch配合rewriteBatchedStatementstrue这类连接参数让驱动把多条插入合并成真正的批量网络包。这个差异在实测中非常明显同一批一万条数据逐条插入可能要十几秒开启真正的批量后往往能压到一秒以内。差距不是一点半点是数量级的。2.2 List切割的核心逻辑为什么不把整个List一次性丢进去非要切割原因有三个。第一是内存一次性构建一个包含十万条参数的PreparedStatement批次驱动内部会缓存大量数据堆内存压力大稍不注意就OOM。第二是事务粒度一次性提交十万条事务日志膨胀回滚代价极高中途失败全部白干。第三是数据库限制不同数据库对单次批量语句的参数个数、包大小都有上限超了直接抛异常。切割的思路很朴素把大List按固定大小分成若干子List每个子List走一次完整的“addBatch executeBatch clearBatch”流程。这样每次占用的内存可控单次事务范围可控失败时也只需要重试当前批次。切割大小没有绝对标准后面会专门讲怎么定这个值。2.3 事务与提交策略的取舍批量插入绕不开事务。有两种做法一种是把所有批次包在一个大事务里全部成功才提交另一种是每个批次独立提交。前者保证原子性但事务长时间不提交会占用锁和日志空间数据量大时风险高。后者牺牲了整体原子性但单批失败不影响已成功的批次适合可以容忍部分成功、或者有补偿机制的场景。我的建议是如果业务要求强一致用大事务但批次要切小并且设置合理的超时如果只是数据导入允许失败重来那就分批提交配合一张记录进度的表失败后从断点继续。这个选择直接决定了后面代码的结构所以要在动手前想清楚。3. 核心细节解析与实操要点3.1 PreparedStatement的批处理机制原生JDBC批量插入的主角是PreparedStatement。它的批处理流程是这样的先setXxx设置好一条记录的参数调用addBatch()把这条参数快照加入批次重复这个过程直到一批加完然后调用executeBatch()一次性提交给数据库最后clearBatch()清空批次缓存准备下一批。这里有个关键点容易被忽略addBatch()之后如果不clearBatch()下一批的参数会继续往同一个批次里加导致批次越来越大切割就失去意义了。所以每执行完一批必须清理。另外executeBatch()返回的是一个int数组表示每条语句影响的行数可以用来校验是否全部成功。注意不同数据库驱动对executeBatch的实现差异很大。有的驱动是真的批量发送有的只是循环发送。是否真正批量取决于连接参数和驱动版本不能想当然。3.2 连接参数里的隐藏开关以常见的MySQL为例连接URL里加上rewriteBatchedStatementstrue驱动才会把多条insert重写成一条多值insert这才是性能起飞的关键。不加这个参数addBatch和executeBatch可能只是减少了网络往返次数但每条SQL还是单独解析执行提升有限。除了这个还有几个参数值得关注。useServerPrepStmts控制是否使用服务端预处理批量场景下通常设为false配合rewrite效果更好。cachePrepStmts可以缓存预处理语句减少重复解析。这些参数不是越多越好要根据实际压测结果来调盲目堆参数可能适得其反。3.3 切割大小的确定方法切割大小是这篇文章里最需要动脑的地方。太小比如每批100条网络往返和事务开销占比高性能上不去太大比如每批5万条内存和包大小又扛不住。经验值一般在500到5000之间但具体要算。计算依据有两个。一个是单条记录的大小字段越多、文本字段越长单条占用的字节就越大。另一个是数据库的max_allowed_packetMySQL或者对应的包大小限制。假设单条记录序列化后约1KB数据库包上限是4MB那么理论上单批不超过4000条比较安全留点余量取2000到3000。如果记录里有大文本字段单条可能几十KB那批次就得降到几百。实操心得不要拍脑袋定批次大小写个简单的压测用不同批次跑同一批数据记录耗时和内存选那个耗时曲线开始变平缓的拐点值。这个值在你的环境和数据特征下才是最优的。4. 实操过程与核心环节实现4.1 环境与依赖准备这次用最朴素的方式不引入任何ORM只依赖JDK自带的java.sql包和对应数据库的JDBC驱动。以Maven项目为例引入MySQL驱动即可版本选稳定的8.x系列。项目结构上建一个工具类专门负责批量插入把切割、批处理、异常处理都封装进去业务代码只负责传List和SQL。dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency数据库连接建议用连接池比如HikariCP避免每次批量都新建连接。连接池的配置里把最大连接数、连接超时设置合理批量任务通常占用连接时间较长超时太短会被强制断开。4.2 List切割工具方法先写一个通用的切割方法把任意List按指定大小切成若干子List。这里用List.subList要注意它返回的是原List的视图不是新集合。如果后续对子List做修改会影响原List而且原List被回收后视图可能失效。稳妥的做法是新建一个ArrayList把子列表元素拷进去。public static T ListListT splitList(ListT source, int batchSize) { ListListT result new ArrayList(); if (source null || source.isEmpty() || batchSize 0) { 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; }这个方法看着简单但有两个细节。一是batchSize要做合法性校验小于等于0直接返回空避免死循环。二是用Math.min处理最后一批不足整批的情况防止下标越界。返回的每个子List都是独立副本后续操作互不影响。4.3 批量插入的核心实现接下来是重头戏。核心方法接收连接、SQL、数据List和批次大小内部完成切割和批量执行。为了演示清楚这里用显式的事务控制每个批次独立提交失败时记录日志并继续或中断具体策略由参数决定。public static T int batchInsert(Connection conn, String sql, ListT data, int batchSize, BinderT binder, boolean continueOnError) throws SQLException { ListListT batches splitList(data, batchSize); int totalAffected 0; boolean originalAutoCommit conn.getAutoCommit(); conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement(sql)) { for (ListT batch : batches) { try { for (T item : batch) { binder.bind(ps, item); ps.addBatch(); } int[] affected ps.executeBatch(); ps.clearBatch(); conn.commit(); for (int count : affected) { totalAffected Math.max(count, 0); } } catch (SQLException e) { conn.rollback(); if (!continueOnError) { throw e; } // 记录当前批次失败继续下一批 System.err.println(批次执行失败已回滚: e.getMessage()); } } } finally { conn.setAutoCommit(originalAutoCommit); } return totalAffected; }这里用了一个Binder函数式接口把“怎么把一条数据绑定到PreparedStatement”这件事抽出来让批量方法保持通用。业务方只需要实现bind方法告诉工具类每个字段怎么set。FunctionalInterface public interface BinderT { void bind(PreparedStatement ps, T item) throws SQLException; }调用的时候大概长这样String sql insert into user_info (id, name, age, create_time) values (?, ?, ?, ?); int affected batchInsert(conn, sql, userList, 1000, (ps, user) - { ps.setLong(1, user.getId()); ps.setString(2, user.getName()); ps.setInt(3, user.getAge()); ps.setTimestamp(4, new Timestamp(user.getCreateTime().getTime())); }, false);4.4 参数计算与批次大小实测前面说批次大小要算这里给一个具体的计算过程。假设表有10个字段其中2个是varchar(255)其余是数值和时间类型。单条记录在驱动内部序列化后粗略估算数值类型每个约8字节时间类型约8字节varchar按平均50字节算加上长度标记和协议开销单条约200字节。数据库max_allowed_packet默认4MB那么单批理论上限约20000条。但考虑到内存和事务取2000条一批比较稳妥。实测数据模拟环境仅供参考一万条记录批次100时耗时约1.2秒批次500约0.6秒批次1000约0.45秒批次2000约0.42秒批次5000约0.44秒。可以看到1000到2000之间是拐点再往上提升不明显反而内存占用上升。所以最终定1000到2000这个区间。注意这个实测值受数据库版本、网络、磁盘、索引数量影响很大。有索引的表插入会慢很多因为每批提交都要更新索引。如果表上有多个二级索引批次可以适当调小减少单次索引维护的压力。5. 常见问题与排查技巧实录5.1 批量插入报包过大怎么办最常见的报错是Packet for query is too large。这说明单批数据超过了数据库的包大小限制。解决办法有两个一是调大数据库的max_allowed_packet参数但这是全局参数改大了可能影响其他场景要谨慎二是直接调小批次大小这是更推荐的做法从源头控制单批数据量。如果单条记录本身就很大比如含长文本那批次可能要降到几十条。5.2 内存溢出与批次的关系有人觉得批次越大越快结果设了个十万跑起来直接OOM。原因在于addBatch会把每条记录的参数缓存在驱动内部批次越大缓存越多。而且executeBatch返回的int数组也占内存。所以批次大小和内存是直接相关的。排查时可以用jmap或者VisualVM看堆内存曲线如果发现执行批量时堆持续上涨不回落基本就是批次太大或者没调clearBatch。5.3 事务回滚导致的性能骤降如果用了大事务中途某条数据违反约束比如主键冲突整个事务回滚之前所有批次的努力全部作废。更糟的是回滚本身很耗时尤其是数据量大时。排查这类问题先看日志里有没有约束冲突再检查数据是否有重复。解决思路是提前做数据校验或者用分批提交把失败范围缩小到单批。5.4 常见问题速查表问题现象可能原因排查方向解决建议插入速度慢未开启rewrite检查连接URL参数加rewriteBatchedStatementstrue报包过大批次太大查看max_allowed_packet调小批次或调大包限制内存溢出批次过大或未clearBatch监控堆内存调小批次确保每批clearBatch部分数据丢失异常被吞检查异常处理逻辑记录失败批次不要静默忽略主键冲突回滚数据重复检查数据源去重或用insert ignore连接超时单批执行太久查看连接池超时配置调小批次调大超时5.5 独家避坑技巧第一个技巧executeBatch返回的数组里如果某个元素是Statement.SUCCESS_NO_INFO值为-2说明驱动无法确定影响行数这在某些数据库的批量模式下是正常的不要当成失败处理。第二个技巧批量插入前临时禁用表上的非唯一索引插完再重建能大幅提升速度但要注意这期间表不可用适合离线导入场景。第三个技巧如果数据来源本身有序按主键顺序插入能减少B树页分裂间接提升性能。6. 性能优化的几个进阶方向6.1 关闭自动提交与手动事务前面代码里已经体现了批量插入一定要关掉自动提交。自动提交模式下每条语句都是一个独立事务数据库要频繁刷盘性能极差。手动控制事务把一批操作合并成一次提交减少磁盘IO次数这是批量插入性能提升的基础。6.2 利用数据库特有的批量语法不同数据库有自己的批量插入语法。MySQL支持多值insertPostgreSQL支持COPY命令Oracle有INSERT ALL。原生JDBC的addBatch是通用方案但在特定数据库上直接用它的原生批量语法可能更快。比如PostgreSQL的COPY通过驱动提供的CopyManager速度比普通insert快好几倍。这属于进阶优化在通用方案满足不了性能要求时可以考虑。6.3 并行分批的可行性如果数据库和连接池允许可以把切割后的批次分给多个线程并行执行。但要注意并行写入同一张表可能加剧锁竞争反而变慢。而且事务边界会变得复杂。我的建议是先做单线程优化把批次大小和参数调到最优如果还不够再考虑并行并且要严格压测验证。7. 我在实际项目里踩过的坑说几个真实踩过的坑。有一次做数据迁移图省事把批次设成了50000本地测试没问题上了生产直接OOM因为生产数据单条比测试大得多。后来改成动态计算批次根据单条记录的平均大小反推批次才稳定下来。还有一次批量插入时忘了clearBatch结果批次越滚越大跑到后面越来越慢查了半天才发现是这个小疏漏。另外一个教训是关于异常处理的。早期代码里catch了异常只打印日志没有回滚也没有记录失败数据导致一部分数据没插进去却没人知道。后来改成失败批次单独落库记录失败原因和原始数据方便重试。这个习惯在数据导入场景里特别重要因为数据来源往往不可控脏数据是常态。最后分享一个小技巧如果批量插入的目标表有自增主键插入时不要指定主键字段让数据库自己生成这样能减少参数绑定和网络传输。如果业务需要拿到生成的主键可以用getGeneratedKeys但批量模式下这个方法的支持程度因驱动而异用之前要确认驱动版本是否支持。
阅读完成 · 觉得有帮助?
咨询建站