1. 为什么你的MyBatis批量插入还是那么慢?
如果你用过MyBatis或者MyBatis-Plus的saveBatch方法,可能会发现一个奇怪的现象:明明代码里调用了批量插入,但实际执行时,数据库监控里看到的还是一条条单独的INSERT语句,速度跟单条插入没什么区别。我刚开始用的时候也踩过这个坑,折腾了半天才发现,问题出在一个关键的连接参数上。
简单来说,MyBatis的批量操作底层依赖的是JDBC的PreparedStatement.addBatch()和executeBatch()方法。但是,MySQL的JDBC驱动(Connector/J)在默认情况下,根本不会真正批量执行这些语句! 它会假装支持批量,实际上却在背后把我们的批量请求拆成一条条单独的SQL,再依次发给数据库。这样一来,网络往返次数(Round-Trips)一点没减少,性能自然上不去。
这就像是你想用卡车一次性运十箱货,结果司机非要把货拆开,一次只搬一箱,来回跑十趟。效率能高才怪。
那么,怎么让司机听话,真的用卡车一次性运走呢?这就需要我们今天要深入探讨的两个核心配置参数:rewriteBatchedStatements 和 allowMultiQueries。它们就像是给JDBC司机下的两道指令,决定了批量操作到底以何种方式、多大力度去执行。
我会结合自己多年的实战经验,用最直白的语言和可复现的案例,带你彻底搞懂这两个参数。你会发现,仅仅是加一个简单的配置,就能让批量插入的性能提升十几倍甚至几十倍。我们先从一个最直接的性能对比开始,感受一下差距有多大。
2. rewriteBatchedStatements:批量插入的“性能倍增器”
2.1 它到底是什么?解决了什么问题?
rewriteBatchedStatements,直接翻译是“重写批处理语句”。这个参数是MySQL JDBC驱动(Connector/J)提供的一个优化开关。
它的核心作用是这样的: 当你开启它(设置为true),并且使用PreparedStatement进行批量插入(INSERT)时,JDBC驱动会做一个“小动作”。它不再发送多条独立的INSERT INTO ... VALUES (...)语句,而是会把这些语句在客户端重写成一条多值语句:INSERT INTO ... VALUES (...), (...), (...)。
举个例子,假设你批量插入3条记录:
- 未开启时,网络传输的是:
INSERT INTO user (name) VALUES ('张三'); INSERT INTO user (name) VALUES ('李四'); INSERT INTO user (name) VALUES ('王五'); - 开启后,网络传输的是:
INSERT INTO user (name) VALUES ('张三'), ('李四'), ('王五');
别看只是语法上的变化,这对性能的影响是巨大的。减少网络传输的语句数量,直接降低了网络延迟和协议解析的开销。对于需要插入成千上万条数据的场景,这带来的性能提升是指数级的。
2.2 如何配置与验证效果
配置非常简单,只需要在连接MySQL的JDBC URL后面加上这个参数即可。
标准的配置姿势:
# 在application.yml或application.properties中
spring.datasource.url=jdbc:mysql://localhost:3306/your_database?rewriteBatchedStatements=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
或者,如果你用的是Druid、HikariCP等连接池,也是在数据源配置的jdbc-url里加上它。
光说不练假把式,我们来做个实测。 我写了一个简单的测试,对比开启和不开启rewriteBatchedStatements的情况下,插入50万条数据的耗时。为了排除框架干扰,这里先用最原生的JDBC来演示。
public class BatchInsertBenchmark {
public static void main(String[] args) throws Exception {
// 测试数据量
int totalRecords = 500000;
int batchSize = 5000; // 每批提交5000条
// 情况1:不使用批处理优化(默认)
String urlWithout = "jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=Asia/Shanghai";
// 情况2:使用批处理优化
String urlWith = "jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true&useSSL=false&serverTimezone=Asia/Shanghai";
// 分别测试
testBatchInsert("未开启 rewriteBatchedStatements", urlWithout, totalRecords, batchSize);
testBatchInsert("已开启 rewriteBatchedStatements", urlWith, totalRecords, batchSize);
}
private static void testBatchInsert(String desc, String url, int total, int batchSize) throws Exception {
Class.forName("com.mysql.cj.jdbc.Driver");
try (Connection conn = DriverManager.getConnection(url, "root", "password")) {
conn.setAutoCommit(false);
String sql = "INSERT INTO batch_test (data) VALUES (?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
long start = System.currentTimeMillis();
for (int i = 1; i <= total; i++) {
pstmt.setString(1, "data-" + i);
pstmt.addBatch();
if (i % batchSize == 0) {
pstmt.executeBatch();
conn.commit();
}
}
// 处理最后一批
pstmt.executeBatch();
conn.commit();
long end = System.currentTimeMillis();
System.out.printf("%s => 插入 %d 条记录,耗时: %.2f 秒%n",
desc, total, (end - start) / 1000.0);
}
}
}
运行结果对比(基于我的本地测试环境):
- 未开启 rewriteBatchedStatements:插入50万条,耗时约 32秒。
- 已开启 rewriteBatchedStatements:插入50万条,耗时约 2.1秒。
性能提升了超过15倍! 这个差距足以说明,在涉及批量插入的场景下,这个参数是必须打开的。它几乎没有任何副作用,却能带来巨大的性能收益。
2.3 深入原理与注意事项
理解了它的威力,我们再来深入看看它的工作原理和一些容易被忽略的细节。
1. 它只对INSERT语句有“重写”效果吗?
网上有些资料说rewriteBatchedStatements只对INSERT有效,这个说法不完全准确。实际上:
- 对
INSERT:效果最好,会将多条VALUES子句合并。 - 对
UPDATE和DELETE:驱动不会进行语法重写,但依然会将多个语句打包在一次网络请求中发送(类似于用分号;隔开),这同样减少了网络往返次数,也能获得性能提升,只是不如INSERT的合并效果那么显著。
2. 驱动版本要求 要获得稳定可靠的批量重写功能,建议使用 MySQL Connector/J 5.1.13 及以上版本。更老的版本可能支持不完善或有Bug。现在项目一般都用8.x版本了,这点通常不是问题。
3. 一个关键的“小聪明”
JDBC驱动其实很“聪明”,它不会无脑合并。当你的batchSize(批处理大小)很小(比如小于等于3)时,驱动会认为合并带来的收益抵不上重写的开销,反而会选择逐条执行。 所以,为了确保批处理生效,建议设置的批处理大小至少大于3,在实际生产中,根据数据量,设置几百到几千都是常见的。
4. 与MyBatis/MyBatis-Plus的配合
如果你直接使用MyBatis的SqlSession并指定ExecutorType.BATCH,或者使用MyBatis-Plus的saveBatch、updateBatchById方法,底层调用的就是PreparedStatement.addBatch()。因此,只要数据库连接URL中配置了rewriteBatchedStatements=true,这些框架的批量方法就能自动获得性能提升,无需修改业务代码。
3. allowMultiQueries:多条SQL执行的“通行证”
3.1 它与rewriteBatchedStatements有何不同?
如果说rewriteBatchedStatements是JDBC驱动在背后偷偷优化,那么allowMultiQueries就是给你一把钥匙,允许你主动地在一条请求中执行多条SQL语句。
它的作用很明确: 允许在同一个PreparedStatement或Statement中,执行由分号(;)分隔的多个SQL语句。
默认情况下,出于安全考虑(防止SQL注入攻击的一种方式),MySQL驱动是不允许这样做的。如果你在Java代码中试图执行"SELECT * FROM user; DELETE FROM log;"这样的字符串,会直接抛出异常。
那么,它和批处理有什么关系呢? 关系在于,当你需要执行批量更新或批量删除,并且这些语句之间没有关联(无法用IN语句或CASE WHEN统一表达)时,allowMultiQueries提供了一种解决方案。你可以在MyBatis的Mapper XML中,手动编写用分号分隔的多条SQL。
3.2 在MyBatis中的实战用法
配置同样简单,追加到JDBC URL即可:
spring.datasource.url=jdbc:mysql://localhost:3306/your_database?allowMultiQueries=true&rewriteBatchedStatements=true&...
实战场景: 假设有一个需求,在插入一条新闻的同时,需要往消息表里插入一条相关的通知记录。我们希望这两个操作在一个数据库事务中完成,并且只和数据库交互一次。
在MyBatis的Mapper XML文件中,我们可以这样写:
<insert id="insertNewsWithMessage" parameterType="News">
<!-- 插入新闻 -->
INSERT INTO tb_news (title, content, creator_id, create_time)
VALUES (#{title}, #{content}, #{creatorId}, NOW());
<!-- 插入关联消息,使用刚插入新闻的ID -->
INSERT INTO tb_message (title, ref_id, type, create_time)
VALUES (#{title}, LAST_INSERT_ID(), 'NEWS_NOTICE', NOW());
</insert>
注意,两条SQL之间用分号;隔开。当调用这个insertNewsWithMessage方法时,MyBatis会将整段字符串发给MySQL。由于我们开启了allowMultiQueries=true,MySQL会依次执行这两条语句。
这种方式的优势:
- 减少网络交互:两个操作一次往返完成。
- 保证原子性:在一个数据库会话中执行,要么全成功,要么全失败。
- 使用
LAST_INSERT_ID()等函数方便地获取上一条插入的自增ID,用于后续操作。
3.3 重要的安全警告与限制
在开启allowMultiQueries之前,你必须清醒地认识到它的风险!
1. SQL注入风险急剧升高
这是最大的安全隐患。如果你的SQL语句是通过字符串拼接生成的,并且用户输入未经严格过滤,攻击者可能会利用分号注入额外的恶意SQL语句。例如,原本是SELECT * FROM user WHERE id = ${id},如果id参数被传入1; DROP TABLE user; --,后果不堪设想。
因此,强烈建议:
- 永远不要在开启
allowMultiQueries的SQL中使用${}进行字符串拼接。 - 必须使用
#{}预编译占位符。预编译机制会确保用户输入被当作数据而非SQL指令处理,从而从根本上杜绝注入。
2. 动态SQL标签的兼容性问题
根据我踩坑的经验,MyBatis的动态SQL标签(如<if>、<where>、<foreach>)可能无法在allowMultiQueries模式下的多条语句中正常工作。MyBatis在解析动态SQL时,可能会对分号的处理产生混淆。通常,这种“手动分号批处理”更适合用于静态的、固定的多条SQL组合。
3. 与特定连接池的兼容性
一些连接池(如早期版本的Druid)可能对allowMultiQueries有额外的限制或需要特殊配置。如果遇到multi-statement not allow这类错误,需要查阅对应连接池的文档,通常需要在其配置中也允许执行多语句。
4. 强强联合:组合使用的最佳实践与陷阱
在实际项目中,我们通常会把rewriteBatchedStatements和allowMultiQueries一起打开,以获得全面的批量操作能力。但组合使用,也意味着要同时考虑它们各自的特点和可能产生的化学反应。
4.1 如何配置最稳妥?
一个经过生产环境检验的、比较完整的MySQL JDBC URL配置示例如下:
spring.datasource.url=jdbc:mysql://localhost:3306/your_db?
useUnicode=true&
characterEncoding=UTF-8&
useSSL=false&
serverTimezone=Asia/Shanghai&
# 批处理核心优化
rewriteBatchedStatements=true&
# 允许执行多语句(谨慎使用)
allowMultiQueries=true&
# 服务端预编译,提升重复SQL性能
useServerPrepStmts=true&
# 缓存预编译语句
cachePrepStmts=true&
# 预编译语句缓存大小
prepStmtCacheSize=250&
# 可缓存的SQL最大长度
prepStmtCacheSqlLimit=2048&
# 使用性能优化配置集(包含多项优化)
useConfigs=maxPerformance
参数解读:
useServerPrepStmts&cachePrepStmts:这两个是“黄金搭档”,用于开启服务端预编译并缓存,对于高并发重复SQL场景性能提升明显。useConfigs=maxPerformance:这是一个“快捷方式”,它会启用一整套MySQL驱动认为的性能优化配置(其中就包含了cachePrepStmts=true)。
4.2 一个已知的Bug与规避方案
这里需要特别提到一个重要的技术细节,也是很多开发者容易忽略的坑。在MySQL Connector/J的某些版本中,当同时开启 rewriteBatchedStatements=true 和 useServerPrepStmts=true 时,进行批量更新操作可能会触发一个驱动层面的Bug。
这个Bug可能导致更新语句被错误地重写或执行异常。MySQL官方和社区给出的临时解决方案是:同时配置 allowMultiQueries=true。
是的,你没看错。为了解决rewriteBatchedStatements和useServerPrepStmts组合的Bug,需要把第三个参数allowMultiQueries也打开。这听起来有点奇怪,但确实是很多经验文档(包括TiDB等兼容MySQL的数据库最佳实践指南)中推荐的做法。
所以,如果你的应用涉及批量更新,并且使用了服务端预编译, safest的做法就是把这三个参数一起配上。
4.3 性能对比与选型指南
为了更直观,我把不同场景下的优化选择总结成了下面这个表格:
| 操作场景 | 推荐配置 | 原理与效果 |
|---|---|---|
| 大批量数据插入 | rewriteBatchedStatements=true | 将多条INSERT重写为VALUES(),(),(),性能提升最大,可达10倍以上。 |
| 大批量数据更新/删除 | rewriteBatchedStatements=true allowMultiQueries=true | 将多条语句打包为一次网络请求发送,减少网络延迟,有稳定提升。 |
| MyBatis中手动执行多条无关SQL | allowMultiQueries=true | 允许在Mapper XML中用分号写多条SQL,实现一次请求完成多个操作。 |
使用MyBatis-Plus的saveBatch | rewriteBatchedStatements=true | 框架底层已用addBatch(),此配置使其真正生效。 |
| 高并发重复简单SQL | useServerPrepStmts=true cachePrepStmts=true | 启用服务端预编译缓存,大幅减少SQL解析开销。 |
选型心法:
- 追求极致插入性能:无脑开
rewriteBatchedStatements。 - 需要执行复杂批量业务SQL:考虑开
allowMultiQueries,但务必做好防注入。 - 生产环境通用配置:
rewriteBatchedStatements=true+allowMultiQueries=true+useServerPrepStmts=true+cachePrepStmts=true,这是一个兼顾性能、功能和安全性的组合拳。
5. 超越配置:MyBatis批处理的本质与更多优化思路
配置参数是“术”,理解本质才是“道”。要真正玩转MyBatis批处理,我们还得再往下挖一层。
5.1 MyBatis批处理的三种执行器
MyBatis的SqlSession有三种执行器,通过openSession(ExecutorType type)指定:
SIMPLE(默认):每执行一条语句就创建一个新的PreparedStatement。REUSE:复用PreparedStatement对象。BATCH:使用批处理执行器,将多个更新语句攒起来,在commit或flushStatements时一次性执行。
当你使用MyBatis-Plus的saveBatch时,它内部其实就是帮你创建了一个ExecutorType.BATCH的会话。所以,框架的批量方法生效的前提,是JDBC驱动支持真正的批处理(即rewriteBatchedStatements=true)。
5.2 手动SQL拼接:另一种“批处理”思路
除了依赖JDBC批处理,MyBatis还有一种常见的批量操作写法:使用<foreach>标签进行SQL拼接。
<insert id="insertBatch">
INSERT INTO user (name, age) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>
这种方式和rewriteBatchedStatements有什么区别?
- 手动拼接:在MyBatis层面生成一条
INSERT ... VALUES (..), (..), (..)的长SQL。它不依赖rewriteBatchedStatements参数,但需要数据库本身支持这种多值语法(MySQL支持)。它的缺点是SQL长度可能超限(受max_allowed_packet限制),且无法享受预编译批处理的某些优化。 - JDBC批处理:依赖驱动重写。优势是驱动会智能处理,并且与预编译配合更好。在大多数情况下,开启
rewriteBatchedStatements的JDBC批处理方式性能更优、更省心。
5.3 务必要注意的“批次大小”
无论是哪种批处理,都不是一次性把所有数据都塞进去就好。批次大小(Batch Size)是一个需要调优的关键参数。
- 太小(如10):网络往返次数仍然很多,优化效果有限。
- 太大(如10万):单次请求数据包巨大,可能撑满网络缓冲区或数据库内存,导致传输变慢甚至OOM。同时,数据库事务锁持有时间过长,影响并发。
如何设置? 没有一个绝对数值,需要根据数据行大小、数据库负载进行测试。一个常见的经验值是 500到5000 之间。你可以像下面这样,在代码中灵活控制:
// MyBatis-Plus 示例
List<User> userList = ... // 大量数据
int batchSize = 1000;
for (int i = 0; i < userList.size(); i += batchSize) {
List<User> subList = userList.subList(i, Math.min(i + batchSize, userList.size()));
userService.saveBatch(subList); // 分批提交
}
5.4 监控与验证:如何确认优化真的生效了?
配置加好了,代码写完了,怎么知道rewriteBatchedStatements真的在起作用呢?
1. 查看数据库通用日志(General Log) 在MySQL中临时开启通用日志,可以看见所有收到的SQL语句。
SET GLOBAL general_log = 1;
然后执行你的批处理程序。观察日志,如果看到的是长长的多值INSERT语句,说明优化生效;如果看到大量单条INSERT,说明没生效。
2. 使用性能监控工具 像Arthas、SkyWalking这类APM工具,可以监控到应用发起的数据库调用次数和耗时。优化生效后,你会看到对于批量插入,调用次数会从N次(数据条数)急剧下降到几次(批次数)。
3. 最简单的日志判断
在你的JDBC URL中加上profileSQL=true参数,MySQL驱动会将执行的SQL信息输出到日志。不过生产环境慎用,日志量会很大。
在我经历的一个数据迁移项目中,开启rewriteBatchedStatements后,原本需要数小时的千万级数据导入任务,缩短到了二十分钟内完成。这种性能提升是实实在在、肉眼可见的。优化从来不是纸上谈兵,而是每一次配置调整和代码实践中积累出来的真功夫。希望这篇实战解析,能帮你彻底掌握这两个关键参数,让你在应对海量数据操作时更加游刃有余。
450



被折叠的 条评论
为什么被折叠?



