重试与幂等
重试与幂等 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
读完本课你能为带副作用的步骤(写库、调用外部服务)设计安全的重试:用幂等键保证「同一请求只生效一次」,用分类规则保证「该重试的才重试」,并且会验证这两点真的成立。
问题场景
重试本身会制造新故障。资料导入任务里「写入片段」一步失败了,自动重试跑了一遍——结果库里有两份片段。更隐蔽的:调用外部通知服务超时,其实对方已经收到了,你重试,用户收到两条一模一样的通知。失败不可怕,可怕的是不知道失败发生在「生效前」还是「生效后」。重试要安全,前提是操作幂等(idempotent):执行一次和执行多次,效果一样。
核心解释
两个工具配合使用:
第一,幂等键(idempotency key):客户端为每个「业务请求」生成唯一标识,服务端见到重复标识就直接返回上次的结果,不再执行。Stripe 的 API 把这个机制做成了标准(Idempotency-Key 请求头),值得照着学:键由发起方生成、随请求携带、服务端记录「键 → 结果」。
-- 幂等表:键唯一,重复插入直接冲突跳过(SQLite 的 ON CONFLICT DO NOTHING)
CREATE TABLE idempotency (
key TEXT PRIMARY KEY, -- 客户端生成的请求标识
task_id TEXT NOT NULL,
step_key TEXT NOT NULL,
result TEXT, -- 首次执行的结果,重复请求时直接回放
created_at TEXT NOT NULL
);
INSERT INTO idempotency (key, task_id, step_key, result, created_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(key) DO NOTHING;
-- 插入冲突 = 请求处理过:读出 result 返回,不再执行副作用
键怎么生成?用业务语义而不是随机数:taskId:stepKey:attempt 里去掉 attempt——同一任务的同一步骤永远同键,换任务才换键。这样「重试同一步」天然撞键,「新任务」天然是新键。
第二,重试规则:不是所有失败都值得重试。
function withRetry<T>(fn: () => Promise<T>, opts: { tries?: number } = {}) {
const maxTries = opts.tries ?? 3;
return async () => {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e: any) {
const recoverable = e.category === "unreachable" || e.category === "rate_limit";
// 只重试可恢复错误;参数错、额度不足重试只是再失败一次(A12 的分类直接复用)
if (!recoverable || i >= maxTries - 1) throw e;
await new Promise((r) => setTimeout(r, 2 ** i * 500)); // 指数退避:0.5s, 1s, 2s
}
}
};
}
如何验证幂等成立:设计一个「重复提交」测试——同一个任务(同 taskId)连续提交两次,断言:库里的目标数据只有一份(COUNT(*) = 1)、下游动作(通知/外部调用)只发生一次。A12 里聊天的重试就是活例子:retry 请求不带 message,服务端不重复写用户消息,只清残留重生成。
示例拆解
把三件事串成一张决策表:
| 失败类型 | 例 | 重试? | 幂等保障 |
|---|---|---|---|
| 请求未到达 | 连接超时、DNS 失败 | 重试 + 退避 | 键没登记,重试即首执行 |
| 结果未知 | 上游超时但可能已执行 | 重试 | 幂等键拦住二次生效 |
| 明确拒绝 | 400 参数错、402 额度不足 | 不重试,直接报 | 无需 |
| 成功后抛错 | 写库成功但响应丢失 | 重试 | 键已登记,回放上次结果 |
关键点:
- 「结果未知」是最需要幂等键的场景:你不知道对方做没做,唯一安全的动作是「带着同一个键再问一次」,由服务端去重。
- 指数退避 + 上限:固定间隔重试会让故障中的服务雪上加霜;退避从 0.5 秒起、翻倍增长、三次封顶。
- 重试与断点恢复的关系(接 A26):断点恢复解决「从哪一步继续」,幂等解决「继续的那一步会不会做重」。两者合起来才构成安全的失败处理。
- 幂等键要有过期策略:长期保留「键 → 结果」表会无限膨胀;按业务定 TTL(如 24 小时),过期键允许重新执行。
动手任务
- 给 A26 的任务步骤加幂等:写片段的步骤带
taskId:stepKey键,手动把同一步骤跑两遍,用 SQL 数一遍片段数,验证还是一份。 - 实现本课的
withRetry,用假函数模拟「前两次抛 unreachable、第三次成功」,验证退避间隔和最终成功;再换成抛quota,验证一次都不重试。 - 找出站内一个「重试安全」的现成例子(A12 聊天重试不带 message),用一句话说明它防住了什么重复。
常见错误
- 对 POST 类操作裸重试:「接口报错了就再调一次」——写操作重试出两份数据。先问「这操作幂等吗」,不幂等先加键。
- 幂等键用随机数:每次重试都生成新键,键形同虚设。键必须绑定业务语义,重试时复用。
- 无差别重试:400/402 也进重试循环,白白多等多失败。错误分类(A12)先行,重试只对可恢复类开放。
- 重试无上限:故障服务被同一请求无限敲打。次数上限 + 退避是重试的基本礼貌,也是自我保护。
检查与复盘
自查清单:
- 能说出幂等键的生成规则(业务语义)与存储方式(唯一键 + 结果回放)
- 能按四类失败说出「重不重试、怎么重试」
- 能设计一个「重复提交只生效一次」的验证实验
参考资料
- Stripe 文档:Idempotent requests —— 幂等键机制的业界标准实现
- SQLite 官方文档:ON CONFLICT —— 去重插入的底层语法
- MDN:fetch() —— 重试的载体与超时控制
- 下一课 A28 讲一个安全话题:检索到的资料本身可能是攻击面。
读完这一课了?
标记完成后,工作台会安排接下来的学习。