"幂等已经接了,重试是安全的了。"这句话通常成立。可当补数据任务重跑一次,账单里出现了两倍的流水——同一个机制,两个目的,只有一个被实现了。
防重复针对的是同一个请求被送达多次;可重跑针对的是同一批数据被处理多次。前者的键是请求标识,后者的键是数据范围;前者要短窗口高速存储,后者要跨天的范围标记。两者共用一套表、一套键、一个保留期时,先失效的那个不会报错,它只会静默地少做事或多做事。
1. 两个目的,两把键
| 比较项 | 防重复(请求幂等) | 可重跑(结果幂等) |
|---|---|---|
| 防的是什么 | 同一请求被投递/重试多次 | 同一批数据被处理多次 |
| 键 | 请求标识(requestId、业务单号) | 数据范围(批次号、时间区间、主键区间) |
| 时间尺度 | 分钟到天,与重试窗口同量级 | 天到月,跨越补数周期 |
| 成功判据 | 第二次没产生第二次副作用 | 第 N 次结果与第 1 次逐行相同 |
| 存储 | 短窗口、高频写、可丢 | 范围标记、低频写、不可丢 |
| 失效表现 | 重复扣款/重复发货 | 重复计数、重复累加 |
| 恶化方向 | 键太粗 → 真重试被吞 | 范围太粗 → 重跑被误判为已做 |
"同一次"和"同一批"不是同一个概念,这是全部混乱的起点。请求幂等回答"这条指令我执行过吗",可重跑回答"这段数据我处理过吗"。
// 防重复的时间线:同一个键,多次到达
t0 下单(req=A) → 成功,写 dedup(A)
t1 超时重试(req=A) → 命中 dedup(A) → 返回首次结果
// ─── 键不变,所以判重成立;窗口必须覆盖整个重试期
// 可重跑的时间线:不同的键,同一段数据
D1 补数(范围=P2026-10) → 处理 60% 失败
D2 补数(范围=P2026-10) → 应当是"续跑 40%",不是"整段跳批"也不是"整段重做"
// ─── 键是范围,答案必须是"已写到哪",而不是"来过没有"
2. 键不同,判据就不同
// 防重复的键:来自调用方,跨重试保持不变
String dedupKey = request.getBizNo(); // 业务单号,最强也最贵
String dedupKey2 = request.getExternalSeq(); // 外部流水号,依赖上游质量
// 可重跑的键:来自数据本身,描述"处理了哪一段"
String scopeKey = batchId + ":" + rangeStart + ":" + rangeEnd; // 范围
String scopeKey2 = table + ":" + partition; // 分区
// ─── 两种键放进同一张幂等表,等于用"指令"去回答"数据"
| 判定依据 | 强度 | 成本 | 失效形态 |
|---|---|---|---|
| 业务单号 | 最强,业务可解释 | 上游必须提供且稳定 | 上游改号规则即失效 |
| 外部流水号 | 强,但依赖第三方 | 需要落库、需要对齐 | 第三方重发时号会变 |
| 内容指纹(字段哈希) | 弱,易碰撞于正常变更 | 计算便宜、无需上游配合 | 金额/时间精度变化即误判 |
| 请求 ID(网关生成) | 只对"同一次调用"有效 | 最低 | 换入口即换 ID,跨系统无效 |
三种判据的成本差一个量级:业务单号要跨团队约定,外部流水号要存储与对账,内容指纹可以就地算但会把"正常的重复内容"误判成重复。先选判据,再谈实现;反过来做,通常是在用一个便宜的键去承担一个昂贵判据的责任。
键的粒度还有第二个方向的风险:范围键取到"整张表"或"整个租户",重跑变成全量重做;取到"单行",标记表本身就成了第二份数据,行数与主表同量级。可用的粒度通常落在分区或主键区间,并且必须能回答"写到哪了",而不只是"来过没有"。
3. 混用的后果:真重试被当成重复丢掉
最典型的现场:为了让补数脚本"能重跑",把脚本也纳入了同一套请求幂等,用批次 ID 做键。
// 错:把范围键塞进请求幂等表,并设 24 小时窗口
if (idempotentStore.exists(batchId)) {
return Result.duplicate(); // 真正的第二次重试在这里被吞掉
}
idempotentStore.put(batchId, now, TTL_24H);
process(batchRange);
// 对:请求幂等只管指令,重跑用范围标记 + 结果可覆盖
if (rangeMarker.done(table, partition)) return; // 已完成则跳过,不是"重复"
upsertByPrimaryKey(transform(batchRange)); // 结果与次数无关
rangeMarker.mark(table, partition);
| 后果 | 机制 | 被牺牲的是 |
|---|---|---|
| 真重试被丢弃 | 请求键与范围键同表,第二次调用被判重复 | 防重复目标被"过度实现",实际丢的是业务动作 |
| 重跑产生重复 | 只有请求幂等,没有范围标记 | 可重跑目标完全缺失 |
| 部分成功无法续跑 | 窗口按"整批"标记,失败批次整段被跳过 | 两者都失效,且难以察觉 |
| 窗口过期后重复执行 | 幂等窗口短于补数周期 | 防重复在跨天场景下形同虚设 |
第三行最难发现:批次处理到 60% 失败,重跑时因为"批次已标记"直接跳过,剩下 40% 永远没做。系统没有任何报错,对账差异要等到下一个结算周期才显形;而那时查的方向通常是"上游多发",不是"幂等键吞了续跑"。
4. 时间语义、状态幂等与存储诉求
只做请求幂等、不做状态幂等,会在"执行成功但响应丢失"时出错:调用方重试,服务端按请求键判重返回"已处理",而实际状态可能停在中间步。
// 请求幂等的表:短窗口、高频、可丢
CREATE TABLE req_dedup (req_id VARCHAR(64) PRIMARY KEY, created_at DATETIME);
// ─── 清理策略:按 created_at 定期删除,窗口与最大重试时长对齐
// 可重跑的范围标记:低频、不可丢、可续跑
CREATE TABLE range_marker (
target VARCHAR(64), part VARCHAR(32), done_at DATETIME,
rows_written BIGINT, checksum VARCHAR(64),
PRIMARY KEY (target, part) -- 粒度决定能否续跑
);
| 诉求 | 请求幂等 | 可重跑 |
|---|---|---|
| 存储介质 | 内存/短 TTL 缓存即可 | 持久表,随数据一起备份 |
| 写入频率 | 每次请求一次 | 每批一次 |
| 能否丢 | 能(窗口过期即视为新请求) | 不能(丢了就会重做) |
| 窗口长度 | 与重试窗口一致,通常分钟到小时 | 覆盖整个补数周期,通常按天 |
| 关键字段 | 键 + 时间 | 键 + 行数 + 校验和 |
"我这接口是幂等的"必须有前置条件才成立:在什么键下、什么窗口内、失败是否留痕、重试是否携带同样的键。缺任何一项,这句话只是在描述"我写过 if (exists) return"。
状态幂等的最小实现不是再记一个键,而是把"执行到哪一步"落成可读的状态:
// 状态幂等:重试读到的是进度,不是"已处理"这个布尔值
enum Step { CREATED, RESERVED, CHARGED, SHIPPED }
// 每步都能被重复执行到同一状态,且每一步的跳转条件是显式的
if (order.step.ordinal() < Step.CHARGED.ordinal()) charge(order);
if (order.step.ordinal() < Step.SHIPPED.ordinal()) ship(order);
// ─── 幂等键只挡"同一个请求",挡不住"上一个请求只做完一半"
5. 排错:五种现场
| 现象 | 真实原因 | 处理方向 |
|---|---|---|
| 重跑后总量翻倍 | 只有请求幂等,无范围标记 | 加范围标记,并把写入改成按主键 upsert |
| 重试没有生效,业务动作缺失 | 请求键被范围键覆盖或复用 | 拆表,键的来源必须可追溯 |
| 跨天补偿出现重复 | 幂等窗口短于补偿周期 | 窗口按最大补偿跨度定,不是按重试次数 |
| 部分失败后无法续跑 | 标记粒度是整批 | 标记下沉到分区/主键区间 |
| 对账差异反复出现在同一批 | 状态幂等缺失,响应丢失即走重试分支 | 落执行状态机,而非只落键 |
度量这四个量:重复率(同一键出现的比例)、被误判重复率(判重但业务实际未执行)、重跑差异行数(重跑前后逐行比对)、幂等窗口命中率(窗口内命中的占比,过低说明窗口太短)。第二个是最容易被忽略、也最能暴露"键选错"的指标。
// 被误判重复率:判重了,但业务表里没有对应痕迹
SELECT count(*) FROM req_dedup d
LEFT JOIN biz_order o ON o.biz_no = d.req_id
WHERE d.created_at > now() - INTERVAL 1 DAY AND o.id IS NULL;
// ─── 非零即说明"判重"替代了"执行",键把真重试吞掉了
6. 边界:什么时候幂等设计是多余的
| 场景 | 结论 | 理由 |
|---|---|---|
| 纯查询、无副作用 | 不做幂等 | 重复执行的结果本来就相同 |
| 写入天然覆盖(按主键 upsert) | 只做覆盖,不做去重 | 结果与次数无关,已满足可重跑 |
| 写入天然追加且带业务唯一索引 | 依靠唯一索引 | 数据库已提供判据,不必再建一套 |
| 上游保证不重试、只投一次 | 按场景核对 | 前提一旦变化,缺失的判据会立刻显形 |
| 数据量极小、重跑靠人眼核对 | 简化处理 | 正式机制的维护成本高于人工成本 |
| 补数只有一次性、之后数据不再回填 | 简化处理 | 范围标记的生命周期短于实现成本 |
一句话选择:**请求幂等买的是"安全重试",范围标记买的是"可续跑",两者不可互相顶替。**共用一套表时,通常被牺牲的是可重跑——因为它不产生告警,只产生对账差异;而对账差异在下一个结算周期才出现,届时没人会把账挂到"幂等表结构"上。
自测
- 防重复的键与可重跑的键,来源上有什么本质差别?
- 用批次 ID 做请求幂等键,真正的第二次重试会得到什么结果?
- "执行成功但响应丢失"这一场景,缺的是哪一层幂等?
- 幂等窗口的长度应该由什么决定,而不是由什么决定?
- 内容指纹作为判据,成本低在哪里、误判在哪里?
- 什么样的写入形态下,幂等设计本身就是多余的?