一个"提交订单"的按钮在弱网下被连点三次,后端创建了三笔订单。前端的"点击后禁用按钮"没起作用——因为用户在按钮禁用前就点完了,而网络重试又在更后面加了两笔。查下来三笔订单金额一样、时间接近,靠人工退款。
重复请求与取消是前端两个"看起来简单"的问题,这个"层"的判断有个简单依据:看你能否枚举"所有能触发这个操作的路径"。按钮层只能覆盖"这一个按钮",而重试、多入口、多页签不在其中。
而它们都必须在"请求层"而不是"按钮层"解决:按钮禁用只能防"同一个人在同一个界面上手抖",防不住"重试、多个标签页、多个入口"。
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. 自测
- 重复请求的三种来源里,哪一种前端"完全不知道"?为什么它最难查?
- 为什么"按钮禁用"不能作为正确性保障?三层去重里哪一层是唯一可靠的?
- "每次请求生成 UUID"为什么让幂等失效?幂等键该在哪一刻生成?
- 请求取消的三个取消点分别解决什么问题?"搜索联想"属于哪一种?
- 为什么"浏览器取消"不等于"服务端停下"?取消信号要传到哪一层才有意义?
- 判断"是否需要幂等"的那个问题是什么?