2602 字
约 8 分钟
15
S14-A19 · 重复请求与取消:用户连点三次,后端收到三次

一个"提交订单"的按钮在弱网下被连点三次,后端创建了三笔订单。前端的"点击后禁用按钮"没起作用——因为用户在按钮禁用前就点完了,而网络重试又在更后面加了两笔。查下来三笔订单金额一样、时间接近,靠人工退款。

重复请求与取消是前端两个"看起来简单"的问题,这个"层"的判断有个简单依据:看你能否枚举"所有能触发这个操作的路径"。按钮层只能覆盖"这一个按钮",而重试、多入口、多页签不在其中。
而它们都必须在"请求层"而不是"按钮层"解决:按钮禁用只能防"同一个人在同一个界面上手抖",防不住"重试、多个标签页、多个入口"。


1. 重复请求的三种来源

来源 例子
用户连点 按钮未及时禁用
客户端重试 超时自动重试
多入口/多页签 两个页面各自提交

第二项最容易被忽略:HTTP 客户端或网关的"超时重试"会把一次点击变成两次请求第二项的隐藏性在于"重试是基础设施的行为":它发生在 axios/网关/负载均衡任一层,而每一层都认为自己在"提高可靠性"——于是可靠性手段变成了重复来源。
,而前端完全不知道。

第三项在"列表页与详情页都能提交"时常见——它们看起来是两个操作,实际是同一件事。

2. 三层的去重位置

层 能力
UI(按钮禁用) 只防连点
请求层(幂等键 + 去重) 防重试与多入口
服务端(幂等/唯一约束) 唯一可靠

第三项的兜底是"唯一约束":即使幂等键因为某种原因失效,唯一索引仍会拒绝第二条。两层一起用才是完整的。
第三项是"唯一可靠"的:只有服务端能保证"同一业务动作只执行一次",因为它是唯一能看到所有请求的地方。

第一项不是没用,而是"体验优化":它能减少无效请求,但不能作为正确性保障。

POST /api/orders
Idempotency-Key: 8f14e45f-6b1a-4c2e-9f0a-2b3c4d5e6f70
{"sku":"A-12","qty":1}
// 幂等键由客户端生成(每次"用户意图"一个),而不是每次"请求"一个

3. 幂等键该怎么生成

方式 问题
每次请求新建 UUID 重试会得到新键(无效)
按"用户意图"生成 正确
服务端生成 无法覆盖重试
用内容哈希 相同内容会被误判重复

第一项是最常见的错误:"生成 UUID 放进 header"看起来对,但如果每次请求都生成,重试时就是新的键——于是幂等失效。第一项的排查方式很直接:看两次请求的 header 是否相同。若不同,说明键在"请求层"生成而不是"意图层"。

第二项才是正解:"用户点了一次提交"这件事生成一个键,之后所有重试都用它。

第二项(按意图生成)的实现细节是"生成时机":点提交那一刻生成并保存,重试逻辑只读取不生成。而这需要"重试"与"提交"共用同一个状态,否则重试一分支就会重新生成。
第四项的风险在"用户真的想提交两次相同的订单"时被误判为重复。

// 正解:在"用户意图"处生成,随重试复用
const key = crypto.randomUUID();          // 点提交时生成一次
await fetch("/api/orders", {
  method: "POST",
  headers: { "Idempotency-Key": key },
  body,
});
// 重试时用同一个 key(不要让重试逻辑重新生成)

4. 请求取消:三种取消点

取消点 效果
组件卸载 避免"卸载后 setState"
新请求发出 丢弃旧响应(防竞态)
用户主动取消 释放资源

第二项是"搜索联想"的经典问题:先发的请求后到,于是旧结果覆盖了新结果。第一项(组件卸载)在 React 里表现为"警告:不能在已卸载组件上 setState"。而它不致命,所以常被忽略——直到它掩盖了真正的竞态。
解法是"新请求发出时取消旧的",或"给请求编号,只接受最新编号的结果"。

第三项在"导出/上传"这类长任务上必要:取消要能真正停下服务端的工作,而不只是前端不看了。

// 竞态处理:AbortController + 编号双保险
let seq = 0, ctrl = null;
async function search(q) {
  ctrl?.abort();                       // 取消上一次
  ctrl = new AbortController();
  const my = ++seq;
  const res = await fetch(`/api/search?q=${q}`, { signal: ctrl.signal });
  if (my !== seq) return;              // 双保险:只接受最新
  render(await res.json());
}

服务端的幂等实现有"三层",缺一层都可能在边界上漏:

// 三层幂等(从外到内)
1 幂等键表:key → 首次结果(TTL 24h),重复则直接返回首次结果
2 唯一约束:业务唯一索引(如 (user_id, client_seq))兜底拒绝
3 状态机校验:已支付订单不允许再次支付(防"同一订单不同键")
// 缺 1 → 重试会重复执行;缺 2 → 并发下仍可能双写;缺 3 → 换键绕过

5. 取消不是"前端不看"

层 取消的含义
浏览器 断开连接 / 停止等待
服务端 收到断开信号(或没有)
分布式调用 需要显式传递取消信号

第二项是关键:浏览器取消不保证服务端停下。多数服务端框架能感知"连接断开",但"业务逻辑是否响应"取决于代码。

第四项(分布式)的难点是"信号传递":HTTP 断开容易,gRPC/消息队列需要显式取消语义。所以"取消即省钱"在多服务链路里往往只能做到"前端不再等"。
所以在"取消即省钱"的场景(调用外部付费 API),取消信号必须一路传到最外层调用,否则"用户取消"只是"前端不再等"。

"连点"还有一个"页签"版本容易被忽略:用户在两台设备上同时操作(手机 + 电脑),而"按钮禁用"完全无效——因为它们各自独立。此时只有服务端的幂等键(若键与"意图"绑定)或业务状态机才能兜住。

// 三种"多点"场景与有效防线
同一台设备连点 → 按钮禁用 + 请求去重
两台设备同时操作 → 服务端幂等键(键需含 user+意图)或状态机
脚本/接口直调 → 服务端唯一约束(前端防线完全无效)

6. 排错:五种"重复/竞态"

现象 成因 处理方向
连点产生多笔 无服务端幂等 加幂等键 + 唯一约束
偶尔两笔(非连点) 客户端/网关重试 幂等键复用
列表显示旧数据 响应乱序 编号 + 取消
卸载后报错 setState on unmounted 卸载时取消
取消后仍产生费用 信号未传到底 全链路传递取消

第二项最难查:用户只点了一次,而后端有两笔——因为重试在中间层静默发生。第三项(显示旧数据)的判据是"是否与输入相关":输入 A 的结果出现在输入 B 的位置 → 一定是竞态,而不是"数据错"。

最后一个"观测"建议:统计"幂等命中率"(重复请求被拦下的比例)。这个数有两种读法——偏高说明客户端在重试(可能网络差),为 0 则说明要么没重试,要么幂等键没生效(后者更危险,因为它看起来"一切正常")。

// 三个可观测量
idempotent_hit_total         重复请求被拦次数 / 总请求
idempotent_key_missing       缺少幂等键的写请求(应为 0)
unique_violation_total       唯一约束拒绝次数(兜底被触发的信号)
// 用法:第 2 项非零 → 有入口没传键;第 3 项持续非零 → 幂等键失效

7. 边界:什么情况不必做幂等

场景 为什么 做法
纯查询(GET) 天然幂等 不必
可重算的派生数据 重复执行无副作用 不必
内部工具(有人复核) 出错可见 可简化
已有唯一约束兜底 数据库会拒绝重复 只需把报错转成友好提示

最后一条与"体验"有关的边界:幂等不只是"防重复",它还能"让重试变安全"。于是"客户端敢重试"这件事本身依赖幂等——没有幂等,重试就是放大故障的手段。
判断口径一句话:这个操作"执行两次"会不会产生两次后果。会(下单、支付、扣库存),必须有幂等键 + 唯一约束;不会(查询、覆盖写),简单处理即可。


8. 自测

  1. 重复请求的三种来源里,哪一种前端"完全不知道"?为什么它最难查?
  2. 为什么"按钮禁用"不能作为正确性保障?三层去重里哪一层是唯一可靠的?
  3. "每次请求生成 UUID"为什么让幂等失效?幂等键该在哪一刻生成?
  4. 请求取消的三个取消点分别解决什么问题?"搜索联想"属于哪一种?
  5. 为什么"浏览器取消"不等于"服务端停下"?取消信号要传到哪一层才有意义?
  6. 判断"是否需要幂等"的那个问题是什么?
S14-A19 · 重复请求与取消:用户连点三次,后端收到三次
https://www.clxhxhhr.top/posts/6551/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。