"批量改成一次一万条,结果更慢"是这类优化的典型第一轮结果。通常不是"批量没用",而是三个上限同时到顶:请求体积、事务锁范围、客户端缓冲与 GC。批量不是越大越好,是在很窄的区间里取极值。
1. 三个上限,三个下限
| 方向 | 具体约束 | 到顶后的表现 |
|---|---|---|
| 上限 | SQL 长度 / packet 大小 | PacketTooBigException 或语句截断 |
| 上限 | 事务大小与锁持有 | 锁等待堆积、间隙锁变大、死锁上升 |
| 上限 | 客户端内存与 GC | 年轻代堆积,GC 抖动 |
| 下限 | 网络往返次数 | 每批一次 RTT,吞吐被 RTT 锁死 |
| 下限 | SQL 解析与计划开销 | 太小则解析占比过高 |
| 下限 | 日志与刷盘次数 | 每事务一次 redo/undo 落地 |
只看一个信号:加大批量后单行平均耗时是否继续下降。继续降说明还在下限区;不再降而 GC 或锁等待上升,说明越过上限。
2. rewriteBatchedStatements 决定成败
同一个 executeBatch(),开与不开是两条完全不同的执行路径:
# MySQL Connector/J:不开时 addBatch 只是客户端排队,逐条发送
jdbc:mysql://db:3306/app?rewriteBatchedStatements=true&useServerPrepStmts=false
| 开关状态 | 实际发出的语句 | 效果 |
|---|---|---|
false(默认) |
N 条独立 INSERT,逐条往返 | 只省 API 调用,不省网络与解析 |
true |
拼成多值 INSERT ... VALUES (...),(...) |
往返 N 次降到 1 次,量级变化 |
true + 服务端预编译 |
取决于驱动版本支持 | 需实测,不能假设 |
这解释了"改批量没效果":代码从 for 改成 addBatch,连接串没改,执行路径一模一样。先确认抓到的是多值语句,再谈批大小。
3. 批大小的调法与拐点判据
不要凭经验值拍一个数。做一轮阶梯测试,每档记录三个量:
long t0 = System.nanoTime();
for (int i = 0; i < rows.size(); i++) {
ps.setObject(1, rows.get(i).id());
ps.addBatch();
if ((i + 1) % batchSize == 0) ps.executeBatch(); // 分档:100/500/1000/5000
}
long ms = (System.nanoTime() - t0) / 1_000_000;
| 观测量 | 含义 | 拐点信号 |
|---|---|---|
| 每批耗时 / 行数 | 单行摊薄成本 | 不再下降即到平台 |
| 年轻代回收频率 | 批内对象压力 | 变密说明上限在内存 |
| 锁等待 / 死锁计数 | 事务锁范围 | 上升说明事务粒度太大 |
典型量级(按库、行宽、索引数核对,不要照抄):单批数百到一两千行常处在平台区;行宽大、索引多、有触发器的表明显偏低。判据是曲线形状,不是数字。
4. 事务粒度与批大小是两个旋钮
这两件事经常被当成一个,实际必须分开调:
| 组合 | 语义 | 适用 | 风险 |
|---|---|---|---|
| 一事务一批 | 批 = 事务边界,整批回滚 | 行间无依赖、可整批重做 | 批越大锁越久 |
| 一事务多批 | 大事务含多次 executeBatch |
需跨批原子 | 锁范围与 undo 都放大 |
| 每批自动提交 | 批 = 提交点 | 日志类、可丢可重 | 失败点分散,需幂等 |
| 分阶段提交 | 显式分组并记进度 | 大批量迁移 | 需断点续跑 |
判据:失败重做的单位是什么。能整批重做就一事务一批;必须跨批原子,就得接受更大的锁范围,用小批换更短持有时间。
5. 失败重试:批内部分成功怎么办
executeBatch() 抛异常时,语义取决于驱动与数据库:可能整批回滚,也可能部分已提交。三种处理方式的代价完全不同:
| 方式 | 做法 | 代价 |
|---|---|---|
| 整批重试 | 回滚后重发同一批 | 要求幂等,否则产生重复 |
| 逐条重试 | 按失败下标只重发失败行 | 需 BatchUpdateException.getUpdateCounts() |
| 降级单条 | 拆成单条逐条执行 | 吞吐骤降,能精确归因 |
try {
ps.executeBatch();
} catch (BatchUpdateException e) {
int[] counts = e.getUpdateCounts(); // 长度可能小于批大小
for (int i = 0; i < counts.length; i++) {
if (counts[i] == Statement.EXECUTE_FAILED) retryQueue.add(rows.get(i));
}
}
getUpdateCounts() 的长度本身就是信息:短于批大小时后面行状态未知,不能假设它们失败。安全做法是把剩余行按幂等键重放。
6. 自增主键回填与批量插入的冲突
单条插入靠 getGeneratedKeys() 拿自增 ID;批量插入时,这件事在两个层面同时变难:
| 情形 | 行为 | 后果 |
|---|---|---|
| 未开 rewrite | 逐条执行,键可逐条返回 | 可用,无吞吐收益 |
| 开了 rewrite(多值) | 可能只返回首批或全部键 | 键与行的对应要核对 |
多值 + ON DUPLICATE KEY UPDATE |
命中更新不自增,序列出空洞 | 按返回顺序对行会错位 |
// 需要回填时先确认驱动实际返回了多少个键,不要假设等于批大小
ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
ps.executeBatch();
ResultSet keys = ps.getGeneratedKeys();
int n = 0; while (keys.next()) n++;
if (n != rows.size()) { /* 走业务主键或二次查询回填 */ }
需要"插入后立刻用 ID 关联"的场景,更稳的做法是客户端生成主键(号段、雪花类),把回填问题从 JDBC 层挪走。
7. 锁与并发:大事务的代价不在自己身上
| 现象 | 机制 | 缓解 |
|---|---|---|
| 间隙锁范围大 | 范围条件 + 大事务,锁住不存在的区间 | 缩小批、主键等值、按业务核对隔离级别 |
| 唯一索引死锁 | 两批以不同顺序插入同一唯一键集合 | 批内按唯一键排序 |
| 从库延迟飙升 | 单事务 row 事件过多,回放串行 | 拆事务,控制单事务行数 |
| 连接被长期占用 | 大事务 + 长批,池被少数线程吃满 | 批间让出、限制并发批数 |
把批从一万降到一千往往不是"吞吐换稳定",而是两者同时变好:锁范围小、从库跟得上、连接更快归还。
8. 内存与游标:读写两头都不能一次拉全
写侧的批大小受内存约束,读侧的大结果集同理,而且更容易被忽略:
// MySQL:流式读取,避免把整个结果集塞进客户端内存
stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE); // MySQL 的流式约定;其他库按各自 API 核对
| 组合 | 内存 | 风险 |
|---|---|---|
| 全量 fetch + 逐条写 | 峰值 = 结果集大小 | OOM,或长时间持快照 |
| 流式读 + 逐条写 | 峰值 ≈ 单行 | 连接久占,长事务影响 purge |
| 流式读 + 分批写 | 峰值 ≈ 批大小 | 需处理并发改动 |
"读大结果集再批量写"的瓶颈常在读侧长事务:undo 无法回收,主从延迟持续累积。
9. 批量删除与归档:分批 + 主键范围
-- 按主键区间循环,每轮一个事务;不要用 LIMIT 无排序删除
DELETE FROM event_log WHERE id > ? AND id <= ? AND created_at < ? LIMIT 1000;
| 做法 | 问题 | 结论 |
|---|---|---|
DELETE ... WHERE created_at < ? |
单事务删百万行,锁与 undo 爆炸 | 不可用 |
LIMIT + 无排序 |
扫描起点不定,越删越慢 | 需主键/索引驱动 |
| 主键区间递增 | 可断点续跑、可限速 | 推荐 |
| 分区表 drop partition | 最快,要求按时间分区 | 归档首选 |
这里的判据是主从延迟:每批提交后看一眼延迟,超阈值就降批或加 sleep。删除批通常应小于插入批——删除还要维护索引与 undo。
10. 批量与幂等:重跑一批的后果
| 幂等基础 | 重跑一批的结果 | 额外成本 |
|---|---|---|
| 无唯一约束 | 产生重复行 | 事后无法自动修复 |
| 唯一索引 + 普通 INSERT | 整批可能因一行冲突失败 | 需逐条降级 |
唯一索引 + ON DUPLICATE KEY UPDATE |
幂等,更新也计入写入 | 从库延迟升高 |
| 批次号 + 幂等表 | 幂等且可审计 | 多一次写、多一层状态 |
判据:这批能不能重放。不能重放的批,重试必须是"先定位失败行再补",而不是整批再来。批越大,一行脏数据毁掉整批的概率越高。
11. 度量:三个数就够定位
| 指标 | 采集方式 | 判据 |
|---|---|---|
| 每批耗时分布 | 批前后打点,记 P50/P99 | P99 远高于 P50 = 锁等待或 GC |
| 失败重试率 | 重试行数 / 总行数 | 持续 > 0 = 约束或数据质量 |
| 从库延迟峰值 | SHOW REPLICA STATUS |
与批提交时刻对齐看 |
再加一条:每批实际影响行数。与配置批大小不一致时,说明有过滤或冲突被忽略。
12. 排错:五种"批量化之后更慢"
| 现象 | 原因 | 处理 |
|---|---|---|
| 改批量后无变化 | rewriteBatchedStatements 未开,仍是逐条发送 |
抓 SQL 确认是多值语句 |
| 加大批量后更慢 | 越过内存上限,GC 或 packet 报错 | 回退到拐点以下,分档重测 |
PacketTooBigException |
单批字节数超过 max_allowed_packet |
降批或提高上限(按实例核对) |
| 偶发死锁 | 批内唯一键顺序不一致 | 批内按唯一键排序 |
| 从库延迟持续攀升 | 单事务 row 事件过多 | 拆事务、降批、限并发 |
13. 边界:这些场景批量收益有限
| 场景 | 结论 | 理由 |
|---|---|---|
| 日增量很小(数百行) | 优化批大小无意义 | 瓶颈在别处 |
| 单条写入本就是毫秒级且并发足够 | 批量化收益被并发掩盖 | 先看连接池与锁 |
| 表上有重触发器或复杂外键级联 | 每行成本主导,批只省往返 | 收益上限很低 |
| 需要逐行返回结果的写入 | 批量破坏语义 | 改用异步或队列 |
| 写入目标是不支持多值语句的库 | rewrite 无效 | 只能靠并发或分区 |
批量化针对的是往返与解析开销。成本主要来自行级工作(触发器、级联、索引维护)时,它解决不了问题——该减行级工作,或换写入路径。
14. 自测
- 改成
addBatch后性能没变,你先检查什么? - 加大批量后单行成本不再下降、GC 变密,说明卡在哪个上限?
- "一事务一批"与"一事务多批"分别把什么变大?
getUpdateCounts()返回长度短于批大小,剩余行的状态是什么?- 为什么批量插入 +
ON DUPLICATE KEY UPDATE时主键回填会错位? - 删除归档的批为什么通常应小于插入的批?