HTTP:幂等和非幂等请求

阶段 7.4|幂等:哪些能重试、哪些不能

一、判定标准(不看 HTTP 方法,看语义)

拿到一个接口,问自己三个问题:

① 会不会改变服务端状态?(只读 → 一定可重试)
② 同样的参数再来一次,结果是"一样"还是"又多一份"?
③ 有没有「新建 / 累加 / 状态流转」动作?
答案 结论
① 否 ✅ 随便重试
② 一样 ✅ 幂等,可重试
② 又多一份 / ③ 有 ❌ 非幂等,禁止自动重试

二、分类总表

✅ 可重试(幂等)

类型 例子 为什么
查询 GET /list、GET /detail/:id 只读
导出 POST /export(纯生成文件) 重复生成无害
全量更新 PUT /user/1(传完整对象) 结果一致
按 ID 删除 DELETE /order/1 删一次和多次状态相同
覆盖写配置 PUT /settings 覆盖而非累加

❌ 禁止自动重试(非幂等)

类型 例子 重试后果
创建型 POST /orders 下单 💥 多张订单
支付/扣款 POST /pay 💥 重复扣款
累加型 PATCH /stock: -1、积分 +100 💥 多扣/多加
提交/审批 POST /submit、/approve 💥 重复流程/重复审批
通知型 POST /sms/send、发邮件 💥 重复发送
计数/上报 GET /api/track?...(后端写库) 💥 数据被污染

⚠️ 方法名只是约定:GET /api/incr-count 照样非幂等。判断看服务端做了什么。


三、什么时候会"悄悄重试"(危险来源)

场景 是否产生重复
7.3 的自动重试 💥 主要来源
超时后用户手动点"重试" 💥
浏览器刷新(POST 未做 PRG) 💥 表单重复提交
用户快速连点按钮 💥(loading 锁能防)
网关/代理重试 💥
消息队列重投 💥

按钮 loading 只防"人为连点",防不住"网络超时重试" —— 两者都要做。


四、四套解决方案(按推荐度)

方案 ①:写操作不自动重试(最简单,必做)

// 重试白名单:只有只读方法才自动重试
const RETRYABLE_METHODS = ['get', 'head', 'options']

function canAutoRetry(config: AxiosRequestConfig) {
  return RETRYABLE_METHODS.includes((config.method ?? 'get').toLowerCase())
}

// 用 7.3 的 retry
retry(() => submit(data), { times: canAutoRetry({ method: 'post' }) ? 3 : 0 })

写操作失败后:给一个「重试」按钮,让用户明确知情地再点一次(而不是自动)。


方案 ②:幂等键 Idempotency-Key(最正解)⭐

原理:客户端为一次业务操作生成一个唯一 Key,服务端按 Key 去重 —— 同样的 Key 第二次来,不重复执行,直接返回第一次的结果。

// utils/idempotency.ts
/** 生成幂等键 */
export function createIdempotencyKey(): string {
  return crypto.randomUUID()      // 或 nanoid()
}

let pendingKey: string | null = null

/**
 * 获取幂等键:同一业务操作共用一把,重试时复用
 * @param forceNew 业务操作结束/重新打开表单时传 true
 */
export function getIdempotencyKey(forceNew = false): string {
  if (!pendingKey || forceNew)
    pendingKey = createIdempotencyKey()
  return pendingKey
}

/** 业务操作成功后重置(下次提交用新 key) */
export function resetIdempotencyKey() {
  pendingKey = null
}

使用:

async function submitOrder(data: OrderData, signal?: AbortSignal) {
  const key = getIdempotencyKey()      // ⭐ 重试时复用同一把 key
  try {
    const res = await axios.post('/orders', data, {
      headers: { 'Idempotency-Key': key },
      signal,
    })
    resetIdempotencyKey()              // 成功后重置
    return res
  }
  catch (e) {
    // key 不重置,重试仍用同一把 → 服务端不会重复创建
    throw e
  }
}

// 现在可以安全重试了
await retry(() => submitOrder(data), { times: 3 })

服务端语义(你要知道的):

收到请求 → 查幂等表(key)
  ├─ 不存在 → 执行业务 → 记录 (key → 响应) → 返回结果
  └─ 已存在 → 直接返回上次的响应(不重复执行)
并发同 key → 第二个返回 409 或等待第一个完成
key 有效期 → 通常 24 小时

方案 ③:先查后写(查重)

重试前先确认"是不是已经做过了":

async function submitOnce(data: OrderData) {
  const existed = await findByBizNo(data.bizNo)   // 先查
  if (existed)
    return existed                                 // 已创建 → 直接用
  return createOrder(data)                         // 没创建 → 才建
}

适用:有业务唯一号(订单号、批次号、业务流水号)的场景。注意并发下仍有竞态,需配合后端唯一索引。


方案 ④:后端兜底(别忘了,前端防不住所有情况)

手段 说明
唯一索引 数据库层 (biz_no) 唯一 → 重复插入直接报错
乐观锁 version 字段 / ETag + If-Match → 冲突返 409
状态机校验 "已审批"状态再审批 → 直接返回成功而非重复流转
PRG 模式 POST 后 302 重定向到 GET → 防浏览器刷新重复提交
// 乐观锁:更新时带版本号,被别人改过就 409
axios.put('/order/1', { ...data, version: 3 })
// 后端:UPDATE ... WHERE id=1 AND version=3 → 影响行数为 0 → 409 Conflict

五、前端落地:接口标记 + 重试白名单

// api/order.ts
export const createOrder = Object.assign(
  (data: OrderData, config?: AxiosRequestConfig) =>
    usePost('/orders', data, { ...config, headers: { 'Idempotency-Key': getIdempotencyKey() } }),
  { idempotent: true },      // ⭐ 标记:带幂等键,可重试
)

export const payOrder = Object.assign(
  (data: PayData, config?: AxiosRequestConfig) => usePost('/pay', data, config),
  { idempotent: false },     // ⭐ 标记:禁止自动重试
)
// 统一重试入口
function safeRetry<P, R>(
  service: ((p: P, c: any) => Promise<R>) & { idempotent?: boolean },
  params: P,
  config: any,
) {
  const times = service.idempotent ? 3 : 0
  return retry(() => service(params, config), { times })
}

六、六个坑 ⚠️

  1. 以为 GET 一定幂等 —— 看服务端做了什么,不是看方法名。
  2. 幂等键每次请求都新建 —— 等于没有去重,重试照样重复执行(Key 必须在重试期间复用)。
  3. 幂等键用 Date.now() —— 重试时时间已变 → 不是同一个 key → 去重失效。
  4. 幂等键解决不了"用户真的想提交两次" —— 那需要业务侧二次确认,不是技术问题。
  5. 前端锁 ≠ 幂等 —— loading/dedupe: 'ignore' 只在单页面有效,刷新、网关重试、多端并发都防不住。
  6. 重试按钮也要防连点 —— 用户疯狂点"重试"同样会产生重复请求(配合幂等键才安全)。

七、决策流程

这个请求会改服务端状态吗?
├─ 否(查询/导出)     → ✅ 自动重试,times: 2~3
└─ 是(写操作)
    ├─ 有幂等键 / 业务唯一号 → ✅ 可重试(服务端去重 + 先查后写)
    ├─ 是全量覆盖(PUT/DELETE by id)→ ✅ 可重试
    └─ 其他(新建/累加/支付/提交)→ ❌ 不自动重试
                                     → 给用户「手动重试」按钮 + 后端唯一索引兜底

✅ 自检

Q1:判断幂等看 HTTP 方法还是语义?

语义。GET /api/incr 也非幂等;PUT 全量覆盖是幂等。

Q2:为什么超时不能说明请求失败了?

超时只代表"客户端没等到响应",服务端可能已经处理成功(响应丢了)。此时重试 = 重复执行。

Q3:幂等键应该在什么时候重建?

一次业务操作成功后(或重新打开表单时)重建;重试期间必须复用同一把。

Q4:前端的 loading 锁能替代幂等吗?

不能。只防人为连点,防不住网络重试、网关重投、刷新页面、多端并发。

Q5:哪些后端手段能兜底防重?

唯一索引、乐观锁(version / ETag + If-Match → 409)、状态机校验、PRG 模式。

Q6:POST /export 是 POST,能自动重试吗?

能 —— 它不产生新的业务数据(只是生成文件),语义上是幂等的。

posted @ 2026-10-14 15:27  寅信  阅读(13)  评论(0)    收藏  举报