结论先给:你这套场景下 batch size 的稳态区间是 50–200 行,而不是「越大越好」;死锁根因不是 batch 行数,而是「并发实例数 × 单事务持锁时长 × PK 区间重叠度」三者的乘积。下面按你 4 个问题逐条给依据和可执行参数。
1) batch size 的决策依据(不是凭感觉)
batch size 同时影响 4 个量,把它当作一个旋钮去调,判据是这 3 条同时满足:
- 单 batch 平均耗时 < 50ms(你 100 行 ~30ms 已达标,500 行 140ms 超标);
- 死锁率 < 1e-5/batch(你的偶发 1213 大约在这个量级,可接受阈值是每 10 万 batch ≤1 次);
- 锁等待 P99 < 50ms(innodb_lock_waits 里 200ms+ 的等待就是 P99 毛刺的来源)。
落到经验区间:
- 50 行:单 batch ~15–25ms,死锁面最小,吞吐偏低(你 60% 那个档位);
- 100–200 行:单 batch ~30–60ms,死锁率与吞吐的甜蜜点,推荐默认值 150;
- >300 行:单事务持锁时间拉长,gap lock 覆盖范围扩大,相邻区间实例撞锁概率非线性上升(InnoDB 死锁检测是 O(N²),N 是参与事务数,所以 batch 越大、参与锁越多,检测本身也越贵)。
关键认知:batch size 不是按「innodb 页大小」定的(那是 buffer pool 命中率的事),也不是按「主键顺序」定的(那是排序成本的事),而是按 单事务持锁时长 × 并发写实例数 的乘积定的。你 8 实例写相邻区间,本质上是 8 个事务在 gap lock 上排队,batch 越大每个事务的持锁窗口越长,排队越严重。
2) 三种方案的适用边界
A. 按 PK 区间分片到固定实例(推荐)
- 做法:把 metric_id 空间切成 8 段(或 16 段),每段只被 1 个实例写,Kafka 侧用
metric_id % 8做路由(或 consumer 侧 filter)。 - 收益:实例间零锁竞争,死锁率→0,单实例可以放开 batch 到 300–500 行而不撞锁。
- 风险:实例扩缩容时要重新分片;如果 metric_id 分布偏斜(某些区间写多),需要按 QPS 加权而不是等分。
- 这是唯一能根治的方案,其它都是缓解。
B. 关死锁检测 + 超时(不推荐长期用)
innodb_deadlock_detect=OFF省了 O(N²) 检测开销,但代价是:死锁不再被主动回滚,而是所有参与事务都等到innodb_lock_wait_timeout才报错(你设 3s,意味着 P99 直接 +3s)。- 适用边界:只在「死锁率 < 1e-6 且单次死锁代价极高」的场景短期救火,比如大促前压测。
- 你的现象里「锁等待变长、P99 更差」正是这个副作用,不要长期关。
C. 整批重放(反模式)
- 问题不只是写放大,还有幂等性:upsert 的 affected_rows 语义是 1=插入、2=更新、0=无变化,整批重放时已经 update 过的行会被再 update 一次,如果并发窗口内有别的实例也在写同一 PK,会出现「旧值覆盖新值」。
- 见第 3 点替代方案。
3) 细粒度重放:用 affected_rows + 行级状态机
不要整批重放,改成:
// 伪代码:单 batch 内逐行跟踪状态
List<MetricRow> batch = ...; // 100~200 行
Connection conn = open();
conn.setAutoCommit(false);
try {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO t_metric (metric_id, ts, v1..v60) VALUES (?,?,...) " +
"ON DUPLICATE KEY UPDATE v1=VALUES(v1), ..., v60=VALUES(v60)");
for (MetricRow r : batch) {
fillParams(ps, r);
ps.addBatch();
}
int[] affected = ps.executeBatch();
// affected[i]: 1=insert, 2=update, 0=no change(都算成功)
conn.commit();
} catch (SQLTransientException e) {
conn.rollback();
// 关键:只重试「还没 commit 的」行
// 因为整批是单事务,rollback 后所有行都回到未写入状态
// 所以这里「整批重放」反而是对的,但要带退避 + 限次
retryWithBackoff(batch, maxRetry=3, baseDelayMs=50);
}
要点:
- 单事务整批(你现在的做法)在 rollback 语义下就是原子的,「重放」不会造成部分行被 update 两次——只要你不把 commit 拆到 batch 中间。
- 真正要避免的是:把大 batch 拆成多个小事务 commit,那样失败时已 commit 的行就回不来了,重放才会出现写放大。
- 如果确实要拆(比如 batch 太大怕超时),用「先查 PK 存在性再决定 insert/update」代替 upsert,代价是多一次 SELECT,但避免重复 update。
退避参数建议:baseDelay=50ms, factor=2, maxRetry=3(即 50/100/200ms),总窗口 < 500ms,正好卡在你的 P99 目标内。
4) innodb 参数 vs 上层攒批
你这台 12C48G 单主库,建议只动 2 个参数,其它保持默认:
# 1) redo log 放大:你的 batch 写入是 redo 密集型,默认 48MB×2 太小
innodb_log_file_size = 2G # 8.0.30+ 用 innodb_redo_log_capacity=4G
innodb_log_buffer_size = 64M
# 2) 死锁检测保持开启(不要关)
innodb_deadlock_detect = ON
innodb_lock_wait_timeout = 5 # 别设 3s,太短会误杀正常等待
# 3) 这两个保持 1,金融/账务场景不要动
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
# 4) buffer pool 按 70% 内存
innodb_buffer_pool_size = 32G
不建议动:unique_checks/foreign_key_checks 关掉的 15% 收益不值得引入数据正确性风险;innodb_flush_log_at_trx_commit=2 在你「不丢数据」目标下是禁忌。
上层做对 3 件事比调参收益大 10 倍:
- 限流:Kafka 消费端加 token bucket,单实例 500 batch/s 封顶(8 实例 × 500 = 4000 正好是你的峰值,别让它超过);
- 背压:batch 耗时 P99 > 100ms 时主动 sleep 10ms,把压力还给 Kafka(Kafka 天然有 offset 缓冲);
- 分区亲和:用
metric_id % 8路由,让每个实例只写自己的 PK 段(见第 2 点方案 A)。
给你的落地清单(按优先级)
- 把 batch 降到 150 行(从 500 降下来);
- 加
metric_id % 8路由 + consumer 侧 filter,实例间零 PK 重叠; - 死锁重试改退避(50/100/200ms,3 次),不要整批重放超过 3 次,超过就告警 + 进 DLQ;
innodb_log_file_size调到 2G,重启一次(8.0.30+ 用innodb_redo_log_capacity);- 上线后观测 3 个指标:
Innodb_row_lock_waits、Innodb_deadlocks、batch 耗时 P99,跑 7 天看趋势。
预期收益:死锁率从「偶发」→「<1e-6/batch」,P99 从当前毛刺(200ms+)→ 稳定 < 100ms,吞吐回到峰值 90%+。
补充一点方法论:你这个问题本质是**「单事务持锁时长」与「并发写实例数」的乘积失控**,不是 MySQL 调优问题,是写入拓扑问题。调参是治标,PK 分片 + 限流才是治本。这套思路也适用于你后面如果碰到「多实例写同一张表」的任何场景。
从 SRE 侧补一个常被漏掉的细节:第 3 点「退避 50/100/200ms × 3 次」的窗口 <500ms 在单实例视角很干净,但 8 实例同时撞锁时会出现「重试雪崩」——T0 全部 fail、T0+50ms 全部重试、T0+100ms 又撞,三次重试恰好把死锁从「偶发」放大成「连续 3 轮 1213」。建议退避里加指数退避 + 抖动(jitter = random(0, baseDelay)),或者干脆按实例序号错开(instance_i 的 baseDelay = 50 + i×10ms),把 8 个实例的重试时刻打散。另外「Innodb_deadlocks」这个 SHOW STATUS 计数器是累计值,观测时务必取两次的差值 / 时间窗口换算成 rate,直接看绝对值会误导——我见过一次「死锁率涨了 300%」的告警,其实是新加了一块日志,rate 反而从 5/min 掉到 0.3/min。最后强烈认同「治本是 PK 分片」,我们去年一次类似的事故就是 12 个实例写同一张 t_event 表,改成 event_id % 12 分区路由后 1213 归零,比调 innodb 参数省事 10 倍。