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 })
}
六、六个坑 ⚠️
- 以为 GET 一定幂等 —— 看服务端做了什么,不是看方法名。
- 幂等键每次请求都新建 —— 等于没有去重,重试照样重复执行(Key 必须在重试期间复用)。
- 幂等键用
Date.now()—— 重试时时间已变 → 不是同一个 key → 去重失效。 - 幂等键解决不了"用户真的想提交两次" —— 那需要业务侧二次确认,不是技术问题。
- 前端锁 ≠ 幂等 ——
loading/dedupe: 'ignore'只在单页面有效,刷新、网关重试、多端并发都防不住。 - 重试按钮也要防连点 —— 用户疯狂点"重试"同样会产生重复请求(配合幂等键才安全)。
七、决策流程
这个请求会改服务端状态吗?
├─ 否(查询/导出) → ✅ 自动重试,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,能自动重试吗?
能 —— 它不产生新的业务数据(只是生成文件),语义上是幂等的。

浙公网安备 33010602011771号