大促结束后的第二天上午,财务的消息把我从复盘的轻松里一把拽了出来:对账对不平,有几十个用户被重复扣了款,更夸张的是,有人同一笔订单在库里躺着三四条一模一样的记录。促销的喜悦还没散去,一盆冷水就浇了下来。
我以为会是个深藏的诡异 bug,结果一复盘,真相平淡得让人后背发凉。大促那一刻流量冲顶,下单接口响应变慢,用户在"提交订单"按钮上疯狂连点;雪上加霜的是,客户端和网关层都配了"超时自动重试"——于是同一个下单请求,被结结实实地送进后端处理了好几遍。而我们的下单接口,对这种"重复"毫无防备:它老老实实地把每一次请求都当成一笔崭新的订单,来一个建一个,来一笔扣一笔。
问题的根子,不在某一行代码的 bug,而在一个架构层面的缺失:整个下单链路,没有任何一处做了幂等性(Idempotency)保护。在单机、低并发的年代,这个疏漏可能一辈子都不会暴露;可一旦进入"网络会抖、请求会重试、用户会乱点、消息会重投"的分布式世界,"重复"就从一个小概率意外,变成了一种必然会发生的常态。
这篇文章,就是我把那次重复扣款事故从头复盘、再把幂等性这门课补扎实之后,整理出的一份接口幂等设计指南。它不堆砌理论,只讲那些能真正挡住"重复"的工程手段。
先纠正几个关于幂等的常见误解
动手之前,先把几个我曾经深信、后来被这次事故狠狠纠正的误解列出来。如果你也这么想过,这篇文章大概率能帮你提前堵上漏洞。
| 常见误解 | 真相 |
|---|---|
| 请求不会重复,前端点一次就发一次 | 分布式下"重复"是常态:用户连点、客户端重试、网关重试、MQ 重投,都会制造重复 |
| 加个分布式锁就等于幂等了 | 锁只解决"并发互斥",解决不了"先后两次重复请求";幂等的核心是去重,不是加锁 |
| 所有接口都得做幂等 | 要看操作语义:查询天然幂等,但"创建/扣减"这类写操作才是重灾区,要重点防 |
| 用数据库唯一索引就够了 | 唯一索引是兜底的最后一道防线,但只靠它会抛一堆异常、且覆盖不了所有场景 |
| 幂等键随便生成一个就行 | 幂等键必须由发起方在一次业务操作开始时生成并贯穿重试,后端生成毫无意义 |
| UPDATE 语句天然幂等,不用管 | "set 成绝对值"幂等,但"在原值上加减"绝不幂等,执行几次结果就差几次 |
第一件事:先想清楚,"重复"到底从哪来
要防住重复,得先看清它会从哪些环节钻进来。所谓幂等性,一句话定义就是:同一个操作,执行一次和执行多次,对系统产生的最终影响完全相同。一个幂等的下单接口,意味着哪怕同一个请求打进来十次,也只会创建一笔订单、只扣一次款。
而在分布式系统里,同一个业务请求被重复发起,几乎是无法避免的。把那次事故的链路摊开看,重复至少能从这么几个地方溜进来:
看清这张图就明白:重复请求不是"会不会发生"的问题,而是"每天都在发生、只是大多数时候后果不严重"的问题。尤其是那条 MQ 的线——主流消息队列为了保证"消息不丢",采用的都是 at-least-once(至少一次)语义,这意味着消息重复投递是协议层面就承诺了的"特性",消费端不做幂等,迟早出事。既然重复无法从源头根除,那唯一的出路,就是让接口自己具备"认出重复、并丢弃重复"的能力。下面就从最朴素到最通用,一层层把防线建起来。
第二件事:数据库唯一索引,最朴素也最可靠的兜底
所有幂等手段里,最朴素、却也最不该缺席的,是数据库唯一索引。它的思路很直接:既然怕同一笔业务被插入多次,那就给那个"业务上唯一"的字段加一个唯一约束,让数据库在最底层替你把关。
-- 给"业务唯一键"加唯一索引:同一个 order_no 只可能存在一条
ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);
-- 重复请求带着相同 order_no 再来插入时,数据库直接报 Duplicate entry,插不进去
INSERT INTO orders (order_no, user_id, amount) VALUES ('NO20260529001', 1001, 99.00);
它的价值在于:无论上层逻辑多么千疮百孔,只要这个唯一索引在,数据库就绝不会让两条相同业务键的记录同时落库。这是最后一道、也是最硬的防线,任何幂等方案都应该用它来兜底。
但只靠它远远不够,原因有二:其一,重复请求插入失败会抛出 Duplicate entry 异常,你的代码得专门捕获它、并把它翻译成"这是重复请求,返回上次的结果"而不是"系统错误";其二,它只能防住"插入"这一种场景,对"扣减库存""更新余额"这类没有插入动作的操作无能为力。所以它是底座,不是全部。真正通用的方案,要靠下面的幂等键。
第三件事:幂等键 + 去重表,通用方案的核心
幂等设计真正的核心,是幂等键(idempotency key):由请求发起方在一次业务操作的最开始,生成一个全局唯一的 key,并在后续所有的重试中都带着同一个 key。后端则维护一张"去重表",每处理一个请求,就先拿这个 key 去查、去抢占——抢到了才处理,抢不到说明是重复,直接返回上次的结果。
这里有个最容易被搞反的关键点:幂等键必须由发起方生成,而不是后端。因为只有发起方知道"这几次重试本质是同一个业务意图";如果后端在收到请求时才生成 key,那重试进来的每个请求都会拿到不同的 key,去重就彻底失效了。典型做法是"两步式":
// 第一步:进入下单页时,前端先向后端申请一个幂等 token
String token = idempotentService.applyToken(); // 例如返回一个 UUID,同时存入 Redis/去重表
// 第二步:提交订单时带上这个 token,后端校验并"消费"它
public OrderResult submit(OrderRequest req, String token) {
// 原子地"检查并删除"token:删除成功才是第一次,失败说明已被消费过(重复)
boolean firstTime = idempotentService.checkAndConsume(token);
if (!firstTime) {
// 重复提交:不再创建订单,返回上次的处理结果
return idempotentService.getResult(token);
}
OrderResult result = doCreateOrder(req); // 真正的业务逻辑只走一次
idempotentService.saveResult(token, result);
return result;
}
这套机制的精髓,在于 checkAndConsume 那一步必须是原子的"检查并消费"——绝不能拆成"先查一下在不在,再决定删不删",否则两个并发的重复请求会同时查到"token 还在",双双通过校验,幂等当场失效。把"判断重复"和"标记已处理"合并成一个不可分割的原子动作,是整个幂等设计的命门。这恰好引出了下一件事:用 Redis 来高效、原子地实现它。
第四件事:用 Redis 把"检查并消费"做成一个原子动作
上一节说幂等的命门是"检查并消费"必须原子。Redis 的 SET key value NX 命令天生就是干这个的——NX 意为"仅当 key 不存在时才设置",它把"判断是否存在"和"占坑"合并成了一次原子操作:
# SET ... NX:不存在则设置并返回 OK(我是第一个),已存在则返回 nil(重复请求)
# EX 给一个合理的过期时间,避免 key 永久堆积
SET idem:order:NO20260529001 "processing" NX EX 600
# 返回 OK → 抢占成功,是首次请求,放行去处理业务
# 返回 nil → 已被别人占了,是重复请求,直接拦截
用代码封装出来,逻辑非常清爽:
// 用 Redis SETNX 实现原子的幂等抢占
func (s *Svc) Submit(ctx context.Context, idemKey string, req Req) (Resp, error) {
ok, err := s.redis.SetNX(ctx, "idem:"+idemKey, "1", 10*time.Minute).Result()
if err != nil {
return Resp{}, err
}
if !ok {
// 抢占失败 = 重复请求。返回上次结果或一个明确的"处理中/已处理"提示
return s.loadResult(ctx, idemKey)
}
// 抢占成功,执行真正的业务(仍建议配合 DB 唯一索引兜底)
return s.doBusiness(ctx, idemKey, req)
}
这里有两个魔鬼藏在细节里。其一,过期时间(EX)必须设:不设的话,去重 key 会无限堆积,最终撑爆 Redis;但也不能太短,要覆盖业务处理的最长耗时,否则业务还没跑完 key 就过期了,重复请求又能进来。其二,SETNX 抢占成功后业务却失败了怎么办?——要有补偿:要么业务失败时主动删除这个 key 允许重试,要么把 key 的值设计成"处理中/已成功/已失败"的状态机,让重试请求能根据状态决定是等待、是返回结果、还是重新处理。幂等不是"挡住重复"这么简单,它要能优雅地回答重复请求"你上次那笔,现在到底什么状态"。
第五件事:分清操作语义,有些天生幂等,有些天生危险
做了这么多防护,还得回头看一个根本问题:不是所有操作都需要、或都能用同一种方式做幂等。搞清楚每类操作的"天生属性",能让你把防护的力气花在刀刃上:
| 操作类型 | 是否天生幂等 | 说明与对策 |
|---|---|---|
| 查询 (SELECT/GET) | ✅ 天生幂等 | 查多少次结果都一样,无需任何处理 |
| 删除 (DELETE) | ✅ 基本幂等 | 删一次和删多次,最终都是"它没了",结果一致 |
| 更新为绝对值 (set status='paid') | ✅ 幂等 | 覆盖成固定值,执行几次结果都相同 |
| 更新为相对值 (balance = balance - 100) | ❌ 不幂等 | 执行几次就扣几次!必须靠幂等键去重 |
| 新增 (INSERT) | ❌ 不幂等 | 重灾区。靠唯一索引 + 幂等键双重防护 |
这张表里最该记牢的是那两行红叉:"在原值上加减"和"插入新记录",是分布式系统里最危险的两类操作。我那次的重复扣款,正是栽在"balance = balance - amount"这种相对更新上——它执行一次扣 99,执行四次就扣 396,而代码本身没有任何语法错误。一个有用的设计直觉是:能用"绝对值更新"就别用"相对值更新"。比如扣库存,与其 stock = stock - 1,不如基于一个明确的版本号或预期值做条件更新(WHERE stock = 预期值),既天然防了重复,又顺手解决了并发覆盖。
第六件事:消息队列消费端,幂等的另一个主战场
接口幂等之外,还有一个同样高发、却更容易被忽略的战场:MQ 消费端。前面说过,主流消息队列都是 at-least-once 语义,同一条消息一定会有重复投递的可能(比如消费者处理完还没来得及 ack 就重启了)。所以,消费逻辑必须自带幂等,把"消息可能重复"当成默认前提。
// 消费端:用消息的业务唯一 id 去重,处理过的直接跳过
func handleMessage(msg Message) error {
// 用消息自带的唯一 id(不是 MQ 的投递 id)做去重键
ok, _ := redis.SetNX(ctx, "msg:done:"+msg.BizID, "1", 24*time.Hour).Result()
if !ok {
log.Info("duplicate message, skip", msg.BizID)
return nil // 重复消息:直接 ack 丢弃,不重复处理
}
return doProcess(msg) // 首次:正常处理
}
注意去重键要用消息的业务唯一 id(比如订单号、流水号),而不是 MQ 自己生成的投递 id——重投时投递 id 可能会变,只有业务 id 才能稳定标识"这是同一件事"。把那次事故的全部防线收个尾,下面这张决策树,是我沉淀下来的"这个接口要不要、怎么做幂等"速查表:
几条可以直接抄走的铁律
- 把"重复请求"当成必然,而非意外。用户连点、客户端/网关重试、MQ 重投,分布式下重复是常态。
- 幂等键由发起方生成,贯穿所有重试。后端生成的 key 对去重毫无意义。
- "检查并消费"必须原子。用 Redis
SET NX或 DB 唯一索引,绝不拆成"先查再写"。 - 去重 key 一定设过期时间,且时长要覆盖业务最长耗时;并设计好"业务失败后"的补偿。
- 能用绝对值更新就别用相对值加减,从源头消灭"执行几次差几次"的不幂等。
- 数据库唯一索引永远是最后一道兜底,任何方案都该叠加它。
- MQ 消费端默认消息会重复,用业务唯一 id(非投递 id)做消费去重。
顺手说清:分布式锁和幂等,到底是不是一回事
复盘会上有同事提议"加个分布式锁不就行了",这其实是个流传很广的误解,值得单独掰扯清楚。分布式锁解决的是"并发互斥",幂等解决的是"重复去重",两者解决的根本不是同一个问题。
设想两个一模一样的重复请求 A 和 B。如果它们同时打进来,分布式锁确实能让 A 先拿到锁、B 在外面等;可等 A 处理完、释放了锁,B 拿到锁之后呢?——它照样会再处理一遍,因为锁只保证"同一时刻只有一个在跑",并不记得"这件事刚才已经被干过了"。于是重复扣款照样发生,只是从"并发地扣两次"变成了"先后地扣两次"而已。
// 反例:只加锁不去重,挡得住并发,挡不住"先后两次"的重复
func (s *Svc) Submit(ctx context.Context, idemKey string, req Req) error {
lock := s.redis.Lock("lock:" + idemKey) // 加锁
defer lock.Unlock()
// ❌ 锁内没有任何"这单是不是处理过"的判断
return s.doBusiness(ctx, req) // A 释放锁后,B 进来照样再扣一次
}
真正幂等的写法,是在锁内(或干脆用 SETNX 把锁和去重合二为一)先判断"这个 key 是否已被消费",消费过就直接返回上次结果。说到底:锁是用来防"同时",幂等是用来防"重复";想真正杜绝重复扣款,你需要的是幂等,锁顶多是它的一个辅助手段。这也是为什么前面用 SET NX 那一招如此优雅——它一条命令就同时干了"互斥占坑"和"去重判断"两件事。
把 token 的状态机设计好,才算闭环
前面反复提到"返回上次的结果",这背后还藏着一个容易被做半截的设计:幂等 token 不该只有"存在/不存在"两种状态,而该是一个完整的状态机。因为重复请求打进来时,首次请求可能还在处理中、可能已成功、也可能已失败,这三种情况下你要给重复请求的回应是完全不同的。
| 首次请求的状态 | 重复请求该得到的回应 |
|---|---|
| 处理中 (processing) | 返回"正在处理,请稍候",或让其短暂等待轮询,绝不能再处理一遍 |
| 已成功 (succeeded) | 直接返回上次缓存的成功结果,让重复请求"看起来也成功了" |
| 已失败 (failed) | 可允许其重新发起(或返回失败原因),这正是"补偿"的入口 |
把 token 的值从一个干巴巴的 "1" 升级成 processing / succeeded / failed 的状态流转,幂等才真正闭环——它不再只是"挡住重复"这么粗暴,而是能对每一个重复请求负责任地回答"你关心的那笔业务,现在到底是什么状态"。我那次事故之所以后果严重,恰恰是因为下单链路连"这单处理过没有"都不记得,更别提把状态回答清楚了。
别忘了前端:把"重复"挡在源头也是一种性价比
聊了一路后端的硬防护,最后想补一个常被工程师轻视的环节:前端的"防连点",虽然挡不住所有重复,却是性价比极高的第一道软防线。它无法替代后端幂等(用户绕过页面直接打接口、网关重试它都管不着),但它能消化掉现实中占比最大的那类重复来源——用户因为响应慢而疯狂连点。
// 提交瞬间禁用按钮 + 进入态,从源头掐掉"连点"这一最高频的重复来源
let submitting = false;
async function onSubmit() {
if (submitting) return; // 已在提交中,后续点击直接吞掉
submitting = true;
btn.disabled = true; // 按钮置灰,给用户明确反馈
try {
await api.submitOrder(payload, { idempotencyKey }); // 仍带上幂等键
} finally {
submitting = false;
btn.disabled = false; // 无论成败都恢复,避免按钮卡死
}
}
这里的关键认知是:前端防重是为了"体验"和"减负",后端幂等才是为了"正确"。前者让那一大批"手快多点了几下"的请求根本发不出来,后端的去重表和数据库压力随之骤降;但你绝不能因为前端做了就省掉后端那道——因为真正会造成资损的那种重复(网关 failover、客户端 SDK 重试、MQ 重投),前端一个都拦不住。两道防线各司其职,缺一不可。
写在最后
回头看,那次重复扣款给我最深的一课,不是某个具体的技术招式,而是一种思维方式的转变:在分布式系统里,要默认"任何请求都可能被重复发起",并把这当成设计的前提,而不是出了事才去打补丁。网络会抖动、客户端会重试、用户会连点、消息会重投——这些都不是异常,而是这个世界运行的常态。
幂等性听起来是个很"高级"的词,但它的内核朴素得很:认出重复,然后丢弃重复。难的从来不是某一种实现——唯一索引、幂等键、Redis SETNX 都不复杂——难的是在设计每一个写接口时,都能条件反射般地多问自己一句:"如果这个请求被打进来两次,会怎样?"能习惯性地问出这句话,你就已经绕开了我当年踩的那个大坑。
那次对账对不平的早晨,财务那条消息至今我都记得清楚。但比起当时的狼狈,我更庆幸它来得不算太晚——它逼着我把幂等这门课从头补扎实,也让"重复是常态"这六个字,从此刻进了我写每一行写操作代码时的肌肉记忆里。
如果你正在维护一个还没做幂等的老接口,也不必焦虑到要推倒重来。最务实的路径是分三步走:先用一条数据库唯一索引把"插入类"的资损口子焊死,这是改动最小、收益最大的一步;再让发起方在关键写操作上补齐幂等键,用 Redis SETNX 把"检查并消费"做成原子;最后把前端防连点和 MQ 消费端去重补上。幂等不是一个要么全有要么全无的开关,它完全可以从最危险的那个接口开始,一条防线一条防线地加固。重要的不是一夜之间做到完美,而是从今天起,写每一个写接口时都先问自己那一句:它被打进来两次,会怎样?
—— 别看了 · 2026