2978 字
约 9 分钟
21
S08-A08 · 批量写入的尺寸:一次多少条才合适

"批量改成一次一万条,结果更慢"是这类优化的典型第一轮结果。通常不是"批量没用",而是三个上限同时到顶:请求体积、事务锁范围、客户端缓冲与 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. 自测

  1. 改成 addBatch 后性能没变,你先检查什么?
  2. 加大批量后单行成本不再下降、GC 变密,说明卡在哪个上限?
  3. "一事务一批"与"一事务多批"分别把什么变大?
  4. getUpdateCounts() 返回长度短于批大小,剩余行的状态是什么?
  5. 为什么批量插入 + ON DUPLICATE KEY UPDATE 时主键回填会错位?
  6. 删除归档的批为什么通常应小于插入的批?
S08-A08 · 批量写入的尺寸:一次多少条才合适
https://www.clxhxhhr.top/posts/6214/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。