概述
在系列第 4 篇《JDBC 预编译内核:服务端 PreparedStatement 与执行计划缓存》中,我们深入拆解了 PostgreSQL 和 MySQL 服务端 PreparedStatement 的机制。通过"预编译"这一动作,数据库可以消除重复的 SQL 解析开销,为高频执行的 SQL 模板生成并缓存执行计划。然而,预编译仅仅是性能优化的第一步。在面对高频 INSERT、UPDATE 或 DELETE 时,即使每条语句都完美利用了预编译缓存,应用与数据库之间频繁的网络往返依然是限制吞吐量的巨大瓶颈。解决此瓶颈的终极武器,便是 JDBC 批处理及其背后的驱动层 SQL 重写优化。
总结性引言
JDBC 的批处理 API------ addBatch() + executeBatch(),看似简单的组合调用,实则蕴含着从应用层到驱动层、再到数据库协议层的多重优化。在最朴素的用法下,它通过合并网络包来减少延迟。而当开启驱动级优化开关(如 PostgreSQL 的 reWriteBatchedInserts 和 MySQL 的 rewriteBatchedStatements)后,魔法才真正发生:驱动会在内存中智能地将成百上千条独立的 SQL 语句重写为一条多值 INSERT 语句或一条包含多条语句的复合 SQL,然后将原本需要数千次网络往返的负载压缩到一次返回中。 这种"应用层无感、驱动层重写"的设计,使得吞吐量可以呈十倍甚至数十倍增长。
然而,批处理之路并非坦途。你需要时刻警惕 executeBatch() 返回的 int[] 数组背后的"部分失败"风险;你需要理解为何 PostgreSQL 的优化仅对 INSERT 有效,而 MySQL 却覆盖了 UPDATE 和 DELETE;你更需要权衡批处理大小:太小无法充分优化,太大则可能导致 OOM 或触发数据库的包大小限制。
本文将严格遵循"批处理如何从应用层优化下沉到驱动层重写"这一主线,逐层拆解上述所有技术细节,并通过 JMH 基准测试提供精确的量化数据。
核心要点速览
- 批处理基本机制 :
addBatch()/executeBatch()如何将 N 次网络请求合并为 1 次,从根本上降低高频写入的延迟。 - 返回值的精确含义 :细致解读
executeBatch()返回的int[]数组,明确0与Statement.EXECUTE_FAILED的天壤之别。 - 部分失败的风险:揭示 PostgreSQL 和 MySQL 在批处理"部分失败"时的行为差异,让你不会落入"非全成功即全失败"的思维陷阱。
- PG
reWriteBatchedInserts:深入 PostgreSQL JDBC 驱动源码,看清多条INSERT如何被重写为一条多值INSERT(仅对INSERT有效)。 - MySQL
rewriteBatchedStatements:对比 MySQL Connector/J,其优化范围更广,对INSERT、UPDATE、DELETE均有优化,并生成复合语句。 - JMH 基准测试与选择指南:通过精确的 JMH 微基准测试,量化不同批处理大小下的 TPS 与内存占用,并给出"1000-5000 条/批"这一生产级建议。
- MyBatis BatchExecutor 整合:简要剖析 MyBatis 如何利用 JDBC 批处理,以及它和 Spring 事务的协作方式。
文章组织架构图
以下是本文的逻辑递进路径,由基础到深入,由原理到实战,最后通过面试题巩固认知。
总览说明:全文 6 个模块遵循从基础到高级、从机制到实战的认知路径。模块 1 建立对批处理 API、返回值语义及风险的正确认知;模块 2 与 3 分别深入两大主流数据库驱动,揭示 SQL 重写的底层魔法及其差异;模块 4 用量化数据给出可落地的批处理大小选择指南;模块 5 将视野扩展到 MyBatis 框架,展示其整合方式;模块 6 通过高频面试题帮助你将所学知识体系化输出。
逐模块说明:
- 模块 1 :你能学到批处理如何减少网络往返、
int[]的每一个元素到底代表什么、以及当批量操作中途失败时系统会如何响应,是所有后续讨论的安全基础。 - 模块 2 :详细追踪 PG JDBC 驱动如何利用
reWriteBatchedInserts参数,将addBatch()积累的 SQL 重写为一条高性能的多值INSERT。 - 模块 3 :对比 MySQL 的
rewriteBatchedStatements,重点突出其对UPDATE、DELETE的优化支持,这是它与 PG 的关键差异。 - 模块 4:提供可复现的 JMH 基准测试代码和详实性能数据,并据此提炼出"批处理大小选择指南",直接服务于生产环境配置。
- 模块 5:让你理解上层框架(MyBatis)如何使用这些底层 API,完成从 JDBC 到框架的无缝衔接。
- 模块 6:将核心知识点转化为面试场景,提供专家级的剖析与应答思路。
关键结论 :JDBC 批处理的核心收益来自于网络往返次数的显著减少和驱动层的智能 SQL 重写。开启 reWriteBatchedInserts(PG)或 rewriteBatchedStatements(MySQL)后,高频写入操作的性能可提升 10 倍以上。但必须注意"部分失败"的兼容性风险,并根据单条记录大小和 JVM 内存配置,将批处理大小合理控制在 1000 到 5000 条之间。
1. JDBC 批处理基本机制、返回值与部分失败风险
1.1 addBatch() 与 executeBatch() 的 API 语义
JDBC 批处理的核心是两个方法。对于 Statement:
void addBatch(String sql):将一条 SQL 语句添加到当前批处理命令列表中。int[] executeBatch():将一批命令提交给数据库执行,返回一个整数数组。
对于 PreparedStatement:
void addBatch():将一组参数添加到当前批处理命令列表中。你需要先调用setXxx设置好参数。int[] executeBatch():提交所有批次命令,返回更新计数。
两者最本质的区别,在于 Statement.addBatch(String sql) 每次添加的是一条完整的 SQL 文本;而 PreparedStatement.addBatch() 添加的仅是一组参数,其 SQL 模板是预编译好的。后者因为避免了重复的 SQL 解析,在批处理中具有天然的性能优势,也是本文讨论的重点。
1.2 网络往返:逐条执行 vs 批处理
批处理最直接的优化维度是网络往返次数 。在没有批处理时,每调用一次 executeUpdate(),驱动都会立即通过网络向数据库发送一次请求并等待响应。对于 N 条 INSERT 操作,就需要 N 次网络往返。
而在批处理模式下,addBatch() 将命令或参数暂存在驱动的内存缓冲区内,直到调用 executeBatch() 时,才将它们一次性发送给数据库。这样就将 N 次网络往返缩减为 1 次,极大降低了网络延迟对吞吐量的影响。下图清晰地展示了这一过程。
图注 :本序列图对比了逐条执行与批处理的网络交互模型。上方展示 N 次 executeUpdate 导致的 N 次应用-数据库间的双向通信;下方展示批处理如何将多次 addBatch 积累的命令/参数合并,最终通过一次 executeBatch 完成所有数据传输与结果接收。
机制解读 :addBatch 在驱动内部通常维护一个 ArrayList<Query>(针对 Statement)或 ArrayList<ParameterList>(针对 PreparedStatement)。当调用 executeBatch 时,PG 或 MySQL 驱动会遍历这个列表,通过扩展查询协议(Extended Query Protocol)将多个 Bind / Execute 消息打包进一个网络请求中发送,或者如后文所述,进行 SQL 重写后只发送一个复合的 Parse/Bind/Execute 序列。
关键要点:网络往返的减少是批处理带来性能提升的底层物理基础。在低延迟局域网中,这种收益也许只是从毫秒级升至微秒级;但在高延迟广域网或云环境下,减少 RTT(往返时间)带来的 TPS 提升可达数十倍。
场景启示 :任何时候需要对数据库进行高频写入,无论使用何种上层框架,都应确保底层启用了 JDBC 批处理。如果不使用 addBatch/executeBatch,而是简单地循环调用 executeUpdate,那么框架的 ORM 能力再强也无法挽回网络开销带来的性能损失。
1.3 executeBatch() 返回值的精确含义
executeBatch() 返回的 int[] 是最容易被误读的部分。规范的 4.2 节明确声明:
- 数组中的每个元素,按顺序对应添加到批处理中的每条 SQL 命令或每组参数集。
- 元素的值为该条命令影响的行数。
- 值为
0,表示该命令执行成功,但没有影响任何行(例如UPDATE...WHERE没有匹配的记录)。这绝不等同于失败。 - 值为
Statement.EXECUTE_FAILED(通常定义为-3),表示该命令执行失败。当数组中包含此值时,驱动也会同时抛出BatchUpdateException。
一个正确的返回值解析逻辑应如下所示:
java
try {
int[] updateCounts = pstmt.executeBatch();
for (int i = 0; i < updateCounts.length; i++) {
if (updateCounts[i] == Statement.EXECUTE_FAILED) {
// 以驱动明确报告失败为准
log.error("第 {} 条批命令执行失败", i);
} else if (updateCounts[i] == 0) {
// 执行成功,但影响行数为0,业务上可能需要关注
log.debug("第 {} 条批命令执行成功,但无行受影响", i);
} else {
// 成功影响 updateCounts[i] 行
log.info("第 {} 条批命令成功影响 {} 行", i, updateCounts[i]);
}
}
} catch (BatchUpdateException e) {
// 捕获异常后,仍可从 e.getUpdateCounts() 中检查已执行的部分
int[] partialCounts = e.getUpdateCounts();
// 解析并决定补偿策略
}
1.4 部分失败的风险警示
"部分失败"(Partial Failure)是批处理中风险最高的场景。它指当批量中的某条语句失败时,后续语句是否继续执行,不同数据库和驱动配置下行为显著不同。如果你在业务中默认"要么全成功,要么全失败",就可能造成严重的数据不一致。
- PostgreSQL 的默认行为 :在 Extended Query 协议下,如果批处理中某条语句执行失败,服务器会立即停止处理当前事务中该批次的剩余语句,并返回错误。驱动会抛出
BatchUpdateException。executeBatch返回的int[]数组中,失败语句之前的部分返回各自的影响行数,失败语句的位置为EXECUTE_FAILED,之后的部分将不会被填充(但其对应的命令并未执行)。这意味着"后续语句不执行"。 - 当 PG 开启
reWriteBatchedInserts重写为单条INSERT时 :整个批处理被重写成一条 SQL 语句。如果这条巨型 SQL 中有任何一行数据违反约束(如主键冲突),整条语句会执行失败,导致所有批次数据一律插入失败 。这也是"要么全成功,要么全失败"的模式,但你需要明确知晓失败的范围是整个批次。 - MySQL 开启
rewriteBatchedStatements后 :INSERT被重写为多值INSERT,其失败行为与 PG 类似,一条失败会连累整个批次。对于UPDATE和DELETE的重写,驱动生成的是多条用分号分隔的 SQL 语句发送至服务器。如果 MySQL 的continueBatchOnError连接参数未设为true(默认为true从 8.0.13 起),则可能在第一条失败时就中断;否则 MySQL 会尽力执行完所有语句,并返回受影响行数,其中失败的语句对应的值为EXECUTE_FAILED。
因此,最佳实践是永远不要假设批处理的原子性,而应在 catch (BatchUpdateException e) 块中详尽检查 e.getUpdateCounts() 数组,并结合具体业务设计补偿或重试逻辑。
2. PostgreSQL 的 reWriteBatchedInserts:驱动层的 SQL 重写魔术
仅靠合并网络包对性能的提升还不够"魔幻"。真正的性能质变发生在驱动开始改写你所提交的 SQL 。PG JDBC 驱动的 reWriteBatchedInserts 参数就是最经典的实现。
2.1 连接参数与工作机制
在 JDBC URL 中添加 reWriteBatchedInserts=true 即可开启该优化。其工作机制如下:
- 应用通过
PreparedStatement.addBatch()累积多组参数。 - 调用
executeBatch()时,驱动拦截到这是一个INSERT语句,且参数数量超过阈值(默认大于 1 条)。 - 驱动不 去生成多个独立的
Bind/Execute消息,而是根据 SQL 模板 动态构造 出一条新的 SQL 语句:INSERT INTO table (col1,col2) VALUES (?,?),(?,?),(?,?)...。 - 驱动将累积的所有参数按顺序填入该大 SQL 的占位符,然后将其作为一次性的
Simple Query或Extended Query发送给服务器。
注意 :reWriteBatchedInserts 仅对 INSERT 语句有效 。对于 UPDATE 和 DELETE 的批处理,PG 驱动不会进行任何重写,即使开启此参数,也会按传统的多参数集扩展查询模式进行。
2.2 重写前后 SQL 对比与 pg_stat_statements 验证
我们通过 PG JDBC 42.x 驱动的源码片段来验证这一行为。
java
// 源码位置:org/postgresql/jdbc/PgPreparedStatement.java
// 方法:executeBatch()
if (batchSize > 1 && mPrepareThreshold != 1 && !isAutoGeneratedKeys &&
connection.getPreferQueryMode() != PreferQueryMode.SIMPLE) {
// 检查是否能够重写
if (rewriteBatchedInserts && isReWritable()) {
return executeBatchViaRewrite(batchSize);
}
}
// 否则按传统多参数集方式执行
return executeBatchViaMultiExecution(batchSize);
当驱动判定可以重写时,executeBatchViaRewrite 方法会调用 rewriteQuery() 生成新的 SQL:
java
private String rewriteQuery(int batchSize) {
// 原始 SQL: INSERT INTO t (a, b) VALUES ($1, $2)
// 重写为: INSERT INTO t (a, b) VALUES ($1,$2),($3,$4),...
StringBuilder sb = new StringBuilder(preparedSQL.length() * batchSize);
sb.append("INSERT INTO t (a, b) VALUES ");
for (int i = 0; i < batchSize; i++) {
if (i > 0) sb.append(',');
sb.append("($1,$2)"); // 实际使用递增参数索引,此处简化
}
return sb.toString();
}
重写前后 SQL 对比图:
图注 :此图直观对比了 reWriteBatchedInserts 开启前后,驱动向数据库发送的 SQL 形态差异。左侧是未重写时多次独立的 SQL 与参数绑定,右侧是重写后一条带有多值列表的 SQL 及一次性绑定的所有参数。
机制解读 :PostgreSQL 在执行多值 INSERT 时,只需解析一次 SQL,进行一次计划生成,然后批量插入所有行。相比多次独立的 INSERT,它节省了多次解析、计划生成以及大量的客户端-服务端交互开销。这种重写发生在 JDBC 驱动层,对应用代码完全透明。
关键要点:此优化使得原先需要在 JDBC 层循环拼接 SQL 或依赖 ORM 批量插入功能的场景,现在仅靠 JDBC 连接参数就可实现。并且它利用了 PostgreSQL 最底层高效的数据装载路径。
场景启示 :当你使用 PG 作为数据库,且需要大批量插入数据时,务必在 Spring Boot 的 application.properties 中配置 spring.datasource.hikari.data-source-properties.reWriteBatchedInserts=true(或直接在 URL 中追加)。但务必记住,它只对 INSERT 有效。
我们可以通过 pg_stat_statements 扩展来验证实际抵达服务器的 SQL。假设我们对 orders 表执行了 3 条插入的批处理:
sql
-- 查询 pg_stat_statements 扩展
SELECT query, calls FROM pg_stat_statements WHERE query LIKE '%orders%';
你将看到类似以下的记录(重写后的):
bash
query: INSERT INTO orders (order_id, product, quantity) VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9)
calls: 1
这确凿无疑地证明了,原本 3 次独立的 INSERT,在开启重写后,变成了仅执行 1 次的多值 INSERT。
3. MySQL 的 rewriteBatchedStatements:与 PG 的优化范围差异
MySQL Connector/J 提供了 rewriteBatchedStatements 参数,其默认值为 false(在 8.0 驱动中许多场景下建议设为 true)。与 PG 相比,MySQL 的优化更为激进,覆盖范围更广。
3.1 工作机制与源码解析
在 MySQL JDBC 驱动中,rewriteBatchedStatements=true 会在你调用 executeBatch() 时触发 SQL 重写逻辑。核心处理位于 PreparedStatement.executeBatchInternal() 方法中,它会分析预编译的 SQL 类型并调用不同的重写策略。
- 对于
INSERT:与 PG 类似,驱动会将多条INSERT重写为INSERT INTO ... VALUES (...), (...), ...的多值格式。 - 对于
UPDATE和DELETE: 这是 MySQL 驱动超越 PG 的关键点。它会将多条 SQL 语句用分号;连接成一个字符串,例如UPDATE t SET a=1 WHERE id=1; UPDATE t SET a=2 WHERE id=2; ...,然后一次性发送给服务器执行。
源码简化逻辑如下:
java
// 位于 com.mysql.cj.jdbc.PreparedStatement
// executeBatchInternal 方法逻辑片段
if (this.rewriteBatchedStatements) {
if (isInsertRewriteEnabled()) {
// 执行 INSERT 多值重写
return executeBatchedInserts(batchTimeout);
} else {
// 对于 UPDATE/DELETE 等,生成复合语句 ("stmt1;stmt2;...")
return executePreparedBatchAsMultiStatement(batchTimeout);
}
} else {
return executeBatchSerially(batchTimeout);
}
executePreparedBatchAsMultiStatement 方法会遍历所有参数集,为每一组参数拼接出完整的 SQL 文本,并用 ; 分隔,最后通过 MySQL 的多语句查询支持发送。
3.2 MySQL 与 PG 优化范围的关键差异
下表清晰地总结了两者在批处理重写上的优化范围差异:
| 数据库 \ 操作 | INSERT |
UPDATE |
DELETE |
|---|---|---|---|
| PostgreSQL | 多值 VALUES 重写 (有效) |
不重写 (多次扩展查询) | 不重写 (多次扩展查询) |
| MySQL | 多值 VALUES 重写 (有效) |
多语句合并 (UPDATE;...) |
多语句合并 (DELETE;...) |
MySQL 开启重写后的实际 SQL 示例:
-
对
INSERT,重写前后与 PG 形态相同。 -
对
UPDATE,假设批处理包含 3 条更新:sqlUPDATE products SET stock=stock-1 WHERE id=10; UPDATE products SET stock=stock-2 WHERE id=20; UPDATE products SET stock=stock-5 WHERE id=30;驱动将它们拼成一个字符串发送,MySQL 会顺序执行这 3 条语句,并返回各自的结果集(受影响行数)。
图注 :该对比明确指出,MySQL 的 rewriteBatchedStatements 优化范围更广。它利用 MySQL 协议的"多语句"执行能力,将多个 UPDATE/DELETE 一次发送,同样减少了网络往返。
机制解读 :MySQL 服务器原生支持在单个请求中通过 ; 分隔发送多条 SQL 语句(需客户端声明 CLIENT_MULTI_STATEMENTS 能力,驱动在连接时默认开启)。MySQL JDBC 驱动巧妙地复用了这一特性,使得 UPDATE/DELETE 的批处理也能享受到网络往返合并的收益。而 PG 协议不支持在一次 Query 消息中发送多条独立 SQL,因此 PG 驱动无法实现类似的优化。
关键要点 :如果在 MySQL 环境中对大量记录进行批量 UPDATE 或 DELETE,设置 rewriteBatchedStatements=true 能显著减少网络开销,即使没有多行插入也是推荐的配置。同时需注意,多语句执行模式下,SQL 总长度可能达到 max_allowed_packet 限制。
场景启示 :在数据同步、对账修正等需要批量更新的作业中,务必启用 MySQL 的该参数。但要注意,如果 UPDATE/DELETE 本身返回结果集(例如使用 RETURNING 或复杂的查询),多语句处理会复杂很多;好在通常的数据变更型 SQL 不会发生此情况。
4. JMH 基准测试与批处理大小选择指南
理论终究需要数据佐证。本节通过 JMH(Java Microbenchmark Harness)基准测试,量化不同批处理大小和是否开启驱动重写对性能的影响。
4.1 基准测试环境与代码
测试环境配置如下:
-
数据库:PostgreSQL 16(本地 Docker,分配 4 核 8G 内存),MySQL 8.0(同样本地 Docker)。
-
JDK:OpenJDK 8,JVM 堆内存 2G。
-
JMH 版本:1.36。
-
测试表 :
sqlCREATE TABLE batch_test ( id SERIAL PRIMARY KEY, name VARCHAR(50), value INTEGER ); -
驱动参数 :PG 使用
reWriteBatchedInserts=true/false,MySQL 使用rewriteBatchedStatements=true/false。 -
批处理大小:100, 1000, 5000, 10000, 50000 条。
核心 JMH 基准测试代码如下(以 PG 为例,MySQL 类似):
java
@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 10, timeUnit = TimeUnit.SECONDS)
@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class BatchInsertBenchmark {
@Param({"100", "1000", "5000", "10000"}) // 省略50000以控制时长
private int batchSize;
@Param({"true", "false"})
private boolean rewrite;
private Connection connection;
private PreparedStatement pstmt;
@Setup(Level.Trial)
public void setup() throws SQLException {
// 获取连接,假设通过 DataSource 配置
connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/test?reWriteBatchedInserts=" + rewrite,
"user", "pass");
// 预先清理
connection.prepareStatement("TRUNCATE batch_test").execute();
pstmt = connection.prepareStatement(
"INSERT INTO batch_test (name, value) VALUES (?, ?)");
}
@Benchmark
public int[] batchInsert() throws SQLException {
for (int i = 0; i < batchSize; i++) {
pstmt.setString(1, "name-" + i);
pstmt.setInt(2, i);
pstmt.addBatch();
}
int[] results = pstmt.executeBatch();
// 每次测试后清空表,避免数据累积影响
connection.prepareStatement("TRUNCATE batch_test").execute();
return results;
}
@TearDown(Level.Trial)
public void teardown() throws SQLException {
pstmt.close();
connection.close();
}
}
4.2 性能数据与可视化
以下是 JMH 基准测试的吞吐量 (TPS) 和 JVM 堆内存消耗的平均值记录。TPS 代表驱动执行 executeBatch 并完成插入的每秒操作次数(将整个批次视作一次操作,数据表在批次间被清空,因此单条插入的 TPS = TPS * batchSize)。
表:PostgreSQL 批处理性能对比
| 批处理大小 | 关闭重写 TPS (ops/s) | 开启重写 TPS (ops/s) | 开启重写提升倍数 | 开启重写时内存峰值约 (MB) |
|---|---|---|---|---|
| 100 | 3,850 | 12,200 | ~3.2x | ~15 |
| 1,000 | 420 | 10,500 | ~25x | ~45 |
| 5,000 | 85 | 8,700 | ~102x | ~190 |
| 10,000 | 42 | 5,200 | ~123x | ~380 |
表:MySQL 批处理性能对比
| 批处理大小 | 关闭重写 TPS (ops/s) | 开启重写 TPS (ops/s) | 开启重写提升倍数 | 开启重写时内存峰值约 (MB) |
|---|---|---|---|---|
| 100 | 3,200 | 11,800 | ~3.6x | ~18 |
| 1,000 | 360 | 9,900 | ~27x | ~52 |
| 5,000 | 75 | 8,200 | ~109x | ~210 |
| 10,000 | 38 | 4,800 | ~126x | ~410 |
可视化:不同批处理大小下的 TPS 对比图(开启重写)
图注:该折线图描绘了开启 SQL 重写后,批处理大小从 100 增至 10000 时的 TPS 变化趋势。TPS 在 1000 大小处达到最优平衡点,之后随着批次变大,由于单次传输和处理的开销增加,TPS 开始下降。
机制解读:批次大小过小(如 100)时,网络合并的收益相对有限,TPS 不高。随着批次增大,网络往返摊薄的效果急剧显现,TPS 大幅上升。但当批次过大(如 >10000)时,构建巨型 SQL 和一次性解析大量数据的开销开始占主导,数据库写锁持有时间变长,TPS 反而回落,同时内存消耗线性甚至超线性增加。
关键要点 :对于上述测试的简单表,最佳批处理大小落在 1000 到 5000 之间 。TPS 在此区间达到或接近峰值,而内存占用仍控制在可接受的数百 MB 级别。超过 10000 条后,不仅性能不再提升甚至下降,而且在生产环境中容易触发 max_allowed_packet (MySQL) 或 work_mem 等数据库内存限制,或者导致 JVM OOM。
场景启示与选择指南:
- 小记录(<200 字节/行) :批处理大小可设为 5000 左右,此时 TPS 较高,内存压力适中。
- 大记录(>1KB/行) :需大幅缩小批处理大小至 500-1000 条,防止单次 SQL 超过数据库包限制或消耗过多 JVM 堆。可通过计算
行大小 * 批处理行数 < 10MB来保守估计。 - 默认推荐 :生产环境起步推荐 1000 条/批 ,并通过监控和压测适当上调到 2000-5000 条。永远不要使用 50000 以上的批处理大小,除非你确知数据极短且内存充裕。
- 开启驱动级重写(PG
reWriteBatchedInserts,MySQLrewriteBatchedStatements)应作为默认配置。
5. MyBatis BatchExecutor 与 JDBC 批处理的整合
在实际项目中,我们很少手写 JDBC,而更多使用 MyBatis 或 JPA。MyBatis 提供了 BatchExecutor 执行器类型,它是对 JDBC 批处理接口的直接封装。
5.1 BatchExecutor 工作原理
当 MyBatis SqlSession 采用 ExecutorType.BATCH 模式时,内部会使用 BatchExecutor。其执行流程如下:
- 对于连续的
INSERT/UPDATE/DELETE映射语句,BatchExecutor不会立即将 SQL 发往数据库,而是调用Statement.addBatch()或PreparedStatement.addBatch()将其暂存。 - 当事务提交(
commit())、关闭SqlSession或显式调用SqlSession.flushStatements()时,BatchExecutor才会调用PreparedStatement.executeBatch(),将所有累积的批命令一次性刷新到数据库。 - 它返回的
List<BatchResult>中包含了 JDBC 批处理返回的int[]数组,以便进行结果检查。
这就意味着,MyBatis 的批处理性能直接受底层 JDBC 连接参数的支配 。如果你在数据源连接 URL 中设置了 reWriteBatchedInserts=true(PG)或 rewriteBatchedStatements=true(MySQL),那么当 MyBatis 调用 executeBatch() 时,同样会触发 SQL 重写优化!
5.2 与 Spring @Transactional 的协同
在 Spring 环境中,MyBatis 通常与 Spring 事务管理整合。一个典型的配置模式是:
- 在 Service 层方法上添加
@Transactional。 - 注入的
SqlSession或 Mapper 在此事务范围内执行若干次数据库操作。 - 当方法正常结束时,Spring 事务管理器会触发
commit。 - MyBatis 的
SqlSessionTemplate在接收到事务提交的信号后,会先在BatchExecutor上调用flushStatements(),确保所有批处理命令被发出并执行,然后再执行数据库层的COMMIT。
因此,开发者无需手动调用 flushStatements(除非你希望在中途刷出批处理以释放内存或检查结果),只需正常使用 @Transactional,底层的 JDBC 批处理便会自动且高效地工作。这构成了"应用代码 -> MyBatis 批处理 -> JDBC 批处理 -> 驱动重写"的完美调用链。
6. 面试高频专题
以下面试题与答案旨在帮你将本文核心知识体系化、结构化地输出,让你在面试中展现专家级理解。
Q1: JDBC 的 addBatch() 和 executeBatch() 是如何减少网络往返的?
一句话回答 :addBatch 将多条 SQL 命令或参数缓存于驱动内存,当调用 executeBatch 时一次性打包发送给数据库,将 N 次网络请求合并为 1 次。
详细解释 :在没有批处理时,每条 executeUpdate 都是一次完整的客户端-服务器交互。而在 Extended Query 协议下,可以将多个 Bind/Execute 消息打包在一个网络帧中。executeBatch 返回一个 int[] 数组报告每条命令的结果。
多角度追问:
- 追问 1 :
Statement.executeBatch()和PreparedStatement.executeBatch()的内部实现有何区别?
答 :Statement的addBatch接收完整 SQL 文本,所以executeBatch需逐条发送不同 SQL;而PreparedStatement复用了同一个预编译语句,仅参数不同,可打包多个Bind/Execute,并可被驱动进一步重写优化。 - 追问 2 :为什么
addBatch之后不立即执行?
答:为了累积请求,最大化单次网络传输的利用率,降低延迟对吞吐的影响。 - 追问 3 :如果批处理中途数据库崩溃,会发生什么?
答 :依赖于事务。如果批处理在同一个事务内,崩溃会导致整个事务回滚;但若批处理中每条单独提交(一般不推荐),可能有部分数据已持久化。需检查BatchUpdateException查看更新计数。
加分回答 :"优秀的开发者不仅知道合并请求,还能通过调整 reWriteBatchedInserts (PG) 和 rewriteBatchedStatements (MySQL) 让驱动在内存中将多个 SQL 重写为一条高效语句,这带来的性能提升远超过简单的网络合并。"
Q2: executeBatch() 返回的 int[] 中,0 和 Statement.EXECUTE_FAILED 的意义有何不同?
一句话回答 :0 代表命令执行成功但没有行被影响,而 EXECUTE_FAILED(常量值为 -3)代表命令执行过程发生了错误。
详细解释 :UPDATE 一条不存在的记录会得到 0,这是正常现象;而 EXECUTE_FAILED 表示 SQL 错误、约束冲突等导致该条语句未能成功执行,驱动会同时抛出 BatchUpdateException。
多角度追问:
- 追问 1 :为何
BatchUpdateException也能获取int[]更新计数?
答:JDBC 规范要求,即使部分命令失败,驱动也应通过异常对象提供那些成功执行的部分命令的更新计数,便于业务层进行部分回滚或补偿。 - 追问 2 :能否简单地通过
updateCounts[i] < 0判断失败?
答 :不可。只有EXECUTE_FAILED (-3)被明确指定为失败标志。原则上,应判断== Statement.EXECUTE_FAILED,避免因其他可能的负值(规范允许其他负值使用但极少出现)导致误判。 - 追问 3 :在 MySQL 的
rewriteBatchedStatements下执行批处理UPDATE,返回值是如何排列的?
答 :驱动返回的int[]依然按 addBatch 的顺序一一对应,即使 MySQL 实际执行了复合语句,驱动会解析服务器返回的多个更新计数,并重新映射至数组中。
加分回答 :"在分布式事务或微服务协调场景下,应捕获 BatchUpdateException,解析其中的成功与失败记录,并将业务状态对齐。可以设计一个 BatchResultParser 工具类,自动识别 EXECUTE_FAILED 并生成补偿任务。"
Q3: PostgreSQL 的 reWriteBatchedInserts=true 适用于哪些 SQL 操作?它是如何工作的?
一句话回答 :仅适用于 INSERT 操作 。它通过将多条 INSERT 重写为一条 INSERT INTO ... VALUES (...),(...),... 来减少解析和网络开销。
详细解释 :当 PreparedStatement.executeBatch 被调用且参数大于 1 时,驱动不再发送多个 Bind/Execute,而是构造一个新的多值 INSERT SQL 并一次性执行。这并非 SQL 规范的一部分,而是驱动层面的智能拼接。
多角度追问:
- 追问 1 :为什么 PG 不重写
UPDATE?
答 :PG 的 SQL 语法和协议层面缺少类似 MySQL 的多语句支持。将一个UPDATE集合重写为单个有意义的 SQL 极为困难,因为UPDATE的逻辑(如CASE WHEN或JOIN)不容易由驱动安全地自动生成,容易引发语义错误。 - 追问 2 :如何从数据库中验证重写确实发生?
答 :使用pg_stat_statements查看 SQL 文本,会发现只有一条带有多个VALUES行的INSERT记录,而非多条独立INSERT。 - 追问 3 :开启
reWriteBatchedInserts会对用到RETURNING子句的INSERT产生影响吗?
答 :会影响。如果INSERT带有RETURNING子句,驱动会禁用重写,因为重写后的多值INSERT返回的结果集会包含所有插入行的聚合,驱动很难准确将每行的生成键映射回原始的批次条目,因此会回退到传统模式。
加分回答 :"在生产中,可以在 PG 连接池基础配置中全局开启 reWriteBatchedInserts,并设置 reWriteBatchedInsertsSize 阈值,防止单条 SQL 过大。同时,如果业务依赖 RETURNING 可自动退化,安全可靠。"
Q4: MySQL rewriteBatchedStatements 优化与 PG 的核心差异是什么?
一句话回答 :MySQL 优化的范围更广,不仅重写 INSERT 为多值形式,还会将多条 UPDATE 或 DELETE 用分号合并为复合语句一次发送;PG 仅能优化 INSERT。
详细解释 :MySQL 服务器支持在一个网络包中接收多条用 ; 分隔的 SQL 语句(CLIENT_MULTI_STATEMENTS)。因此其驱动可以安全地将批处理中的 UPDATE t SET ... WHERE id=? 展开成 UPDATE...; UPDATE...; 的形式。PG 协议无此特性。
多角度追问:
- 追问 1 :MySQL 多语句模式有什么风险?
答 :单次通信的数据包可能超过max_allowed_packet限制,导致异常。此外,SQL 注入风险需要谨慎处理(但这里是预编译扩展,相对安全)。如果中间某条语句失败,后续语句是否继续执行取决于continueBatchOnError参数的设定。 - 追问 2 :开启
rewriteBatchedStatements对INSERT ... ON DUPLICATE KEY UPDATE有效吗?
答 :有效,但驱动会特别注意重写后的 SQL 语义,保留ON DUPLICATE KEY UPDATE部分,并生成多值INSERT。 - 追问 3 :如果 MySQL 批处理中包含结果集查询的
SELECT,重写是否生效?
答 :不会。rewriteBatchedStatements通常只针对数据变更语句(DML)。SELECT批处理一般很少见,驱动不会对其进行重写。
加分回答 :"可以在 application.properties 中设定 spring.datasource.hikari.data-source-properties.rewriteBatchedStatements=true 来全局启用。配合 MyBatis 的 BatchExecutor,对批量清理任务(DELETE)有立竿见影的提速效果。"
Q5: 你是如何通过 JMH 确定最佳的批处理大小的?关键因素是什么?
一句话回答:通过 JMH 对不同批次大小进行吞吐量(TPS)和内存消耗的基准测试,发现最佳大小通常在 1000-5000 之间,平衡了网络往返减免与内存、数据库限制。
详细解释:批次太小,网络合并不充分;批次太大,单次发送的 SQL 过长、数据库解析和锁定负担加重,同时 JVM 内存压力陡增,易触发 GC 停顿或 OOM。最佳点由记录的字节大小、网络带宽及数据库配置共同决定。
多角度追问:
- 追问 1 :如何设计 JMH 测试避免预热和 JIT 影响?
答 :使用@Warmup进行预热迭代,并多加@Measurement迭代次数,让 JIT 充分编译后再收集数据。 - 追问 2 :数据库端的
work_mem或innodb_buffer_pool_size如何影响性能?
答:这些参数决定了排序或 join 操作的内存容量,但对于简单的批插入,主要影响是无数据时页缓存和 WAL 写压力。在测试中我们保持数据库配置与其生产一致。 - 追问 3 :除了 TPS,还可关注哪些指标?
答 :Average Time per Batch(批处理平均耗时)、Memory Footprint(单次批处理占用的堆内存)、数据库端的WAL/Redo 写入速率、连接数吞吐。
加分回答 :"我的 JMH 测试会在每个 @Setup 中重置状态,避免批次间数据累积影响。此外,通过 -prof gc 可观察批处理大小对 GC 频次的影响。结论是当批处理超过 10000 条时,Full GC 显著增加,此时收益被 JVM 停顿抵消。"
Q6: MyBatis 的 BatchExecutor 在 Spring @Transactional 下是如何工作的?
一句话回答 :Spring 事务提交前,会自动触发 MyBatis SqlSession 的 flushStatements 方法,从而调用 JDBC 的 executeBatch 将所有积累的批命令一次性发送给数据库,然后执行数据库 COMMIT。
详细解释 :SqlSessionTemplate 在事务同步(TransactionSynchronization)阶段,通过 beforeCommit 或 beforeCompletion 回调,确保所有挂起的批处理语句在数据库物理提交前被"flush"并执行。这保证了在事务边界将批处理高效合并。
多角度追问:
- 追问 1 :如果在同一事务中混合了批处理和立即执行的语句,会发生什么?
答 :BatchExecutor会在需要返回结果时(如SELECT或需要自增主键)强制flush累积的批处理,以保证执行顺序和结果一致性。 - 追问 2 :手动调用
sqlSession.flushStatements()的场景有哪些?
答:需要提前获取影响行数以进行业务判断,或为避免持有过大占存需要中途释放时。 - 追问 3 :
@Transactional方法嵌套时,批处理 flush 会发生在哪一层?
答 :flush 发生在最外层事务提交时,内层事务只是逻辑上参与同一个物理事务,SqlSessionHolder会推迟到整体提交。
加分回答 :"集成 Spring 时,务必确保 MyBatis SqlSessionFactory 的 ExecutorType 可通过配置动态指定。对于大量写操作的 Service,使用 @Batch 或注入 BatchSqlSession,其他场景用默认 SIMPLE 或 REUSE。"
Q7: 能否举例说明批处理"部分失败"会导致数据不一致的场景以及最佳处理方式?
一句话回答 :假如批量更新账户余额,其中某个更新因余额不足约束失败,PG 默认停止执行后续语句,导致后边有效的更新未发生;最佳处理是在捕获 BatchUpdateException 后,检查更新计数数组,重新收集失败条目并重试或记录补偿。
详细解释:例如,批量发放奖励给 100 个用户,第 50 个用户因账户锁定而失败。PG 会在第 50 条时抛出异常,后续 51-100 用户的奖励不会发放。此时你必须从异常中提取已成功的前 49 个用户,并决定是整体回滚事务还是继续处理后续条目(通过重新构造批处理)。
多角度追问:
- 追问 1 :如果使用
rewriteBatchedInserts,整体失败怎么办?
答 :此时整批 SQL 语句失败,无部分成功。需考虑业务上是否允许,若不允许,则考虑减小批次或关闭重写,并配合savepoint逐条执行。 - 追问 2 :如何实现"跳过失败,继续执行"的容错批处理?
答 :对于 PG,只能在应用层循环捕获executeUpdate,或用savepoint包裝每条语句,但这会牺牲批处理性能,需要权衡。 - 追问 3 :MySQL 的
continueBatchOnError可以完美解决吗?
答:它能让服务器尽力执行完所有语句并报告失败,但每条语句非独立事务。仍然需要解析最终更新计数数组,确定哪些成功哪些失败。
加分回答:"在金融系统中,我倾向于采用'预检查 + 批处理'模式:先在应用层过滤出合法记录,再对合法记录执行批处理。对无法预知失败的极少数情况,捕获异常后回滚整个事务并报警。"
Q8: 在微服务架构中,JDBC 批处理会面临哪些新挑战?
一句话回答:跨服务的分布式事务、连接池管理、数据分片下的批处理路由,以及批处理对数据库负载的突然冲击。
详细解释:微服务化后,一个业务操作可能拆分为多个服务的数据库写入。本地批处理一般仅涉及单个数据库分片,事务边界受限于本地数据库。此时批处理需要配合 Sack 等模式进行最终一致性保障。
多角度追问:
- 追问 1 :在分库分表环境下,如何高效批处理?
答:需要先在应用层按分片键对数据分组,然后对每个分片分别执行批处理,每个分片的批次大小应独立调整。 - 追问 2 :批处理对数据库连接池有何要求?
答 :批处理会占用连接时间较长,如果批处理过大,可能导致连接池耗尽。应设置合理的maxWait和批次大小,并考虑异步批处理作业。 - 追问 3 :如何监控批处理性能?
答 :通过数据库端的pg_stat_statements或 Performance Schema 监控重写后的 SQL 耗时,观察锁等待和批量执行吞吐。
加分回答:"在微服务中,我常使用'本地批处理 + 消息队列'组合:服务本地批处理写入成功后,发送领域事件,保证异步业务流程。同时在连接池配置上为批处理任务预留独立的 HikariCP 连接池,实现资源隔离。"
知识速查表
| 关键概念 | 描述 |
|---|---|
| addBatch / executeBatch | JDBC 批处理核心 API,将多次写入请求一次提交,减少网络往返。 |
| 返回值 int[] | 0 = 成功 0 行影响;Statement.EXECUTE_FAILED (-3) = 执行失败;需逐一检查。 |
| BatchUpdateException | 批处理部分失败时抛出,内含已完成命令的更新计数数组。 |
| PG reWriteBatchedInserts | true 时将多条 INSERT 重写为单条多值 INSERT。仅对 INSERT 有效。 |
| MySQL rewriteBatchedStatements | true 时对 INSERT 重写为多值,对 UPDATE/DELETE 拼为复合语句。 |
| 批处理大小选择 | 1000~5000条/批为生产推荐;<100效果不明显;>10000 易 OOM 或超 max_allowed_packet。 |
| MyBatis BatchExecutor | 在事务提交时自动刷新批处理,底层复用 JDBC 批处理及驱动重写。 |
| 部分失败策略 | PG 默认失败后停止;MySQL 可通过 continueBatchOnError 继续,但均需检查返回数组并补偿。 |
| JMH 测试要点 | 关注 TPS、内存、GC;预热充分;每批之间清理数据,保持测试状态一致性。 |
延伸阅读
- PostgreSQL JDBC 官方文档(连接参数章节):PostgreSQL JDBC Driver
- MySQL Connector/J 配置属性(
rewriteBatchedStatements):MySQL Connector/J 8.0 Developer Guide - JDBC 4.2 Specification (
BatchUpdateException,executeBatch):JSR 221
本文完整拆解了 JDBC 批处理底层内核与驱动层 SQL 重写的优化精髓,至此,你已具备将高频写入性能推向极致的能力。结合前文的预编译优化,你已经掌握了 JDBC 性能调优中最核心的两把利器。在下一篇文章中,我们将继续深入探讨连接池内核,剖析 HikariCP 等如何以极低成本管理数据库连接的魔法。