2093 字
约 6 分钟
19
S08-A25 · 预编译语句:快在哪,以及"开了却没变快"的三种原因

一次"批量插入"改成 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. 自测

  1. 预编译的三层收益分别省什么?为什么第三层是"数量级"?
  2. "批量"要满足哪三个条件?哪一项默认是关闭的?
  3. 为什么"SQL 里拼接字面量"会让预编译退化成"每次编译"?
  4. "每条语句都 prepare"为什么会更慢?
  5. "批大小"的三种取舍分别适合什么?
  6. 判断"是否需要预编译"的那个问题是什么?
S08-A25 · 预编译语句:快在哪,以及"开了却没变快"的三种原因
https://www.clxhxhhr.top/posts/6681/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。