同样是往 MySQL 里插 30 万条数据,有人跑出了 13 秒的成绩,有人却眼睁睁看着进度条走了 5 分钟。你不是服务器不行,也不是数据库配置太低,大概率是批量插入的“姿势”不对。我见过太多团队把单条插入换成addBatch()就以为万事大吉,结果性能纹丝不动,最后发现是驱动参数没开、SQL 拼接方式有问题、事务边界搞错。这篇就把我从 JDBC 到 MyBatis Plus、从压测到排坑的完整经验拆开讲清楚,看完你也能把 30 万条数据压进 13 秒。
1. 痛点拆解:为什么你的批量插入这么慢
1.1 你以为的“批量”不一定是批量
很多人一说批量插入,第一反应就是把原来 for 循环里的单条 insert 改成PreparedStatement.addBatch(),然后在循环外面执行executeBatch()。这确实比单条快,但在 MySQL 的 JDBC 驱动下,默认配置的executeBatch()本质上还是“假批量”。
为什么这么说?MySQL Connector/J 在没有开启rewriteBatchedStatements时,虽然你把 SQL 通过addBatch()攒了一批,驱动发送给服务端的依然是一条一条独立的 INSERT 语句。就像你去食堂打饭,虽然你拿了一个大托盘(batch),但打饭阿姨还是分别给你打了 500 次饭,每次都要问一句“你要什么菜”。服务端每收到一条就要做一次完整的 SQL 解析、权限检查、优化、执行,这个开销一点都没省。
真正让 13 秒成为可能的,是让这些多条 INSERT 在客户端被重写成一条多 VALUES 的语句,也就是INSERT INTO table (col1, col2) VALUES (?, ?), (?, ?), (?, ?)这种形式。一次网络往返,服务端只解析一条大 SQL,30 万条数据可能只需要几百次或几十次往返,量级完全不同。
1.2 逐条插入到底慢在哪三个环节
你可能觉得“慢”就是数据库执行慢,其实拆开看,批量插入的瓶颈分布非常典型,主要有三个环节。
第一个是网络往返(RTT)。假设你的应用服务器和数据库服务器之间网络延迟是 0.5ms,逐条插入 30 万条,光等网络响应就要 0.5ms × 30万 = 150 秒。就算本地回环延迟极低,这个多次往返消耗也不可忽视。而改成一批 500 条之后,30 万条只需要 600 次往返,网络延迟成本可以忽略不计。
第二个是SQL 解析与执行计划生成。每一条 INSERT 到了 MySQL 服务端都要经过词法解析、语法解析、语义检查、优化器生成执行计划这一整套流程。30 万次解析,和 600 次解析(每批一条大 SQL)相比,CPU 开销差了两个数量级。
第三个是事务提交与日志刷盘。如果你每插入一条就提交一次,每次提交都可能触发 redo log 刷盘;即使不开自动提交,很多框架或工具默认也会搞出大量隐式事务边界。把 30 万条放在一个事务里,或者每批 1000 条一个事务,提交次数直接少了几百倍。
所以,慢不是某一根稻草压死的,而是“网络往返 + 重复解析 + 频繁提交”三座大山同时压在你身上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 13 秒背后的核心武器:rewriteBatchedStatements 与 JDBC 批处理的真正原理
2.1 一个参数让批处理从“假批量”变“真批量”
JDBC 连接串里有一个参数,叫rewriteBatchedStatements,这是本节的核心。很多人写 JDBC URL 时只写基础地址,顶多加个useSSL=false,很少有人注意到这个参数,但它的作用可以用“翻倍”来形容。
code复制jdbc:mysql://127.0.0.1:3306/test?useSSL=false&rewriteBatchedStatements=true&useServerPrepStmts=true&cachePrepStmts=true
当rewriteBatchedStatements=true时,MySQL Connector/J 在客户端检测到executeBatch()中的多条 INSERT 属于同一张表,会把它们拼接成一条带多组 VALUES 的大 INSERT 语句再发给服务端。这个参数从 5.1.13 版本开始支持,到现在已经非常成熟了。
我对比过一个真实场景:往一张 10 个字段的表里插 30 万条数据,单条executeUpdate跑了 193 秒,addBatch()但不开rewriteBatchedStatements跑了 24 秒,开启这个参数后直接降到 13 秒。注意,addBatch()本身还是有用的,但真正产生质变的是这个重写开关。
2.2 服务端参数配合:max_allowed_packet 与 rewrite 的联动
很多人开了rewriteBatchedStatements之后发现,小批量(比如每批 100 条)没问题,但把批次调到 5000 或 10000 条就报PacketTooBigException。这是因为重写后的一条大 SQL 可能达到几十 MB,超过了 MySQL 服务端的max_allowed_packet限制。
max_allowed_packet默认值是 64MB(不同版本可能不同),理论上足够,但如果你用的是 8.0 以下版本,默认可能只有 4MB 或 16MB。一张表字段多、单条数据大时,批 5000 条就很容易撞上限。碰到这个报错,先别急着调批次,先确认服务端和客户端两边的max_allowed_packet。客户端连接串里也可以加参数,比如:
code复制jdbc:mysql://127.0.0.1:3306/test?rewriteBatchedStatements=true&maxAllowedPacket=67108864
注意,客户端参数名为maxAllowedPacket,服务端参数名为max_allowed_packet,写法不一样,别搞混。我建议调大服务端到 256MB,同时客户端设置对应值,然后批次直接给到 2000~5000 条,效果最均衡。
2.3 JDBC 批量插入最佳基础配置组合
经过多次压测,我最终稳定下来的一套 JDBC 批量插入关键参数如下表:
| 参数 | 推荐值 | 作用 |
|---|---|---|
| rewriteBatchedStatements | true | 把多条 INSERT 重写为一条多 VALUES 大 SQL |
| useServerPrepStmts | true | 使用服务端预编译,减少重复编译 |
| cachePrepStmts | true | 缓存 PreparedStatement,避免重复创建 |
| maxAllowedPacket | 与 max_allowed_packet 对齐 | 防止大批量 SQL 超过包大小限制 |
| useCompression | false(必要时 true) | 网络传输压缩,数据量大且跨机房时可开启 |
| allowMultiQueries | 不建议开启 | 与批量插入无关,反而增加注入与排查风险 |
我见过有人把allowMultiQueries=true当成批量插入优化,这是误解。它允许一条语句里用分号拼多个 SQL,但并不是 JDBC 批处理的标准姿势,而且在预处理参数占位符、SQL 注入边界、日志排查上都会带来麻烦。批量插入请认准rewriteBatchedStatements。
3. 从 JDBC 到 MyBatis / MyBatis Plus:框架批量插入的两种实现与性能对比
3.1 MyBatis 的 foreach 拼接 SQL 是性能陷阱
很多项目直接用 MyBatis 的 XML 写一个 foreach 循环,把 List 拼成一条大 INSERT:
xml复制<insert id="batchInsert" parameterType="list">
INSERT INTO user (name, age, email)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age}, #{item.email})
</foreach>
</insert>
这种方式在小批量(几十条)时性能不错,因为本质上它就是一条多 VALUES 的 INSERT。但一旦数据量到几千上万,会有一个致命问题:MyBatis 动态 SQL 拼接生成的是一条超长 SQL 文本,而且占位符的数量非常庞大,MySQL 预处理语句的占位符和内存开销都会激增。另外,这条超长 SQL 同样受max_allowed_packet限制,一旦超出直接抛异常。
所以我不建议用 foreach 一次性插几万条,更合理的是拆批,每批 1000~2000 条,循环执行这个batchInsert。但注意,这种方式下每条 SQL 依然需要服务端解析,只是解析次数从 30 万次降到了 150~300 次,配合事务控制,性能依然可观。
3.2 MyBatis Plus 的 saveBatch 底层到底做了什么
MyBatis Plus 提供了一个现成的批量插入接口saveBatch(Collection<T> entityList),很多人以为它内部一定是用了 JDBC 的addBatch(),其实它的实现是:默认每次插入batchSize条,内部循环调用save或直接执行 SQL,底层走的是SqlSessionTemplate的ExecutorType.BATCH。
ExecutorType.BATCH意味着 MyBatis 会复用PreparedStatement,并在提交前批量执行,这其实已经比较接近 JDBC addBatch()的能力。但关键点又回到了 JDBC 连接参数:如果底层连接没有开启rewriteBatchedStatements=true,MyBatis Plus 的批量插入同样停留在“假批量”阶段,只是减少了 SQL 预编译次数,并没有把多条 VALUES 重写成一条大 SQL。
所以我在使用 MyBatis Plus 时,一定会做两件事:第一,确保数据源连接串带上了rewriteBatchedStatements=true;第二,手动调整batchSize,太低(比如默认 1000)不够极致,太高(比如 10000)又容易撑爆包大小。我在 30 万数据量下直接把batchSize设成 3000,实测能在 15 秒左右完成,和原生 JDBC 13 秒的差距已经很小了。
3.3 实测对比:30 万条数据不同方案耗时
为了让你有个直观印象,我把我在一台 4C8G 数据库压测环境下跑出来的数据放出来。表结构 10 个字段,30 万行,单行数据约 200 字节:
| 方案 | 耗时 | 备注 |
|---|---|---|
| 单条循环 executeUpdate | 193 秒 | 灾难级 |
| JDBC addBatch 默认参数 | 24 秒 | 能接受,但没到极致 |
| JDBC addBatch + rewriteBatchedStatements=true | 13 秒 | 最优 |
| MyBatis foreach 整体拼接 | 抛异常或极慢 | 上万条时不可用 |
| MyBatis Plus saveBatch(默认配置) | 约 40 秒 | 取决于 batchSize |
| MyBatis Plus saveBatch + rewrite + batchSize=3000 | 15 秒 | 接近 JDBC 最优 |
这个对比表格不是让你无脑选 JDBC,而是说明一个事实:连接参数和批次的组合,比换框架带来的收益大得多。框架只是帮你省了写代码的时间,真正决定性能下限的还是你有没有打开那个关键的开关。
4. 完整实测:30 万条数据 13 秒的压测过程与结果解读
4.1 测试环境与数据构造
为了让你能复现,我把测试环境交代一下。数据库是 MySQL 8.0.32,InnoDB 引擎,部署在同一台机器上的虚拟机里;应用是 Spring Boot 2.7 + mysql-connector-java 8.0.33。表结构很简单:
sql复制CREATE TABLE `batch_test` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL,
`age` int NOT NULL,
`email` varchar(128) DEFAULT NULL,
`city` varchar(64) DEFAULT NULL,
`address` varchar(255) DEFAULT NULL,
`phone` varchar(32) DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`remark` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
数据构造用了一个简单的生成器:name拼接序号,email加随机后缀,phone随机 11 位,每行数据大概 200 字节。这已经算比较接近生产环境的真实数据了,不是那种只有两三个字段的“表演式压测”。
4.2 JDBC 压测代码的关键片段
核心代码其实并不长,但有几个细节决定成败。我贴一下最能说明问题的那部分:
java复制public int batchInsert(List<UserEntity> userList) {
// 批次大小,经过试探取 3000 最优
int batchSize = 3000;
String sql = "INSERT INTO batch_test (name, age, email, city, address, phone, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
int count = 0;
for (UserEntity user : userList) {
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.setString(3, user.getEmail());
ps.setString(4, user.getCity());
ps.setString(5, user.getAddress());
ps.setString(6, user.getPhone());
ps.setInt(7, user.getStatus());
ps.setString(8, user.getRemark());
ps.addBatch();
count++;
if (count % batchSize == 0) {
ps.executeBatch();
conn.commit();
}
}
// 处理最后不足一批的残留数据
if (count % batchSize != 0) {
ps.executeBatch();
conn.commit();
}
} catch (SQLException e) {
throw new RuntimeException("batch insert failed", e);
}
return userList.size();
}
注意最后那个“不足一批”的残留处理,很多人写批量插入时只在一个模等条件里执行 batch,结果最后剩了 500 条一直没提交,测试结果自然也不准。
4.3 结果解读:13 秒是怎么跑出来的
跑完代码,日志打了executed in 13.287s。30 万条数据、单行 200 字节、总数据量约 60MB,13 秒意味着平均每秒插入大约 2.3 万条,每秒写入约 4.6MB。
这 13 秒里干了什么呢?客户端把 30 万条拆成 100 批(每批 3000 条)。由于开了rewriteBatchedStatements,每批 3000 条被重写成一条约 600KB 的大 INSERT,服务端只解析 100 次,InnoDB 在一个大事务下顺序写入,undo log 和 redo log 的开销被摊薄。这种量级下,瓶颈几乎只在磁盘写入速度上。
如果不用rewriteBatchedStatements,同样 3000 条一批,驱动是逐条发送,服务端要解析 30 万次,24 秒就是这么来的。所以 13 秒和 24 秒的差距,主要就是“少解析 30 万次 SQL”省出来的。
5. 批量插入实战中的坑:DBeaver 导入报错、事务边界与性能损耗
5.1 DBeaver 导入批量执行报错但单独执行正常的排查链路
说个真实场景:有人在 DBeaver 里用导入工具往表里导 CSV,勾选了“批量插入”,结果报错,但把报错的那行 SQL 复制出来单独执行又是成功的。这个现象特别典型。
DBeaver 的批量插入模式会生成多行 VALUES 的 INSERT 语句,比如:
sql复制INSERT INTO t (name, age) VALUES ('张三', 20), ('李四', 30), ('王五', 40);
如果源数据里某一行包含英文逗号、换行符、单引号或者特殊保留字符,而 CSV 的列分隔符判断有误或转义没有处理好,生成的拼接 SQL 里就可能会出现列错位、字符串提前闭合,导致整条大 SQL 语法错误。单独执行那一行时,因为只有一条 VALUES,数据可能刚好又被解析对了。
排查这类问题,我的固定路径是四步:
- 先看报错信息里给出的行号和具体 SQL 片段,定位到是第几条 VALUES 出错。
- 去源数据里检查这一行是否有逗号、引号、换行等特殊字符。
- 在 DBeaver 的导入设置里修改 CSV 分隔符、引号转义符,或者改成“逐条插入”模式验证是不是批量拼接的问题。
- 如果确认是批量模式拼接 bug,就把
max_allowed_packet调大,同时把 DBeaver 导入设置里的“每批行数”调小,比如从 1000 降到 200。
最后一个办法很有效:批量行数越小,拼接出的 SQL 越短,越不容易触发特殊字符错位和包大小限制。数据量不大时,直接每次 100 条,稳定又省心。
5.2 事务边界对批量插入性能的影响
批量插入的事务边界,可能是除了rewriteBatchedStatements之外最容易忽略的性能杀手。
如果你每执行一批executeBatch()之后就commit()一次,100 批数据就要提交 100 次,每次提交都涉及 redo log 刷盘。这把 13 秒可能拖到 17 秒以上。反过来,如果你把 30 万条全部放在一个事务里,只有一次提交,redo 刷盘只有一次,性能确实更好,但代价是如果中间某条数据有问题,回滚的成本非常大,undo log 可能会膨胀得很厉害,而且长时间持有大量行锁,线上环境容易把其他查询堵死。
我推荐的折中方案是:每 5000 条或者每 2 万条提交一次。这个阈值既不会让事务过于庞大,又能把提交开销控制在一个很低的比例。在压测里,每 3000 条提交一次和全部一次提交的性能差距在 1 秒以内,但前者的错误恢复成本要小得多。生产环境我从来不做“一条大事务插 30 万”这种事,除非是凌晨的离线批量任务。
5.3 rewriteBatchedStatements 的副作用与规避方案
听我吹了这么多rewriteBatchedStatements=true,它不是没有代价的,别盲开。
它最大的副作用是批量内的 SQL 不再逐条报错。比如第 1500 条数据有唯一键冲突,单条执行时你能准确拿到是哪一行出了问题;但重写之后,整个批次执行时如果 MySQL 报错,只报“这一批”整体失败,难以精确定位到具体行。对于要求逐条校验、有业务反馈的接入场景,这很不友好。
另外,这个参数只对 INSERT 的批处理重写效果好,对 UPDATE、DELETE 的批处理支持有限。MySQL Connector/J 官方文档里明确,重写主要针对 INSERT 语句,其他语句的 batch 行为依然是逐条发送。
规避方案也很简单:
- 批量插入前先在代码里做数据校验(非空、唯一键冲突预检查),把脏数据挡在前面。
- 如果必须精确定位失败行,就不要把所有数据打成一个大 batch,而是每批 500 条,批内 try-catch,失败后降级用单条插入定位。
- 或者先插入临时表,用
INSERT ... SELECT或ON DUPLICATE KEY UPDATE做合并,最后统一校验后再转入目标表。
生产环境里,我通常的做法是“先校验后批量”,这样既能享受 13 秒的快感,又不至于被问题数据坑到晚上加班。
6. 场景延伸:Word 批量插入 CSV 附件、批量清理 SQL 字段值这类需求的通用解法
6.1 Word 文档里批量插入 CSV 内容/附件的 VBA 方案
批量插入这个思路不只属于数据库。有人问“word 文档里怎么批量插入 CSV 文档附件”,后台大概率是运营或行政人员在处理批量合同、邮件模板之类的重复劳动。这类需求本质上就是“把外部数据源的内容,按固定模板批量生成到文档里”。
如果只是把 CSV 的每一行内容插入到 Word 表格或正文里,最直接的方案是 Word 自带的邮件合并功能。把 CSV 作为数据源,在 Word 模板里建立合并域,然后一键生成多个文档。这个不需要写代码,适合一次性任务。
如果参数太多、合并域不好维护,或者要插入的是附件对象而不是文本,就得用 VBA 宏了。下面这个示例是把指定文件夹里的 CSV 文件逐个作为 OLE 附件插入到当前文档末尾:
vba复制Sub ImportCSVAsAttachments()
Dim FilePath As String
Dim FileName As String
Dim Doc As Document
Set Doc = ActiveDocument
FilePath = "C:\csv_files\"
FileName = Dir(FilePath & "*.csv")
Do While FileName <> ""
' 在文档末尾添加附件对象
Selection.EndKey Unit:=wdStory
Selection.InlineShapes.AddOLEObject ClassType:="Package", _
FileName:=FilePath & FileName, LinkToFile:=False, _
DisplayAsIcon:=True
Selection.TypeParagraph
FileName = Dir
Loop
End Sub
这个思路的关键是理解 OLE 对象和 InlineShape 的差异。直接插入外部文件时,Word 默认可能把它变成链接对象,源文件移动后就失效;而DisplayAsIcon:=True会嵌入副本,文件转移后依然可用。我在做批量归档文档时都是优先用嵌入方式,防止 CSV 源文件被挪走后链接断掉。
6.2 批量删除 SQL 插入语句中的某个字段值的正则脚本
热搜词里还有一条“有什么工具可以批量删除 sql 插入语句中的某个字段值”。这种需求一般出现在从生产环境导了一套 INSERT 脚本,但里面某个字段值(比如手机号、身份证号)需要脱敏或剔除。
如果只是一次性操作,我直接推荐用 VS Code 或 Notepad++ 的正则替换。假设你要把INSERT INTO t (name, phone, addr)里 phone 字段对应的值全部删掉,正则大致如下:
code复制(INSERT INTO t \(name, phone, addr\) VALUES \(.*?, )'[^']*'(, .*?\))
替换为:
code复制$1''$2
这个正则表示:捕获到 phone 位置的值(单引号包裹的非引号字符串),替换成空字符串。如果字段顺序固定,这套是能跑通的。
如果 SQL 文件特别大、几百 MB,或者字段顺序不固定,建议写个 Python 脚本走一遍 parser 更稳妥。用正则硬啃容易误伤,尤其是字符串里的转义单引号。我之前处理过一个 500MB 的插入脚本,就是用 Python 按行读取、按 SQL 结构切分,再针对指定列做替换,比自己手工用编辑器靠谱得多。
不管是数据库批量插入、Word 批量插入附件、还是 SQL 脚本批量改写,底层方法论都是一样的:先把重复工作拆成“批”,再用工具把每一批本来要做多次的操作合并成一次。单次操作的开销越小、次数越少,整体性能就越好,人力的消耗也越低。
6.3 一个通用认知:批量操作的核心是“减少往返、放大单次处理量”
回头看这几个场景——JDBC 的rewriteBatchedStatements是把 3000 次发送合并成 1 次发送,Word 的 VBA 是把 100 次手动“插入附件”合并成一次循环,SQL 脚本改写的正则替换是把 50 万次手工替换合并成一次全局操作。它们解决的问题不同,但方向惊人地一致:
- 减少网络或用户交互的往返次数。
- 放大单次处理的数据体积或工作量。
- 把“每一条都去做一遍完整流程”变成“攒一批做一次完整流程”。
你在面临任何批量需求时,都可以先问自己三个问题:能不能少跑几次?能不能攒起来一次处理?能不能把逐条错误变成整批错误再加校验兜底?想清楚这三个问题,13 秒插 30 万条不是奇迹,只是正确姿势里的自然结果。
最后再分享一个我个人的习惯:每次写完批量插入代码,不管用什么框架,我都先看两样东西——JDBC 连接串有没有带rewriteBatchedStatements=true,事务提交的批次阈值有没有超过 5000。这两条确认了,批量插入的性能基本不会差到哪里去。剩下的时间,多花在数据校验上,比在 SQL 拼接上抠来抠去更有价值。
