我的下单接口偶尔会给同一个用户重复下两笔一模一样的单、甚至重复扣款,可我代码里明明每次只下一单,排查半天才明白是客户端超时重试、用户手抖多点了几下,让同一个请求被发了好几次、而我的接口压根没防着重复的深度复盘
这是一次让我对"同一个请求只会来一次"这个我从没怀疑过的假设有了刻骨认知的事故。我有个下单接口:接到请求就创建一个订单、扣一次款。代码逻辑清清楚楚——一次请求,一个订单,一次扣款。我自己测试时点一下下一单,完美无缺,从没出过问题。
可上线后,客服陆续收到投诉:有用户反映自己只下了一单,系统却给他创建了两笔甚至三笔一模一样的订单、重复扣了好几次款。我把这些用户的请求日志翻出来,发现一个共同特征:这些重复的订单,都来自短时间内几乎同时到达的、内容完全相同的多个请求。我一开始以为是代码有并发 bug、是循环写错了,反复检查下单逻辑都没问题——它确实是"一次请求一个订单"。直到我顺着这些重复请求往上游追,才恍然大悟:问题不在我的接口"处理一次请求"的逻辑上,而在于同一个下单操作,被发了不止一次请求!原因有好几种:客户端请求超时了(其实服务端已经成功处理了,只是响应慢/丢了),客户端以为失败就自动重试;用户点了下单按钮没反应,手抖又多点了几下;网络层重发。于是同一个"下单"意图,变成了好几个内容一样的请求打到我的接口;而我的接口对每一个请求都老老实实地新建一个订单、扣一次款——它默认了"每个请求都是一次独立的、新的操作",压根没防着"这几个请求其实是同一个操作的重复"。
故障现场:同一操作的多次重复请求,各自都被新建了订单
我把这个"一次操作变多次执行"的过程还原出来,问题一目了然:
用户的一次"下单"意图, 因为各种原因变成了多个请求:
- 客户端超时重试: 服务端其实已处理成功, 但响应慢/丢了, 客户端以为失败 → 重发
- 用户重复点击: 按钮点了没反应, 手抖多点几下 → 多个请求
- 网络层/代理重发、消息重投……
我的接口对每个请求的处理(没有任何防重):
请求1 → 新建订单A, 扣款一次 ✓(本意)
请求2 → 新建订单B, 扣款一次 ✗(同一操作, 又来一遍!)
请求3 → 新建订单C, 扣款一次 ✗(再来一遍!)
→ 同一个下单意图, 变成了 3 笔订单、扣了 3 次款 ✗✗✗
# 根因: 我的接口【不是幂等的】——
# 它默认"每个请求都是一次全新的、该独立执行的操作",
# 没有识别"这几个请求其实是同一个操作的重复"的能力
# 而在不可靠的网络/客户端环境下, "同一操作被重复请求"是【必然会发生】的
# 关键认知: 网络是不可靠的, 超时不代表失败, 重复请求无法避免
# → 接口必须自己具备"重复也只生效一次"的能力, 即【幂等】
看着"一个下单意图变成三笔订单",我才彻底明白:我的接口默认了一个根本不成立的假设——"每个到达的请求,都是一次独立的、应该被执行的新操作";可在不可靠的网络和客户端环境下,"同一个操作被发送多次请求"是必然会发生的(超时重试、重复点击、网络重发)。而我的接口对每个请求都照单全收、各建一单,没有任何"识别并消除重复"的能力——它不是幂等的。所谓"幂等(idempotent)",就是"同一个操作执行一次和执行多次,效果完全相同";而我的下单接口,执行 N 次就产生 N 笔订单,显然不幂等。不是我的下单逻辑写错了,是我没意识到"请求会重复"这个必然,从而没让接口具备"重复也只生效一次"的能力。
第一件事:搞懂幂等——不可靠环境下,重复请求必然发生,接口要能消重
冷静下来,我去把"接口幂等性设计"这一课认真补了,才明白这个"重复下单"的根源:
【为什么接口必须幂等——重复请求是必然, 不是意外】
不可靠环境下, "同一个操作产生多个请求"无法避免:
- 客户端超时重试: 网络慢/响应丢失时, 客户端无法区分"失败"和
"其实成功了只是没收到响应", 于是重试 → 重复请求
- 用户重复操作: 点了没反应就再点、刷新页面重提交
- 网络/代理/消息中间件的重发(at-least-once 投递)
→ "重复"是分布式/网络系统的常态, 必然发生
幂等(idempotent)的定义:
- 同一个操作, 执行一次 和 执行多次, 产生的效果【完全相同】
- 幂等接口: 重复请求进来, 只有第一次真正生效, 后续重复"识别为同一操作、
直接返回上次结果", 不会重复产生副作用(不重复下单/扣款)
天然幂等 vs 需要设计:
- 查询(GET)、把状态设为固定值(SET x=5)→ 天然幂等, 重复无妨
- 创建、扣款、累加(x += 1)、追加 → 天然【不幂等】, 必须自己设计成幂等
让"创建/扣款"类操作幂等的核心: 唯一标识 + 去重
1. 每个"操作"带一个【全局唯一的幂等键】(idempotency key,
由客户端生成, 同一操作的重试用同一个 key)
2. 服务端处理前先查这个 key 处理过没:
- 没处理过 → 执行 + 记录这个 key + 存结果
- 处理过 → 不再执行, 直接返回上次的结果
3. "查 key + 执行 + 记 key"要保证原子(用唯一约束/分布式锁/事务)
防止并发重复请求同时穿过
核心认知: 别指望"请求不会重复", 要让接口"重复了也只生效一次"
这一下点醒了我:我把"每个请求都是一次独立的新操作"当成了理所当然,可在不可靠的网络环境下,"同一个操作被重复请求"是必然会发生的——我没法消除重复,只能让接口有能力识别并消化重复。而这个能力,就是"幂等":给每个操作一个全局唯一的幂等键,服务端凭这个键去重——同一个键只真正执行一次,重复来的直接返回上次结果。创建、扣款这类天然不幂等的操作,必须主动设计成幂等,而不能默认"请求不会重"。不是去阻止请求重复(阻止不了),而是接受重复必然发生、并让接口对重复免疫。
第二件事:正解——用幂等键去重,让重复请求只生效一次
找到根因,正解就清晰了:给每个"操作"配一个全局唯一的幂等键(客户端生成,同一操作的重试用同一个键);服务端处理前先查这个键有没有处理过——没处理过就执行并记录键和结果,处理过就不再执行、直接返回上次结果;并用唯一约束/分布式锁保证"查键+执行+记键"的原子性,防止并发重复请求同时穿过。让创建/扣款这类不幂等操作,变得对重复免疫。
// 错误: 每个请求都新建订单 + 扣款, 没有去重 → 重复请求重复下单
public Order createOrder(OrderReq req) {
Order o = orderRepo.save(new Order(req)); // ✗ 来几次建几次
pay(req.getUserId(), req.getAmount()); // ✗ 扣几次款
return o;
}
// 正解: 幂等键去重 —— 同一操作只真正执行一次
public Order createOrder(OrderReq req, String idemKey) { // 客户端传幂等键
// 1) 先查这个幂等键处理过没(查到就直接返回上次结果, 不重复执行)
Optional done = idemStore.find(idemKey);
if (done.isPresent()) {
return done.get(); // ✓ 重复请求, 返回上次的订单
}
// 2) 原子地"占住"这个键 + 执行 + 记录结果(用唯一约束/事务防并发穿透)
try {
idemStore.insertUnique(idemKey); // 唯一约束: 并发时只有一个成功
} catch (DuplicateKeyException e) {
return idemStore.find(idemKey).orElseThrow(); // 别人正在/已处理 → 返回其结果
}
Order o = orderRepo.save(new Order(req)); // 真正执行(只此一次)
pay(req.getUserId(), req.getAmount());
idemStore.saveResult(idemKey, o); // 记录结果供重复请求返回
return o;
}
// 客户端: 同一个下单操作, 重试时复用【同一个】幂等键(别每次重试都生成新的)
String idemKey = uuid(); // 一次下单意图 = 一个 key, 重试沿用它
// 辅助: 也可用业务唯一约束兜底(如 订单号唯一、(用户,商品,时间窗)唯一)
// + 前端按钮防抖/置灰 减少重复点击(但前端不可靠, 服务端幂等才是根本)
这套做法的精髓,是给每个"操作"一个身份证(幂等键),让服务端能认出"这几个请求其实是同一个操作",从而只为它执行一次、其余重复直接返回已有结果。关键在于"查键+执行+记键"必须原子(靠数据库唯一约束、分布式锁等),否则两个重复请求会同时查到"没处理过"然后都执行。前端防抖只能减少重复、不能根除(前端不可靠),服务端的幂等才是真正的防线。不是去阻止请求重复,而是让接口凭幂等键认出重复、并对它免疫。
【接口幂等设计, 几条原则】
1. 不可靠环境下请求重复必然发生(超时重试/重复点击/网络重发), 别指望不重复
2. 创建/扣款/累加 等天然不幂等的操作, 必须主动设计成幂等
3. 核心: 全局唯一幂等键(客户端生成, 同一操作重试沿用同一个)+ 服务端去重
4. "查键 + 执行 + 记键"要原子(唯一约束/分布式锁/事务), 防并发重复穿透
5. 重复请求: 直接返回上次结果, 不重复产生副作用
6. 前端防抖/置灰只减少重复(前端不可靠), 服务端幂等才是根本防线
业务唯一约束(订单号/业务键唯一)可作兜底
第三件事:其他"默认只发生一次、其实会重复发生"的同类坑
顺着"不可靠环境下重复必然发生、要设计成对重复免疫"这条线,我把同类的坑都梳理了一遍,它们都源于"默认某事只会发生一次":
第一个,消息队列消费不幂等。MQ 多为 at-least-once 投递,同一条消息可能被投递/消费多次,消费逻辑不幂等就会重复处理(重复发券、重复入账)。消费要按消息唯一 id 去重。
第二个,Webhook/回调被重复触发。第三方回调(支付成功通知等)会重试、可能重复到达,处理回调要幂等,别重复发货/重复入账。
第三个,定时任务重复执行。多实例部署时同一个定时任务可能被多个实例同时跑,或补偿重跑,任务逻辑要幂等或加分布式锁。
第四个,重试逻辑放大了不幂等的危害。自己加了失败重试,可下游操作不幂等,一重试就重复执行。加重试的前提是被调操作幂等。
第四件事:不幂等 vs 幂等接口,一张表对照
我把"不幂等接口"和"幂等接口"在重复请求下的表现整理成一张表,这是我现在设计写操作接口的依据:
| 维度 | 不幂等接口 | 幂等接口(幂等键去重) |
|---|---|---|
| 重复请求来时 | 每个都执行, 重复产生副作用 | 只第一次执行, 重复返回上次结果 |
| 超时重试 | 重复下单/扣款 | 安全, 只生效一次 |
| 用户重复点击 | 多笔订单 | 一笔订单 |
| 能否安全加重试 | 不能(重试放大危害) | 能(幂等是重试的前提) |
| 依赖前提 | 赌"请求只来一次"(不成立) | 接受重复必然、自己消重 |
| 实现 | 无防重 | 幂等键+去重+原子保证 |
这张表让我看清:不幂等接口建立在"请求只会来一次"这个不成立的假设上,一遇到必然发生的重复就重复产生副作用;幂等接口则接受"重复必然发生",用幂等键让自己对重复免疫。而且幂等还是"安全重试"的前提——只有被调操作幂等,客户端/框架才敢放心重试。任何会产生副作用、又可能被重复请求的写操作,都该设计成幂等。
第五件事:我对"一次请求一次操作"的几个想当然
这次事故,本质是我把"每个请求只会来一次"当成了理所当然。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "一次操作就发一个请求,接口只会收到一次" | 超时重试/重复点击/网络重发让同一操作发多次 |
| "我代码一次请求建一单,就不会重复" | 重复在请求层面,你的单次逻辑没错但被调用多次 |
| "重复下单是我并发 bug" | 常是同一操作的多个重复请求各自执行所致 |
| "超时了就是失败了,重试没风险" | 超时可能其实成功了,重试就重复;要幂等才安全 |
| "前端按钮置灰防住重复就行" | 前端不可靠;服务端幂等才是根本防线 |
| "创建类接口不用考虑幂等" | 创建/扣款天然不幂等,最需要主动设计幂等 |
第六件事:设计写操作接口时,我现在的自检习惯
现在每当我设计一个会产生副作用的写操作接口、或排查"重复下单/重复扣款",我都会先按这张图问自己:
这张图的精髓,是"设计写接口先假设请求会重复(网络下必然),不幂等的操作就用幂等键去重让它对重复免疫"。设计就写操作配幂等键、服务端原子去重、用业务唯一约束兜底、排查就看重复下单是不是同一操作的多个重复请求各自执行了。这套习惯,让我从"赌请求只来一次"变成了"假设请求必然重复、让接口对重复免疫"——核心始终是:不可靠的网络和客户端环境下,"同一个操作被发送多次请求"是必然会发生的(客户端超时重试——超时不代表失败、用户重复点击、网络/MQ 重发);创建、扣款、累加这类操作天然不幂等,接口默认"每个请求都是一次独立的新操作"、对每个重复请求都执行一次,就会重复下单重复扣款;正解是接受重复必然发生、让接口幂等——给每个操作一个全局唯一幂等键(客户端生成、重试沿用)、服务端处理前去重(查键命中就返回上次结果、未命中才执行并记键)、"查键+执行+记键"用唯一约束/分布式锁保证原子防并发穿透、前端防抖只是辅助服务端幂等才是根本。
我立下的几条规矩
这场"同一操作重复下单扣款"的事故,换来了我设计接口时,刻进骨子里的几条铁律:
- 不可靠环境下"同一操作被重复请求"必然发生(超时重试、重复点击、网络/MQ 重发),别指望请求只来一次。
- 超时不代表失败:客户端无法区分"真失败"和"成功了但响应丢了",所以会重试、造成重复。
- 创建/扣款/累加等天然不幂等的写操作,必须主动设计成幂等。
- 幂等核心:全局唯一幂等键(客户端生成、同一操作重试沿用)+ 服务端凭键去重。
- "查键+执行+记键"必须原子(数据库唯一约束/分布式锁/事务),防并发重复请求同时穿过。
- 重复请求直接返回上次结果,不重复产生副作用;业务唯一约束(订单号等)可作兜底。
- 前端防抖/置灰只能减少重复(前端不可靠),服务端幂等才是根本;幂等也是安全重试的前提。
附:我现在给写操作统一套的"幂等守卫"
这是我现在给任何有副作用的写操作固定套的一层"幂等守卫"——把这次踩坑的教训(幂等键去重、查执行记原子、重复返回上次结果)固化成了一个通用包装,让重复下单/扣款再没机会发生:
/**
* 幂等守卫: 同一 idemKey 只真正执行一次, 重复请求返回首次结果
* 用法: idempotent(idemKey, () -> createOrderInternal(req))
*/
public T idempotent(String idemKey, Supplier action) {
// 1) 先查这个键有没有处理过(命中就直接返回上次结果, 不重复执行)
IdemRecord rec = idemStore.find(idemKey);
if (rec != null && rec.isDone()) {
return rec.getResult(); // ✓ 重复请求, 返回首次结果
}
// 2) 用唯一约束"抢占"这个键: 并发重复请求里只有一个能插入成功
try {
idemStore.insertPending(idemKey); // 唯一约束(idemKey 唯一)
} catch (DuplicateKeyException e) {
// 别人已抢到/正在处理 → 等待并返回其结果(或让客户端稍后重试)
return waitAndGetResult(idemKey);
}
// 3) 只有抢到键的这一个请求会真正执行业务 + 记录结果
try {
T result = action.get(); // 真正执行(全局只此一次)
idemStore.markDone(idemKey, result); // 记录结果供重复请求返回
return result;
} catch (Exception ex) {
idemStore.remove(idemKey); // 失败则释放键, 允许之后重试
throw ex;
}
}
// 业务接口只管写业务逻辑, 幂等交给守卫:
public Order createOrder(OrderReq req, String idemKey) {
return idempotent(idemKey, () -> {
Order o = orderRepo.save(new Order(req));
pay(req.getUserId(), req.getAmount());
return o;
});
}
这个守卫把我这次的教训钉死在了一个通用入口:它用幂等键的唯一约束保证"并发的重复请求里只有一个能真正执行",执行成功就记录结果、让后续重复请求直接拿到首次结果,执行失败则释放键允许重试。有了它,任何有副作用的写操作只要包一层 idempotent(key, ...),就自动对重复免疫——业务代码专心写业务逻辑,"消除重复"这件容易被遗漏的事被统一收拢、做对一次。把"不可靠环境下重复必然发生、要让操作对重复免疫"这个道理,沉淀成一道所有写操作必经的守卫,这是我对这次事故最实在的交代——毕竟,"会不会重复扣款"这种事,绝不该靠"但愿请求不会重"来保证。
写在最后
回头看,这场由"接口不幂等"引发的"重复下单扣款"事故,真正教给我的,远不止"加个幂等键去重"这一个技巧。它让我对"我们设计一件事的处理逻辑时, 常常默默假设了'它只会被触发一次、按我设想的理想次数发生'; 可在一个充满不确定、不可靠、会重试、会出错的真实世界里, '同一件事被触发多次'往往不是意外、而是必然; 当我们的处理没有为这种'必然的重复'做好准备时, 每一次本应'幂等'的操作, 都会被忠实地、重复地执行, 酿成我们没料到的后果",有了一次刻骨的体会。我栽跟头,是因为我把'理想情况下事情发生的次数(一次)', 当成了'现实中它实际发生的次数'——我设计接口时, 脑子里是"用户点一下下单, 我建一单"这个干净的一对一画面;我没意识到, 在真实的网络和用户行为里, "一次下单意图"会因为超时重试、重复点击、网络重发, 裂变成多个一模一样的请求; 而我的接口忠实地对每一个都执行了一遍;我防的是"处理逻辑写对", 却没防"处理被重复触发"——而后者在不可靠环境下是必然的。这让我领悟到一个关于"理想次数、必然重复与对重复免疫"的深刻认知:在任何不可靠、有重试、有不确定性的环境里, "一个操作恰好只被执行一次"是一种难以保证的奢望; 更现实、更安全的假设是"它可能被执行多次";因此, 健壮的设计不应建立在"它只会发生一次"这个脆弱前提上, 而应让操作本身具备"无论被触发多少次, 效果都和一次相同"的性质(幂等)——不是去保证"不重复"(往往做不到), 而是让"重复"变得无害;这是一种从"追求理想的不发生"到"容忍现实的发生、并使其无害"的思路转变: 与其徒劳地试图消灭重复, 不如坦然接受重复必然存在、并让系统对它免疫。这给了我一种看待"一切'会产生副作用、又可能被重复触发的操作'之事"时的清醒:每当我设计一个有副作用的操作时, 要追问"这个操作在现实中真的只会被触发一次吗?有没有重试、重复、重发会让它被触发多次?如果被触发多次, 会不会产生我不想要的重复后果?"——别把"理想的一次"当成"现实的一次"; 对那些重复会造成危害的操作, 主动让它幂等、对重复免疫, 而不是赌它不会重复;"假设重复必然发生、让操作对重复免疫(幂等)", 是设计对接口、也是做对一切'不可靠环境下的副作用操作'的关键。认清不可靠环境下重复请求必然发生、创建扣款天然不幂等、要用幂等键让接口对重复免疫——这,是我用一次重复下单扣款的事故,换来的、关于架构、也关于如何看待必然的重复的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次设计一个下单、支付、扣款这类有副作用的接口时,先想想"这个请求会不会因为重试或重复点击来好几次?来好几次会不会重复扣款?",并给它加上幂等键去重,那我对着那些"一笔下单变三笔"的重复订单折腾的大半天,就值了。
—— 别看了 · 2026