A27第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

重试与幂等

重试与幂等 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

读完本课你能为带副作用的步骤(写库、调用外部服务)设计安全的重试:用幂等键保证「同一请求只生效一次」,用分类规则保证「该重试的才重试」,并且会验证这两点真的成立。

问题场景

重试本身会制造新故障。资料导入任务里「写入片段」一步失败了,自动重试跑了一遍——结果库里有两份片段。更隐蔽的:调用外部通知服务超时,其实对方已经收到了,你重试,用户收到两条一模一样的通知。失败不可怕,可怕的是不知道失败发生在「生效前」还是「生效后」。重试要安全,前提是操作幂等(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 小时),过期键允许重新执行。

动手任务

  1. 给 A26 的任务步骤加幂等:写片段的步骤带 taskId:stepKey 键,手动把同一步骤跑两遍,用 SQL 数一遍片段数,验证还是一份。
  2. 实现本课的 withRetry,用假函数模拟「前两次抛 unreachable、第三次成功」,验证退避间隔和最终成功;再换成抛 quota,验证一次都不重试。
  3. 找出站内一个「重试安全」的现成例子(A12 聊天重试不带 message),用一句话说明它防住了什么重复。

常见错误

  • 对 POST 类操作裸重试:「接口报错了就再调一次」——写操作重试出两份数据。先问「这操作幂等吗」,不幂等先加键。
  • 幂等键用随机数:每次重试都生成新键,键形同虚设。键必须绑定业务语义,重试时复用。
  • 无差别重试:400/402 也进重试循环,白白多等多失败。错误分类(A12)先行,重试只对可恢复类开放。
  • 重试无上限:故障服务被同一请求无限敲打。次数上限 + 退避是重试的基本礼貌,也是自我保护。

检查与复盘

自查清单:

  • 能说出幂等键的生成规则(业务语义)与存储方式(唯一键 + 结果回放)
  • 能按四类失败说出「重不重试、怎么重试」
  • 能设计一个「重复提交只生效一次」的验证实验

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1故障定位较难

bug 现场:资料导入任务的「写入片段」步骤超时,自动重试跑了一遍,库里出现两份片段。更隐蔽的是:调用外部通知服务超时(其实对方已收到),重试后用户收到两条相同通知。重试代码如下。根因与修法?

async function runStep(task) {
  try {
    await writeChunks(task);
    await notifyExternal(task);
  } catch (e) {
    await sleep(1000);
    await runStep(task); // 失败就重试
  }
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...