☰
原生JDBC批量插入实战:List切割与性能优化
2026/10/14 23:43:59 网站建设 项目流程

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,配合rewriteBatchedStatements=true这类连接参数,让驱动把多条插入合并成真正的批量网络包。这个差异在实测中非常明显,同一批一万条数据,逐条插入可能要十几秒,开启真正的批量后往往能压到一秒以内。差距不是一点半点,是数量级的。

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里加上rewriteBatchedStatements=true,驱动才会把多条insert重写成一条多值insert,这才是性能起飞的关键。不加这个参数,addBatch和executeBatch可能只是减少了网络往返次数,但每条SQL还是单独解析执行,提升有限。

除了这个,还有几个参数值得关注。useServerPrepStmts控制是否使用服务端预处理,批量场景下通常设为false配合rewrite效果更好。cachePrepStmts可以缓存预处理语句,减少重复解析。这些参数不是越多越好,要根据实际压测结果来调,盲目堆参数可能适得其反。

3.3 切割大小的确定方法

切割大小是这篇文章里最需要动脑的地方。太小,比如每批100条,网络往返和事务开销占比高,性能上不去;太大,比如每批5万条,内存和包大小又扛不住。经验值一般在500到5000之间,但具体要算。

计算依据有两个。一个是单条记录的大小,字段越多、文本字段越长,单条占用的字节就越大。另一个是数据库的max_allowed_packet(MySQL)或者对应的包大小限制。假设单条记录序列化后约1KB,数据库包上限是4MB,那么理论上单批不超过4000条比较安全,留点余量取2000到3000。如果记录里有大文本字段,单条可能几十KB,那批次就得降到几百。

实操心得:不要拍脑袋定批次大小,写个简单的压测,用不同批次跑同一批数据,记录耗时和内存,选那个耗时曲线开始变平缓的拐点值。这个值在你的环境和数据特征下才是最优的。

4. 实操过程与核心环节实现

4.1 环境与依赖准备

这次用最朴素的方式,不引入任何ORM,只依赖JDK自带的java.sql包和对应数据库的JDBC驱动。以Maven项目为例,引入MySQL驱动即可,版本选稳定的8.x系列。项目结构上,建一个工具类专门负责批量插入,把切割、批处理、异常处理都封装进去,业务代码只负责传List和SQL。

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

数据库连接建议用连接池,比如HikariCP,避免每次批量都新建连接。连接池的配置里,把最大连接数、连接超时设置合理,批量任务通常占用连接时间较长,超时太短会被强制断开。

4.2 List切割工具方法

先写一个通用的切割方法,把任意List按指定大小切成若干子List。这里用List.subList要注意,它返回的是原List的视图,不是新集合。如果后续对子List做修改会影响原List,而且原List被回收后视图可能失效。稳妥的做法是新建一个ArrayList把子列表元素拷进去。

public static <T> List<List<T>> splitList(List<T> source, int batchSize) { List<List<T>> 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, List<T> data, int batchSize, Binder<T> binder, boolean continueOnError) throws SQLException { List<List<T>> batches = splitList(data, batchSize); int totalAffected = 0; boolean originalAutoCommit = conn.getAutoCommit(); conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(sql)) { for (List<T> 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 Binder<T> { 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参数加rewriteBatchedStatements=true
报包过大批次太大查看max_allowed_packet调小批次或调大包限制
内存溢出批次过大或未clearBatch监控堆内存调小批次,确保每批clearBatch
部分数据丢失异常被吞检查异常处理逻辑记录失败批次,不要静默忽略
主键冲突回滚数据重复检查数据源去重或用insert ignore
连接超时单批执行太久查看连接池超时配置调小批次,调大超时

5.5 独家避坑技巧

第一个技巧,executeBatch返回的数组里,如果某个元素是Statement.SUCCESS_NO_INFO(值为-2),说明驱动无法确定影响行数,这在某些数据库的批量模式下是正常的,不要当成失败处理。第二个技巧,批量插入前临时禁用表上的非唯一索引,插完再重建,能大幅提升速度,但要注意这期间表不可用,适合离线导入场景。第三个技巧,如果数据来源本身有序,按主键顺序插入能减少B+树页分裂,间接提升性能。

6. 性能优化的几个进阶方向

6.1 关闭自动提交与手动事务

前面代码里已经体现了,批量插入一定要关掉自动提交。自动提交模式下,每条语句都是一个独立事务,数据库要频繁刷盘,性能极差。手动控制事务,把一批操作合并成一次提交,减少磁盘IO次数,这是批量插入性能提升的基础。

6.2 利用数据库特有的批量语法

不同数据库有自己的批量插入语法。MySQL支持多值insert,PostgreSQL支持COPY命令,Oracle有INSERT ALL。原生JDBC的addBatch是通用方案,但在特定数据库上,直接用它的原生批量语法可能更快。比如PostgreSQL的COPY,通过驱动提供的CopyManager,速度比普通insert快好几倍。这属于进阶优化,在通用方案满足不了性能要求时可以考虑。

6.3 并行分批的可行性

如果数据库和连接池允许,可以把切割后的批次分给多个线程并行执行。但要注意,并行写入同一张表可能加剧锁竞争,反而变慢。而且事务边界会变得复杂。我的建议是,先做单线程优化,把批次大小和参数调到最优,如果还不够,再考虑并行,并且要严格压测验证。

7. 我在实际项目里踩过的坑

说几个真实踩过的坑。有一次做数据迁移,图省事把批次设成了50000,本地测试没问题,上了生产直接OOM,因为生产数据单条比测试大得多。后来改成动态计算批次,根据单条记录的平均大小反推批次,才稳定下来。还有一次,批量插入时忘了clearBatch,结果批次越滚越大,跑到后面越来越慢,查了半天才发现是这个小疏漏。

另外一个教训是关于异常处理的。早期代码里catch了异常只打印日志,没有回滚也没有记录失败数据,导致一部分数据没插进去却没人知道。后来改成失败批次单独落库,记录失败原因和原始数据,方便重试。这个习惯在数据导入场景里特别重要,因为数据来源往往不可控,脏数据是常态。

最后分享一个小技巧:如果批量插入的目标表有自增主键,插入时不要指定主键字段,让数据库自己生成,这样能减少参数绑定和网络传输。如果业务需要拿到生成的主键,可以用getGeneratedKeys,但批量模式下这个方法的支持程度因驱动而异,用之前要确认驱动版本是否支持。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询