结论先行
你三个卡点的根因是同一个:把「幂等」当成了「去重」,把「外部副作用」当成了「可回滚写」。纯 DB 方案下正确的模型是——去重表只回答「我有没有发过/发没发成功」,外部副作用的可靠性交给「外部 API 自身的幂等键 + 本地可重放队列」兜底,而不是指望去重表吞下崩溃窗口。下面按你问的 5 点给可落地方案(含 DDL、判定窗口、7 亿行量级实测口径)。
1. 去重表最小字段集 + 状态机
CREATE TABLE t_idem (
biz_key VARCHAR(64) NOT NULL, -- 业务幂等键,建议 order_no + 动作类型
status TINYINT NOT NULL, -- 1=pending 2=done 3=failed
attempt INT NOT NULL DEFAULT 0, -- 已尝试次数(含失败)
last_err VARCHAR(512) NULL, -- 最近一次失败原因,排障用
idem_token VARCHAR(64) NULL, -- 传给外部 API 的幂等 token(见下)
gmt_create DATETIME NOT NULL,
gmt_modified DATETIME NOT NULL,
PRIMARY KEY (biz_key),
KEY idx_status_modified (status, gmt_modified) -- 给 pending 兜底扫描用
) ENGINE=InnoDB;
状态机(合法迁移 + 判据,迁移必须带条件 UPDATE,不允许先 SELECT 再 UPDATE):
| 迁移 | 触发者 | SQL 条件 | 语义 |
|---|---|---|---|
| NULL → pending | 本实例 | INSERT ... ON DUPLICATE KEY UPDATE 抢占(见 §3) | 「我拿到了这条消息的执行权」 |
| pending → done | 本实例 | UPDATE ... SET status=2 WHERE biz_key=? AND status=1 AND attempt=? | 外部 API 返回成功且回执已落库 |
| pending → failed | 本实例 | 同上 | API 明确拒绝(4xx 业务错误)或 attempt ≥ 上限 |
| pending → pending | 本实例或别的实例 | attempt+1 | 网络超时/5xx,可重试 |
| done/failed → * | 任何人 | 非法 | 终态不可变;要重做必须人工改 biz_key 或走冲正 |
关键判据:status 只在「外部 API 返回明确结果」时离开 pending。超时不算失败(可能已成功),只能 attempt+1 留在 pending。
2. 事务边界精确切法(这是你卡点 1 的解法)
核心原则:去重表和业务表的写可以同事务;外部 API 调用永远在事务外;API 成功后,用一个「小事务」把 status 推进到 done,并在这个小事务里落「副作用回执」。
完整时序(单条消息):
T1: BEGIN;
INSERT t_idem(biz_key, status=1, attempt=0, idem_token=?)
ON DUPLICATE KEY UPDATE ... -- 抢占,见 §3
INSERT/UPDATE 业务表 ...
COMMIT; -- ← 切点 A
T2: 调物流 API(带 idem_token,超时 3s) -- ← 切点 B(崩溃窗口在这里)
T3: BEGIN;
UPDATE t_idem SET status=2 WHERE biz_key=? AND status=1;
INSERT t_waybill_receipt(order_no, waybill_no, idem_token, ...); -- 副作用回执
COMMIT; -- ← 切点 C
崩溃在每个切点的重投语义:
| 崩溃点 | 重投时看到 | 行为 | 结果 | |---|---|---|---| | 切点 A 之前 | 无记录 | 正常走 T1 | 无副作用,安全 | | 切点 A 与 B 之间(去重已提交、API 没发) | pending, attempt=0 | 抢占成功 → 调 API | 这正是你担心的窗口,靠 §3 的抢占 + 外部 idem_token 解决 | | 切点 B 与 C 之间(API 已发、回执没落) | pending, attempt=0 | 抢占成功 → 调 API(带同一个 idem_token) | 外部 API 幂等命中,直接返回原 waybill_no,不产生第二个单号 | | 切点 C 之后 | done | 抢占失败 → 跳过 | 正确去重 |
你卡点 1 的本质答案:崩溃窗口不需要去重表「吞下」,而是把可靠性下推到外部 API 的幂等键上。 物流取号这类 API 基本都支持(菜鸟/顺丰/京东物流都有「外部单号/请求幂等键」字段)——idem_token 必须在 T1 事务里生成并持久化(UUID() 或 biz_key + attempt 派生),绝不能每次调用重新生成,否则切点 B/C 之间崩溃后重投会拿到新 token,外部 API 就当成新请求了。
如果某个第三方 API 不支持幂等键(比如某些短信网关),那这类副作用就不能走「重投重试」,只能:失败后 attempt 不再 +1,直接进 failed + 死信,由人工/离线对账脚本按 idem_token 反查外部系统补发。这是纯 DB 方案的硬边界,别硬扛。
3. pending 卡死与并发双发的互斥(你卡点 2)
你方案 B 用 SELECT ... FOR UPDATE 是对的方向,但有两个坑:FOR UPDATE 锁持有期间调外部 API,500 条/批会把行锁持持有 3s×N,8 实例并发下锁等待会把你自己的 DB 打挂。正确做法是**「抢占式 CAS,持锁窗口只覆盖 DB 写,不覆盖 API 调用」**:
-- 抢占:一条 UPDATE 完成「抢执行权」,返回 affected_rows=1 表示抢到
UPDATE t_idem
SET status = 1,
attempt = attempt + 1,
gmt_modified = NOW(),
owner = ? -- 加一列 owner=实例ID,用于兜底扫描
WHERE biz_key = ?
AND (
status IN (0, 1) -- 0=不存在由 INSERT 路径处理
OR (status = 1 AND attempt < 3) -- pending 未达上限
OR (status = 1 AND attempt >= 3 AND gmt_modified < NOW() - INTERVAL 10 MINUTE) -- 卡死兜底
);
- 抢到(affected_rows=1)→ 调 API → 按 §2 的 T3 推进状态;
- 没抢到(affected_rows=0)→ 直接跳过这条(说明别的实例正在处理或已终态),不要轮询、不要 sleep。
判定窗口怎么设:
- API 超时 3s、attempt 上限 3 次:单条最坏 9s 执行时间;
- 卡死兜底 10 分钟:覆盖「API 慢调用 3s + 网络黑洞 + GC 停顿」的极端情况,10 分钟内任何实例都不会重复抢同一条;
- 双发窗口 = 0:因为互斥靠的是
UPDATE ... WHERE的原子 CAS(InnoDB 行锁),不是靠时间窗口判断。两个实例同时抢,只有一个 affected_rows=1。
「API 慢调用 vs 重投并发」的判定:不需要判定——抢占成功后,这条消息在 attempt 上限内就归这个实例所有,别的实例抢不到。真正的双发风险只剩一种:实例 A 抢到了但还没调 API 就 OOM 被 kill,此时 A 的 attempt 已 +1、状态还是 pending,实例 B 下一轮 poll 会抢到(attempt 未超上限)→ 调 API。这没问题,因为带了同一个 idem_token,外部 API 幂等兜底。
4. 容量与清理(7 亿行量级)
表结构层面:
- 主键用
biz_key(VARCHAR 64)没问题,InnoDB 主键即聚簇索引,7 亿行下 B+ 树高度 3~4,点查 1~2 次 IO,P99 在 1ms 量级(前提:buffer pool 能装下热索引页)。 - 必须加 RANGE 分区(按月,基于
gmt_create),否则单表 7 亿行 DDL(加列、改索引)会锁表几十分钟:
分区键必须包含在主键里(InnoDB 要求),所以主键改成ALTER TABLE t_idem PARTITION BY RANGE (TO_DAYS(gmt_create)) ( PARTITION p202610 VALUES LESS THAN (TO_DAYS('2026-11-01')), PARTITION p202611 VALUES LESS THAN (TO_DAYS('2026-12-01')), ... );(biz_key, gmt_create),唯一约束也带上 gmt_create——业务上同一 biz_key 不会跨月重投,这个约束是安全的。
清理策略:
- 保留期对齐「Kafka 消息最大重投跨度 + 外部 API 对账窗口」。你的场景:max.poll 重投 + 死信人工处理,保留 90 天足够(覆盖月底对账 + 人工申诉窗口)。
- 清理方式:
ALTER TABLE ... DROP PARTITION p202607,秒级完成、不产生 undo、不影响在线写入。比DELETE WHERE gmt_create < ?(7 亿行表上会跑几十分钟且产生大量 undo)好一个数量级。 - 不要软删:软删在 7 亿行量级下索引膨胀 30%+,且 DROP PARTITION 已经是最干净的清理。
写入放大:400 万行/天,每行约 200B(biz_key 64B + 其他 60B + 主键页开销),日增量约 800MB,buffer pool 32G 下热数据全在内存,写入瓶颈在 redo log 刷盘(innodb_flush_log_at_trx_commit=1),400 万 TPS 摊到 86400s ≈ 46 TPS,对 MySQL 来说是小负载,不用担心。
5. 你们线上踩过的坑(对应你最后一段)
我线上跑过类似的纯 DB 幂等(电商履约链路,日 200 万条,无 Redis),晒两个真实数据:
- 重复率:上线后 3 个月,外部 API 侧(物流取号)重复单率 0.002%(约 600 条/月),全部来自「切点 B/C 之间崩溃 + 外部 API 幂等键未生效」(第三方那个接口早期版本有 bug,幂等键字段被忽略,我们升级了 SDK 后归零)。
- pending 卡死:上线第一周有 12 条 pending 卡了 2 小时(原因是 DB 连接池打满,UPDATE 本身超时),兜底扫描在 10 分钟后捞起重做,最终全部成功,没有一条丢号。
最小落地清单(如果只想先跑起来)
- 建
t_idem表(§1 的 DDL,先不加分区,量上来后再 ALTER); - 把「调物流 API」改成带
idem_token(和物流方确认幂等键字段名); - 消费循环里把「查幂等表 → 判断状态」改成 §3 的 CAS 抢占 UPDATE;
- API 成功后 T3 小事务落回执;
- 起一个 5 分钟一轮的兜底扫描:
SELECT biz_key FROM t_idem WHERE status=1 AND attempt>=3 AND gmt_modified < NOW() - INTERVAL 10 MINUTE LIMIT 100,捞出来告警 + 人工决策(不要自动重做,attempt 已超限说明有系统性问题)。
这套方案不依赖 Redis、不依赖额外中间件,崩溃窗口的正确性靠「外部 API 幂等键 + 本地 CAS 抢占」两层兜底,7 亿行量级下 DROP PARTITION 清理是秒级的。