一次"批量插入"改成 PreparedStatement + addBatch,期望"快一个数量级",实际只快了 8%。查下来两个原因:没开 rewriteBatchedStatements(于是"批量"被拆成"逐条")、以及 SQL 里有"每次都不一样的字面量"(于是"语句无法复用")。
"预编译更快"这句话,成立的前提是"同一条 SQL 被执行很多次"。而"快在哪"有三层:解析(省掉语法分析与优化)、网络(省掉重复传输 SQL 文本)、批量(把 N 条合成 1 次往返)——第三层收益最大,而它需要"显式开启".
1. 三层收益的机制
| 层 | 省什么 |
|---|---|
| 解析/优化 | 服务端"编译一次" |
| 传输 | 重复的 SQL 文本 |
| 批量往返 | N 次 RTT → 1 次 |
第三项是"数量级"的:**"1000 条 insert"从"1000 次往返"变"1 次"**第三项的"量级"可以算:RTT 0.5ms × 1000 = 500ms——而"1 次往返"只要 0.5ms;于是"批量"的收益与"RTT"成正比,在"跨机房"时尤其明显.
——而它的开关不在 PreparedStatement 本身.
"批量是否生效"用两个地方确认,而不必"猜":
// 两种"证据"
1 general_log / 驱动日志:看到"1 条 insert ... values (...),(...),(...)"
2 耗时:1000 条从"秒级"变"几十毫秒"(RTT 跨机房时更明显)
// 判据:日志里仍是"1000 条独立 insert" → rewriteBatched 没生效
2. "批量"要满足的三个条件
| 条件 | 说明 |
|---|---|
用 addBatch + executeBatch |
不是循环 execute |
连接串开 rewriteBatchedStatements=true |
MySQL 驱动特有 |
| SQL 形态一致(仅参数不同) | 否则无法合并 |
第二项的"症状"很典型:"代码用了 addBatch,而 general_log 里看到"1000 条独立 insert"——日志是判断"批量是否生效"的唯一可靠证据.
第二项是"最常漏"的:默认关闭——于是"你写了 addBatch,驱动仍逐条发送".
"语句复用"要看"执行计划缓存",而不是"代码里有没有 prepare":
-- 两条命令(MySQL,看"计划复用")
SHOW GLOBAL STATUS LIKE 'Com_stmt_prepare'; -- 准备次数
SHOW GLOBAL STATUS LIKE 'Com_stmt_execute'; -- 执行次数
-- 判据:execute 远大于 prepare → 复用在生效;两者接近 → 每次都在 prepare
3. "开了却没变快"的三种原因
| 原因 | 表现 |
|---|---|
未开 rewriteBatched |
批量无效(逐条) |
| SQL 拼接了字面量 | 无法复用(每次新语句) |
| 事务边界太大/太小 | 提交次数拖慢 |
第二项很常见:**"insert into t values (" + id + ")"**第二项的"伪装"是"看起来用了 PreparedStatement":SQL 字符串里"拼了值"——于是"每次文本不同",服务端无法"复用执行计划".
这样的拼接,会让"每次 SQL 文本都不同"——**于是"预编译"退化成"每次都编译".
"批大小"与"失败重试"要一起设计,否则"批量"会放大故障:
// 三段式:分批 + 幂等 + 失败只重跑该批
for (var chunk : partition(rows, 500)) {
try (var ps = conn.prepareStatement(INSERT)) {
for (var r : chunk) { ps.setX(...); ps.addBatch(); }
ps.executeBatch(); // 一批一次往返
} catch (SQLException e) {
// 记下 chunk 的"范围",重试这一批(配合唯一键幂等)
}
}
// 判据:任一批失败,"已成功的批"不受影响
4. "预编译"的三种"假优化"
| 假优化 | 问题 |
|---|---|
每条语句都 prepare |
开销更大(一次编译一次执行) |
用 Statement 但"SQL 缓存" |
依赖驱动实现 |
| 只批处理"小批量"(10 条) | 提升有限 |
第一项的"边界"是"一次准备、多次执行"才划算:"一次准备、一次执行"时,PreparedStatement 多一次往返(PREPARE + EXECUTE).
第一项最反直觉:"准备"本身有成本——"一条语句只执行一次"时,PreparedStatement 比 Statement 更慢.
5. "批大小"的三个取舍
| 批大小 | 影响 |
|---|---|
| 10–100 | 安全(回滚代价小) |
| 500–1000 | 吞吐好(多数场景) |
| 5000+ | 事务大、锁久、失败重试贵 |
第二项是"常见最优":**它把"往返次数"降到"可忽略",同时"单次事务"还在"秒级".
"批内顺序"影响死锁,而它容易被"并行生成"打乱:
// 两种写法(同一批插入)
乱序:由多线程生成 → id 顺序随机 → 加锁顺序随机 → 死锁概率上升
有序:先排序再批量 → 加锁顺序一致 → 死锁概率下降
// 判据:批量后"死锁"变多,先查"批内是否按主键/唯一键排序"
6. 排错:五种"预编译问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 批量没变快 | 未开 rewrite | 开参数 |
| 偶发"SQL 语法错" | 拼接字面量 | 用占位符 |
| 内存涨 | 批太大 | 降批或分批提交 |
| 死锁变多 | 批内顺序不一致 | 统一排序 |
| 单条反而慢 | 每条都在 prepare | 复用/改 Statement |
"服务端预编译"与"客户端预编译"的差别也值得知道:
// 两种"预编译"(MySQL)
服务端:useServerPrepStmts=true → 真正"在服务端保存执行计划"
客户端:默认 → 客户端"拼接占位符后发送"(省了"解析"但没省"往返")
// 判据:想省"解析"就要服务端预编译;只想"防注入/批量",客户端够
7. 边界:什么情况"不必预编译"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性 DDL/报表 SQL | 只执行一次 | Statement 即可 |
| 极简查询(无参数) | 无复用 | Statement |
| 已有"ORM 批处理" | 框架已封装 | 跟随 |
| 低频接口(QPS < 1) | 解析成本可忽略 | 不必优化 |
最后一条与"安全"有关的边界:"预编译"顺带解决了"SQL 注入"——但"拼接字面量"的写法把"注入面"又打开了;所以"为了安全"也该用占位符,而不是"为了性能".
判断口径一句话:"这条 SQL 会不会被"用不同参数执行很多次"**。会(批量写、热点查询),预编译 + 批量是数量级收益;不会(一次性脚本、低频接口),它只是"多一点复杂度".
再补一条"框架"经验:很多 ORM 默认"不批处理"——它们"逐条提交",除非你显式开"batch"或"jdbc batch"。于是"换了 ORM 却发现批量变慢"很常见,而"代码看起来一模一样"。判据还是那两条:驱动日志里是不是"一条多值 insert",以及 Com_stmt_execute 与 Com_stmt_prepare 的比值。
而这两条,都不需要"读框架源码"就能拿到。
最后一个"决策"提醒:"预编译"这件事的收益是"分层的"——"防注入"几乎总是值得;"省解析"在"高频低延迟"时值得;"批量往返"在"批量写"时最值。所以"要不要上"不是"是/否",而是"你要哪一层收益"。而"要批量往返"就必须"开 rewriteBatchedStatements + 用 addBatch",两者缺一,"收益"就不会发生——这也是"改了没变快"最常见的两个原因。
而"分层"这个视角,也能解释"为什么有人说'预编译没用'":他只在"一次性查询"上试过——那一层本来就没有收益。
所以"没用的结论",往往只是"用错了层"。
而分清"层",比记住"参数名"更重要。
这句话也适用于所有"性能参数":先问"它省的是哪一段",再问"我这段是不是瓶颈"。
8. 自测
- 预编译的三层收益分别省什么?为什么第三层是"数量级"?
- "批量"要满足哪三个条件?哪一项默认是关闭的?
- 为什么"SQL 里拼接字面量"会让预编译退化成"每次编译"?
- "每条语句都 prepare"为什么会更慢?
- "批大小"的三种取舍分别适合什么?
- 判断"是否需要预编译"的那个问题是什么?