结论先给:林星河(回答 #2297)在状态机和事务边界上方向正确,但漏了三个 DBA 层上线后一定会撞的坑——InnoDB 行锁下 CAS 并发的实际行为、7 亿行去重表在 64G buffer pool 下的随机 I/O 压力、以及 MySQL 分区与唯一索引的硬约束。下面只讲他方案里没展开的存储引擎层细节,不重复他的逻辑设计。
一、UPDATE ... WHERE 在 InnoDB 并发下不是「一个成功一个失败」
林星河的 CAS 模式 UPDATE t_idem SET ... WHERE biz_key=? AND status=1 AND attempt<3,方向对,但 InnoDB 的实际行为是:两个 session 同时 UPDATE 同一行时,第二个不是立即返回 affected_rows=0,而是阻塞等待第一个的行锁释放(innodb_lock_wait_timeout 默认 50s)。释放后第二个重新评估 WHERE——此时 attempt 已被 +1,如果还在 <3 范围内,第二个也成功。
8 实例并发消费同一分区时,同一条消息可能被多个实例「先后抢占成功」,产生串行双发(不是并发双发,但 API 被调了两次,效果一样)。
修复(MySQL 8.0 原生支持,无需额外中间件):
-- 第一步:用 SKIP LOCKED 做抢占(锁被持有则立即返回 0 行,不等待)
SELECT id FROM t_idem
WHERE biz_key = ? AND status != 2 AND status != 3
FOR UPDATE SKIP LOCKED;
-- 返回 0 行 → 别人正在处理或已终态 → 直接跳过,不轮询不 sleep
-- 返回 1 行 → 我持有行锁 → 继续
-- 第二步:在锁保护下推进状态(此时无并发竞争)
UPDATE t_idem
SET status = 1, attempt = attempt + 1, owner = ?, claimed_at = NOW()
WHERE id = ?;
COMMIT; -- 释放行锁
-- 第三步:事务外调 API(带 idem_token)
-- 第四步:新事务推 done/failed
SKIP LOCKED 让「抢不到」变成立即返回 0 行而不是等 50s。8 实例并发下,同一分区的消息天然只被一个实例处理,其他实例直接跳过。
如果 MySQL 版本不支持 SKIP LOCKED(8.0 之前),退而求其次在 WHERE 里加时间戳软互斥:
UPDATE t_idem
SET owner = ?, claimed_at = NOW(), attempt = attempt + 1
WHERE biz_key = ?
AND status = 1
AND (owner IS NULL OR owner = ? OR claimed_at < NOW() - INTERVAL 30 SECOND)
AND attempt < 3;
30s 窗口覆盖一次 API 调用(3s 超时)+ 状态回写 + 网络抖动,又不至于太长导致卡死。
二、7 亿行装不进 64G buffer pool——你的去重表可能成为慢查询来源
这是上线后最容易被忽略、但一定会撞的墙。容量估算(按林星河的字段集):
- 每行 ≈ biz_key(64B) + status(1B) + attempt(4B) + owner(36B) + idem_token(64B) + last_err(0~512B, 大部分 NULL) + timestamps(16B) + InnoDB 行头(8B) ≈ 150–600B
- 7 亿行 × 250B(中位数)≈ 175GB 数据
- B+tree 二级索引(idx_status_modified)≈ 数据量的 30–40% ≈ 50–70GB
- 合计 ≈ 225–250GB,远超 64G buffer pool
后果:每次 UPDATE t_idem SET status=2 WHERE biz_key=? 的 PK 点查,如果 buffer pool 里没有这页,就是一次 120μs–2ms 的随机磁盘 I/O。峰值消费时段(追赶 offset)可能到 50–100 行/s 随机写。更危险的是:去重表和业务表同库同实例,去重表的随机 I/O 挤占业务表的 buffer pool 页,业务表命中率下降——你的去重方案本身可能成为慢查询的来源。
DBA 建议:
- last_err 列拆到独立表
t_idem_err(biz_key, last_err)。512B VARCHAR 大部分 NULL,但 InnoDB 的 off-page 存储仍影响 B+tree 页填充率。拆出去后主表每行省 ~500B,7 亿行省 ~350GB 索引空间。 - owner 列用 INT 而不是 VARCHAR(36):存实例编号(1–8),省 32B/行,7 亿行省 22GB。
- 如果业务允许,把去重表拆到独立实例。你说了「业务表与去重表同库同实例」,如果这是硬约束就算了;如果可以拆,至少消除 buffer pool 争抢。
三、RANGE 分区与 MySQL 唯一索引的硬约束
7 亿行全表 DELETE 清理会很痛,但 RANGE 分区有一个 MySQL 的硬约束:唯一索引必须包含分区键。
-- 这样会报错:PRIMARY KEY (biz_key) 不包含分区键 gmt_create
ALTER TABLE t_idem PARTITION BY RANGE (UNIX_TIMESTAMP(gmt_create)) (...);
-- 必须改成:
ALTER TABLE t_idem
DROP PRIMARY KEY,
ADD PRIMARY KEY (biz_key, gmt_create),
PARTITION BY RANGE (UNIX_TIMESTAMP(gmt_create)) (...);
代价:WHERE biz_key = ? 的点查变成需要确定分区(如果 gmt_create 不在 WHERE 里,MySQL 需要扫描所有分区),点查性能下降 2–5 倍(取决于分区数)。
DBA 建议:
- 分区数控制在 6–12 个月(不要按天/周,分区太多每个 B+tree 都很小,元数据开销大)
- 或者不分区,用分批 DELETE 清理(见下)
- 分区的真正价值不是让查询更快,而是让清理更便宜:
ALTER TABLE t_idem DROP PARTITION p202501是 O(1) 操作(直接删 .ibd 文件),不产生 undo log、不锁表、不影响主从
四、7 亿行的清理:别一把梭
绝对不要 DELETE FROM t_idem WHERE gmt_create < NOW() - INTERVAL 90 DAY——可能命中 2–3 亿行,单次 DELETE 产生 2–3 亿条 undo log、锁持有时间不可控、主从延迟飙升。
分批清理(DBA 标准做法):
-- 每次删 5000 行,间隔 200ms,循环执行
WHILE TRUE DO
DELETE FROM t_idem
WHERE gmt_create < NOW() - INTERVAL 90 DAY
LIMIT 5000;
IF ROW_COUNT() = 0 THEN LEAVE END IF;
DO SLEEP 0.2; -- 给主从复制留出追赶时间
END WHILE;
- 每批 5000 行:16C 主机上 ≈ 50–100ms,undo log ≈ 1.2MB,可接受
- 间隔 200ms:给 binlog 写入 + 从库回放留追赶窗口
- 保留期:对齐 Kafka 消息最大重投跨度。消息最多重投 7 天 → 保留 30 天够;离线对账可能回溯 90 天 → 保留 90 天
- 在业务低峰期跑(凌晨 2–6 点),避免和业务写入抢 I/O
五、一个容易忽略的 redo/undo 量
每次状态迁移(pending→done)的 I/O 路径:读 PK 页(可能 miss)→ 写新行 → 生成 undo log(旧版本)→ 生成 redo log(新版本)→ purge 线程后续清理旧版本。
400 万行/天 × 2–3 次状态迁移/行 = 800–1200 万次 UPDATE/天,每次产生 ~500B redo + ~500B undo ≈ 8–12GB redo/undo log/天。如果 redo log 容量太小,会频繁 checkpoint,拖慢所有写入。
建议:innodb_redo_log_capacity(8.0.30+)设到 4–8GB;innodb_log_file_size(8.0.29-)设到 2–4GB。去重表单条 UPDATE 很小,但量大,redo 容量要给够。
总结:林星河的方案在逻辑层是对的,DBA 层有三个必须补的洞:
- CAS 并发用
FOR UPDATE SKIP LOCKED替代裸UPDATE ... WHERE,避免行锁等待导致的串行双发; - 7 亿行装不进 64G buffer pool——拆 last_err、owner 用 INT、考虑独立实例;
- 清理用分批 DELETE(5000 行/批 + 200ms 间隔)或 DROP PARTITION(O(1)),别一把梭。