结论先行
你的死锁不是"batch × writer 数"的乘积问题,而是 key 碰撞概率 × 锁持有窗口 的函数。缩 batch 之所以治标不治本,是因为它缩短了锁持有窗口但没降低碰撞概率(key_skew 没变)。真正有效的操作域约束是:让同一个 batch 内不同 key 的锁区间不重叠——这要求 batch 按 key 预排序 + 路由收敛,而不是单纯调 batch size。
1. 定量模型:死锁率 ≈ 碰撞概率 × 锁窗口重叠率
我拆成一个可拟合的三段式:
deadlock_rate ≈ P_collision × P_overlap × (1 - retry_damping)
-
P_collision(同 key 碰撞概率):你的场景是 96 分区 × 24 实例,top 200 key 占 60% 流量。假设 top 200 key 均匀分布在 24 实例,每实例平均持有 8.3 个热 key。单 batch 200 行覆盖 ~200 个 (key, ts) 对,其中热 key 重复出现的概率用生日问题估算:
P_collision ≈ 1 - exp(-n² / (2 × K_effective))其中 n = 单 batch 内热 key 出现次数(你的 200 行里约 120 行落在 top 200),K_effective = 24 × 8.3 ≈ 200 个热 key 槽位。 →
P_collision ≈ 1 - exp(-120² / (2×200)) ≈ 1 - exp(-36) ≈ 1.0这意味着热 key 场景下碰撞几乎是必然事件,死锁率差异主要来自 P_overlap。
-
P_overlap(锁窗口重叠率):两个事务的锁区间在时间轴上的重叠概率。这取决于:
- 单事务持锁时长 T_lock ≈ batch_rows × t_per_row + fsync_time
- 并发 writer 数 N
- 同一 key 被不同事务同时命中的概率
简化为:
P_overlap ≈ min(1, N × T_lock / T_interval),其中 T_interval 是同一 key 两次写入的间隔。你的 3 个数据点反推: | 配置 | N | batch | T_lock(估) | 死锁率 | |------|---|-------|-------------|--------| | 8×200 | 8 | 200 | ~2ms | 0.3% | | 24×200 | 24 | 200 | ~2ms | 4.7% | | 24×50 | 24 | 50 | ~0.5ms | 1.4% |
从 8→24 实例(N×3),死锁率×15.7,说明 α ≈ log(15.7)/log(3) ≈ 2.35,即
deadlock_rate ∝ N^2.35(在 key_skew 不变时)。 从 batch 200→50(T_lock×0.25),死锁率从 4.7→1.4(×0.30),说明对 T_lock 的弹性约 1.2(接近线性略超)。合成经验公式(你的数据域内拟合):
deadlock_rate(%) ≈ 0.003 × N^2.35 × (batch/200)^1.2 × skew_factor其中 skew_factor 对你的 top-200 占 60% 场景 ≈ 1.0(基准)。
安全域约束(< 0.5%):
0.5 / 0.003 ≥ N^2.35 × (batch/200)^1.2 167 ≥ N^2.35 × (batch/200)^1.2- N=12:
12^2.35 ≈ 313→ 已超,batch 必须 < 50 才能压到 0.5% - N=8:
8^2.35 ≈ 153→ batch 可到 ~220 - N=24:
24^2.35 ≈ 1200→ batch 必须 < 25(不可行)
结论:24 实例 + 多行 UPSERT 这个组合在你当前 key_skew 下无解,必须降 N 或改变锁模式。
2. 操作侧:主变量是 writer 数,batch 是辅变量
我线上划定安全域的原则(MySQL 8.0 + InnoDB RR + 多行 UPSERT 场景):
主变量 = 并发 writer 数(N),原因:
- N 对死锁率是超线性(α≈2.35),batch 只是线性(β≈1.2)
- 缩 batch 直接牺牲吞吐(更多 fsync),扩 N 可以靠硬件/分区扩展
- 你的 key_skew 是结构性的(top 200 占 60%),不随 batch 变化
辅变量 = batch size,原则:
- 在 N 确定后,batch 取
min(200, 安全域上限) - 但更重要的是 batch 内按 key 排序(见第 4 节)
实操建议:
- 先把 N 压回 12(96 分区 / 12 实例 = 每实例 8 分区),死锁率预计降到 ~1.2%
- 再按 key hash 分桶写入(见第 4 节),在 N=12 下进一步压到 < 0.3%
- 如果业务要求 24 实例吞吐,走第 4 节的架构改造
3. 检测侧:三个指标组合做「提前 5 分钟」预警
| 指标 | 采集方式 | 预警阈值(你的场景) | 提前量 |
|------|---------|---------------------|--------|
| SHOW ENGINE INNODB STATUS → ROLLBACK 段 delta/min | 每 10s 采集一次,算 1min 增量 | > 50 行/min(你正常 ~200/天 ≈ 0.14/min) | ~3-5 min |
| performance_schema.events_waits_summary_global → wait/lock/storage/lock/table/insert 的 AVG_WAIT | 每 30s 采集 | > 5ms(正常 < 0.1ms) | ~2-3 min |
| 应用侧 retry count / 5s 窗口 | 消费组 metrics 暴露 | > 5 次/5s | 即时 |
推荐告警规则(Prometheus 语法):
- alert: UpsertDeadlockSpiking
expr: |
rate(innodb_rollback_rows[1m]) > 50
and rate(innodb_rollback_rows[5m]) > 20
for: 2m
labels:
severity: warning
- alert: UpsertDeadlockCritical
expr: rate(innodb_rollback_rows[1m]) > 200
for: 1m
labels:
severity: critical
关键:用 rate 而非绝对值,且加 for: 2m 防毛刺。你从 0.3% 到 4.7% 的跳变大约持续 30s+(HPA 扩容后新实例逐步接管分区),2 分钟窗口足够抓住。
4. 架构侧:真正治本的三个方案(按改造成本排序)
方案 A:batch 内按 key 排序 + 路由收敛(改造成本:低)
核心思路:让同一 batch 内锁的获取顺序全局一致,消除 AB-BA 环路。
// 消费端:按 metric_key 排序后再组 batch
List<MetricRow> batch = buffer.drain(200);
batch.sort(Comparator.comparing(MetricRow::getMetricKey)
.thenComparing(MetricRow::getTs));
// 此时同一 batch 内锁获取顺序 = key 字典序 → 全局一致 → 无 AB-BA
配合 key hash 路由(你已试过,但只在实例级做):
- 把 top 200 热 key 单独建一个 topic
metrics-write-hot(8 分区),用 2 个专用消费者处理 - 冷 key 走原来的 96 分区 topic
- 热 key 的并发 writer 从 24 降到 2,碰撞概率骤降
预期效果:N_eff(热 key 的等效 writer 数)从 24 降到 2,按公式死锁率降到 0.003 × 2^2.35 × 1.2 ≈ 0.02%,冷 key 本身碰撞概率极低。
方案 B:INSERT + 定时 MERGE(改造成本:中)
把 UPSERT 拆成两步:
- 写入
t_metrics_staging(纯 INSERT,无唯一索引冲突 → 零死锁) - 每 5s 由一个单线程 MERGE 任务将 staging 数据 upsert 到
t_metrics
-- staging 表:无唯一索引,纯追加
INSERT INTO t_metrics_staging (metric_key, ts, val, batch_id) VALUES ...;
-- MERGE 任务(单线程,无并发竞争)
INSERT INTO t_metrics (metric_key, ts, val)
SELECT metric_key, ts, val FROM t_metrics_staging
WHERE batch_id IN (...)
ON DUPLICATE KEY UPDATE val = VALUES(val);
DELETE FROM t_metrics_staging WHERE batch_id IN (...);
代价:读路径要 UNION staging(或接受 5s 延迟),单线程 MERGE 有吞吐上限(~20k 行/s 单核)。适合你的 15k msg/s 峰值。
方案 C:换写入模式 → INSERT IGNORE + 读时 COALESCE(改造成本:高)
- 写入只 INSERT IGNORE(不触发 UPDATE 路径的 record lock,只触发 insert intention lock)
- 同一 (key, ts) 的重复值在读取时取 MAX(val) 或 LATEST(val)
- 需要加一个后台去重任务
代价:语义变了(从"最后写入赢"变成"读时聚合"),业务方要接受。
5. 你问的「12 实例 × batch 100」预测
按我的拟合公式:
0.003 × 12^2.35 × (100/200)^1.2 × 1.0
= 0.003 × 313 × 0.435
≈ 0.41%
预测:0.35% ~ 0.5%,刚好在安全域边缘。如果你同时做方案 A(batch 内排序),预计压到 0.15% ~ 0.25%,可以接受。
6. 一句话操作建议
本周就做:batch 内按 metric_key 排序(5 行代码)+ 把 N 从 24 压到 12 + 加 ROLLBACK rate 告警。这三件事组合后死锁率应该回到 0.3% 以下,且吞吐损失 < 10%(因为 batch 不缩,只是排序)。
下周评估:热 key 拆 topic(方案 A 完整版),把 top 200 key 的并发 writer 从 12 降到 2,彻底消除碰撞。