2907 字
约 9 分钟
15
S17-A18 · 幂等的两个目的:防重复与可重跑被混在一起

"幂等已经接了,重试是安全的了。"这句话通常成立。可当补数据任务重跑一次,账单里出现了两倍的流水——同一个机制,两个目的,只有一个被实现了。

防重复针对的是同一个请求被送达多次;可重跑针对的是同一批数据被处理多次。前者的键是请求标识,后者的键是数据范围;前者要短窗口高速存储,后者要跨天的范围标记。两者共用一套表、一套键、一个保留期时,先失效的那个不会报错,它只会静默地少做事或多做事。


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) 只做覆盖,不做去重 结果与次数无关,已满足可重跑
写入天然追加且带业务唯一索引 依靠唯一索引 数据库已提供判据,不必再建一套
上游保证不重试、只投一次 按场景核对 前提一旦变化,缺失的判据会立刻显形
数据量极小、重跑靠人眼核对 简化处理 正式机制的维护成本高于人工成本
补数只有一次性、之后数据不再回填 简化处理 范围标记的生命周期短于实现成本

一句话选择:**请求幂等买的是"安全重试",范围标记买的是"可续跑",两者不可互相顶替。**共用一套表时,通常被牺牲的是可重跑——因为它不产生告警,只产生对账差异;而对账差异在下一个结算周期才出现,届时没人会把账挂到"幂等表结构"上。


自测

  1. 防重复的键与可重跑的键,来源上有什么本质差别?
  2. 用批次 ID 做请求幂等键,真正的第二次重试会得到什么结果?
  3. "执行成功但响应丢失"这一场景,缺的是哪一层幂等?
  4. 幂等窗口的长度应该由什么决定,而不是由什么决定?
  5. 内容指纹作为判据,成本低在哪里、误判在哪里?
  6. 什么样的写入形态下,幂等设计本身就是多余的?
S17-A18 · 幂等的两个目的:防重复与可重跑被混在一起
https://www.clxhxhhr.top/posts/6358/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。