几乎每个前端都遇到过这种线上事故:用户在弱网下点了一下「提交订单」,转圈半天没反应,于是又点了一下------结果生成了两笔一模一样的订单。复盘的时候,后端说「你们前端没防重复点击」,前端说「我们置灰按钮了啊,是不是你们接口没做幂等」。
这场扯皮的根源,是双方对「幂等」这个词的理解不在一个层面。本文就从前端最熟悉的场景出发,把这一串问题讲清楚:什么是幂等、它是不是通用概念、SQL 怎么保证幂等、接口怎么设计幂等、GET 是不是一定幂等、POST 怎么做、以及------交易和表单到底是不是都得做幂等。
先把结论放前面,这也是我想让每个前端都记住的一句话:
幂等不是「阻止重复发生」,而是「重复发生也无所谓」。前端的按钮置灰、防抖,都只是降低重复的概率;真正的安全网,是服务端 + 数据库的幂等设计。前端的职责是「配合」,不是「负责」。
一、先用直觉理解幂等:做一次和做 N 次,结果一样
幂等(idempotent)最朴素的定义是:
一个操作执行一次,和执行很多次,对系统状态产生的最终效果是相同的。
它确实是个通用概念 ,最早来自数学(一个函数满足 f(f(x)) = f(x) 就是幂等的,比如取绝对值 abs(abs(x)) = abs(x)),后来被 HTTP、数据库、分布式系统、运维工具大量借用。在不同领域你都见过它的身影:
- 前端 :
new Set().add(x)加几次都只有一个 x;arr = [...new Set(arr)]去重跑几次结果一样;CSS 设display: none设两遍没区别。 - React :组件渲染应该是幂等的------同样的 props 和 state,渲染出同样的 UI,这也是 React 18 StrictMode 故意把渲染、effect 跑两遍来帮你揪出「不幂等副作用」的原因。
- 运维 :
kubectl apply(声明式,重复 apply 结果一致,幂等)对比kubectl create(命令式,第二次就报 already exists,不幂等);Terraform、Ansible 整套理念都建立在幂等之上。
这里就藏着一个能让前端瞬间理解的类比:
幂等 ≈ 声明式思维 :你声明「最终要变成什么样」,重复声明没有副作用(像 React 的
setState(5)、像kubectl apply)。 不幂等 ≈ 命令式/增量思维 :你描述「在当前基础上做一个变化」,每执行一次就叠加一次(像count++、像balance -= 100)。
别把幂等和「安全」「纯函数」搞混
这三个词经常被混用,但它们不是一回事:
| 概念 | 是否改变状态 | 重复执行的含义 | 典型例子 |
|---|---|---|---|
| 安全(safe) | 不改(只读) | 本来就不产生副作用 | GET 查询 |
| 幂等(idempotent) | 会改 | 改一次和改 N 次,最终状态相同 | PUT 覆盖、DELETE、设为绝对值 |
| 纯函数(pure) | 不碰任何外部状态 | 同样输入永远同样输出 | Math.abs、map 里的回调 |
记住一个关键区分:安全的一定是幂等的,但幂等的不一定安全。DELETE 会真删数据(不安全),但删一次和删三次最终都是「没了」这个状态(幂等)。
二、回到 HTTP:方法的「语义约定」,不是「框架保证」
HTTP 把方法分成了 safe(安全)和 idempotent(幂等)两个维度,这是 RFC 9110 明文规定的语义。RFC 9110 对幂等的定义是:
"A request method is considered 'idempotent' if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request." (如果用某个方法发送多次相同请求,对服务器意图产生的效果和发送单次请求相同,这个方法就被认为是幂等的。)
各方法的语义约定如下:
| 方法 | 安全 | 幂等 | 说明 |
|---|---|---|---|
| GET / HEAD | 是 | 是 | 只读 |
| OPTIONS / TRACE | 是 | 是 | 只读类 |
| PUT | 否 | 是 | 整体覆盖,覆盖几次结果一样 |
| DELETE | 否 | 是 | 删几次,最终都是「已删除」态 |
| POST | 否 | 否 | 默认语义是「新建/追加」,每次都产生新结果 |
| PATCH | 否 | 否 | 不保证(取决于它是增量还是覆盖) |
但这里有个前端最容易误解的点,必须强调:
幂等是「方法语义的约定 」,不是「框架替你实现好的事实 」。RFC 只是要求你「应当」这样实现;浏览器、缓存、重试中间件会假设你遵守了约定。
RFC 9110 的措辞很能说明问题------它说客户端可以在连接中断后自动重试幂等请求(因为重试不会有额外伤害),但同时规定:
客户端不应当(SHOULD NOT)自动重试非幂等方法的请求;代理服务器更是绝不能(MUST NOT)自动重试非幂等请求。
也就是说,「方法是不是幂等」直接决定了「这条请求能不能被安全地重放」。而网络世界里,重放是常态:超时重试、代理重试、客户端库自动重试......一个没做幂等的 POST,在这些重放面前就是裸奔。
三、GET 接口一定幂等吗?
这是个高频问题,准确答案是:GET 在规范上被定义为幂等且安全,但「实际是否幂等」取决于你怎么实现它。 GET 不是有什么魔法让它天生幂等,而是规范要求你「应当」让它无副作用。
有两个必须澄清的误区:
误区一:以为「响应内容相同」才叫幂等。
幂等说的是对服务器状态的副作用 ,不是响应体。一个 GET /api/now 每次返回的时间都不一样,但它依然是幂等的,因为它没改变服务器状态。RFC 9110 也明确说了,幂等性「只针对用户请求的意图」------服务器为每个请求记日志、留版本历史这类附带副作用,不破坏 它的幂等性。MDN 举的例子很形象:连续两次 DELETE 第一次返回 200、第二次返回 404,响应不同,但它仍然是幂等的。
核心论断:幂等 ≠ 每次响应相同。幂等只关心「重复执行有没有改变系统的最终状态」。
误区二:以为把删除、计数做成 GET 也没事。
只要你给 GET 加了副作用(计数器 +1、抽奖、GET /api/deleteUser?id=1),它语义上还挂着「安全幂等」的牌子,实现上却在偷偷改状态------这时候坑你的不是 HTTP,是你自己违背了契约。历史上有过经典教训:有人把「删除」做成可点击的 GET 链接,结果浏览器的预取(prefetch)、爬虫、企业网关的探测请求把这些链接挨个「点」了一遍,数据被批量删光。因为所有这些中间件都理所当然地认为 GET 是安全的、可以随便重放的。
所以对前端的实操建议很简单:任何会改变服务端状态的操作,都不要用 GET。 让 GET 保持纯查询,它就是幂等的。
四、对于 SQL:怎么写出幂等的写操作
接口的幂等,最终往往要落到数据库这一层来兜底。所以先看 SQL 层面:哪些写法天生幂等,哪些不是。
天生不幂等的写法(重复执行会出事):
sql
-- 相对增量:执行两次就扣两次,余额直接错乱
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 裸 INSERT:执行两次插入两条一模一样的订单
INSERT INTO orders (order_no, user_id, amount) VALUES ('NO123', 1, 100);
天生幂等的写法(重复执行无所谓):
sql
-- 设为绝对值:设几次结果都一样
UPDATE account SET status = 'frozen' WHERE id = 1;
-- 按主键删除:删几次,最终都是「这条没了」
DELETE FROM orders WHERE order_no = 'NO123';
看出规律了吗?
写幂等 SQL 的核心心法:用「绝对赋值 」代替「相对增量」,用「唯一约束 + 冲突处理」代替「裸 INSERT」。
具体有这么几件趁手的武器:
1. 唯一索引/唯一约束(最根本的兜底)。 给业务唯一键(订单号、请求 ID)建 UNIQUE 索引,重复插入会被数据库直接拒绝。这是不依赖应用层逻辑的最后一道防线。
sql
ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);
2. Upsert(存在则更新/忽略,不存在则插入)。 各数据库都有原生语法,重复执行结果一致:
sql
-- MySQL:命中唯一键就走 UPDATE 分支(这里写成什么都不真正改)
INSERT INTO orders (order_no, user_id, amount)
VALUES ('NO123', 1, 100)
ON DUPLICATE KEY UPDATE order_no = order_no;
-- PostgreSQL:命中冲突就什么都不做
INSERT INTO orders (order_no, user_id, amount)
VALUES ('NO123', 1, 100)
ON CONFLICT (order_no) DO NOTHING;
3. 状态机 + 条件更新(CAS,交易场景最常用)。 把「改状态」和「触发后续动作」绑定到 affected rows 上:
sql
UPDATE orders SET status = 'paid'
WHERE order_no = 'NO123' AND status = 'unpaid';
-- 第一次:affected = 1(确实是我把它从未支付改成已支付),才去触发发货
-- 第二次:affected = 0(已经是 paid 了),不重复发货
这个模式的精髓是:靠「影响了几行」来判断这次操作是不是真的生效,而不是无脑执行后续逻辑。重复扣款 vs 条件更新的差别一目了然:
把常见的坑和改法列成一张表:
| 不幂等写法 | 问题 | 幂等改法 |
|---|---|---|
balance = balance - 100 |
重复执行重复扣 | 配合状态条件,或记流水 + 唯一约束 |
裸 INSERT |
重复执行插多条 | 唯一索引 + ON DUPLICATE / ON CONFLICT |
无条件 UPDATE 后触发副作用 |
重复触发发货/发券 | WHERE 加状态条件,看 affected rows |
五、接口层怎么设计幂等(重点说 POST)
数据库兜底之上,接口层要有一套主动的幂等机制。这里的核心思路是纵深防御------每一层都拦掉一部分重复,最终由服务端 + 数据库确保万无一失:
前面几层都只是「减少概率」,真正「保证幂等」的是后两层。POST 默认不幂等,要让它幂等,业界最通用的方案是幂等键(Idempotency Key)。
方案一:客户端生成幂等键(最通用)
这套机制最经典的工业级实现是 Stripe 的 Idempotency-Key 请求头。它的工作方式,Stripe 官方文档讲得很清楚:
- 客户端为每一笔操作生成一个唯一 key(推荐用 V4 UUID)。
- 服务端处理第一次请求时,把结果(状态码 + 响应体)连同这个 key 一起存下来,无论成功还是失败。
- 之后任何携带相同 key 的请求,直接返回第一次存下的结果(包括 500 错误也照样返回同一个),不重复执行业务逻辑。
时序上是这样的:
前端要配合的写法,关键只有一条:重试必须复用同一个 key,否则换了 key 就等于一笔全新的请求,幂等直接失效。
ts
// 进入结算页时生成一次,绑定到「这一单」的整个生命周期
const idempotencyKey = crypto.randomUUID()
async function submitOrder(payload: OrderPayload, retries = 2): Promise<OrderResult> {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 关键:重试复用同一个 key,服务端才能识别这是「同一笔」
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
} catch (err) {
if (retries > 0) return submitOrder(payload, retries - 1)
throw err
}
}
一个常被忽略的细节:Stripe 还会校验同一个 key 的请求参数是否一致,如果你用同一个 key 却换了请求体,它会直接报错,防止「key 复用」被误用。
方案二:服务端预发 token(传统表单防重)
另一种常见于传统表单提交的模式:进入页面时先向服务端要一个一次性 token,提交时带回去,服务端「校验并消费」这个 token,消费过就拒绝。
ts
// 1. 进入表单页,先要一个一次性 token
const { token } = await fetch('/api/order/token').then(r => r.json())
// 2. 提交时带上它
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, token }),
})
// 3. 服务端:原子地「校验 + 删除」token,
// 第一次消费成功就下单;token 已不存在就当作重复提交拒绝
两种方案本质相同:都是给「这一次操作」一个全局唯一的身份,让服务端能识别出「这两个请求其实是同一件事」。区别只是 key 由谁生成、何时生成。
服务端落地的两个关键点
幂等键的存储不是简单地「查一下有没有、没有就插」------那中间有并发窗口。两个带相同 key 的请求同时到达,可能都查到「没有」,然后都去执行。所以要靠原子操作抢占:
ts
// 用 Redis SET NX + TTL 抢占式占位,保证同一个 key 只有一个请求能进入执行
const ok = await redis.set(`idem:${key}`, 'processing', 'NX', 'EX', 600)
if (!ok) {
// 已有同 key 在处理或已处理:返回已存结果,或提示「请勿重复提交」
return getStoredResultOrConflict(key)
}
// 抢到锁后再执行业务 + 落库(和数据库唯一约束形成双保险),完成后回写结果
两个要点:一是用 SET NX 或数据库唯一约束这类原子操作 保证「同一个 key 只被处理一次」;二是给 key 设置 TTL(Stripe 是 24 小时后清理),别让它无限堆积。
六、是不是交易/表单场景就一定要做幂等?
这是用户最实际的疑问。我的答案是:不要按「场景名字」来判断,要按「重复执行的后果」来判断。 判断标准只有一条:
这个操作被重复执行,会不会产生多余的、不可接受的副作用?
对照下来:
- 必须做:支付、下单、转账、扣库存、发券、积分变动、创建唯一资源、发通知/短信、计费。这些都是「写 + 有业务后果 + 重复代价高」的典型。
- 天然幂等或可以不做 :纯查询(GET,天生幂等);「保存草稿」「改个人资料」这类覆盖式写入(后写覆盖前写,重复保存影响极小);点赞可以做成幂等的 toggle。
所以更准确的说法是:
不是「交易场景才做幂等」,而是「任何会被重复触发、且重复有害的写操作都要做」。交易只是其中代价最高、最典型的那一类,所以最常被拿出来举例。
而「会被重复触发」几乎是分布式系统的默认状态,不是小概率异常。重复请求的来源远比前端想象的多:
- 用户手抖双击、刷新页面重新提交表单;
- 弱网超时后,用户或代码发起重试;
- HTTP 客户端库、消息队列(at-least-once 投递)自带的自动重试;
- 浏览器预取、企业网关探测把 GET 链接重放;
- React 18 StrictMode 下 effect 被故意调用两次;
- 移动端切后台再回前台,请求被重发。
正因为重复无法从源头杜绝,幂等才成了「写操作」的标配防御,而不是可选项。
七、前端到底该做什么、不该幻想什么
最后回到前端视角,把职责边界划清楚。
前端能做的(降低概率 + 改善体验,但都不等于幂等本身):
ts
// 1. 提交后立刻置灰 + loading,挡住手抖双击
button.disabled = true
// 2. 防抖,挡住短时间内的连续触发
const onSubmit = debounce(handler, 300)
// 3. 生成并复用幂等键,让重试是「安全重试」
const idempotencyKey = crypto.randomUUID()
还有一个常被忽视但很有效的体验层手段------PRG(Post / Redirect / Get):表单提交成功后用重定向跳到结果页,这样用户刷新的是 GET 结果页,而不是重发那个 POST,从源头上减少「刷新导致重复提交」。
前端必须想明白的边界(这条最重要):
前端所有的「防重复」都只能减少重复的概率 ,无法消除重复。只要请求离开了浏览器,它就可能在网络层、代理层、或被用户行为以你不知道的方式重放。真正的幂等保证必须在服务端落地,前端的角色是「带上稳定的幂等键去配合」,而不是「独自负责」。
最典型的反面教材,就是开头那个「我们置灰按钮了啊」------按钮置灰在这些情况下统统失效:弱网下请求已发出但 UI 还没响应、多标签页同时操作、有人直接拿 Postman/curl 打接口、恶意重放。把 UX 层的防抖当成幂等方案,是这个领域最常见的认知错位。
给前端的配合清单:
- 写操作一律用 POST/PUT/DELETE,绝不用 GET 改状态。
- 涉及钱/库存/通知的提交,主动带上
Idempotency-Key,重试复用同一个 key。 - UX 层做好置灰、防抖、PRG,但心里清楚这只是「第一道、也是最弱的一道」防线。
- 和后端对齐:这个接口的幂等键叫什么、放 header 还是 body、key 的有效期多久、重复提交时返回什么(是返回上次结果,还是报「请勿重复提交」)。
八、可以抄进团队规范的几条
- 幂等 = 做一次和做 N 次结果相同,它关心的是「对状态的副作用」,不是「响应是否相同」。
- GET 不改状态,让它保持只读,它就是幂等的;改状态用 POST/PUT/DELETE。
- SQL 优先绝对赋值、唯一约束、条件更新(CAS),少用相对增量和裸 INSERT。
- POST 用幂等键做幂等,客户端生成 UUID 或服务端预发 token,重试复用同一个 key。
- 服务端用原子操作(SET NX / 唯一约束)抢占,并给 key 设 TTL。
- 按「重复是否有害」决定要不要做幂等,而不是按场景名字;涉及钱、库存、通知的一律做。
- 前端只负责降低概率和带 key 配合,服务端 + 数据库才是幂等的最终责任方。
一句话收尾:重复请求在分布式世界里是常态,不是意外。与其前端拼命「不让它发生」,不如让系统「即使发生了也无所谓」------这才是幂等的真正意义。
参考与数据源
- RFC 9110: HTTP Semantics(Idempotent Methods) ------ 幂等(9.2.2 节)与安全(9.2.1 节)方法的权威定义,以及客户端/代理重试约束
- MDN: Idempotent(词条) 与 MDN: Safe ------ GET/POST/DELETE 幂等性的直观例子
- Stripe API: Idempotent requests ------ 工业级幂等键实现(key 生成、结果存储、参数校验、24 小时清理)
- MySQL: INSERT ... ON DUPLICATE KEY UPDATE
- PostgreSQL: INSERT ... ON CONFLICT