我的下单接口偶尔会给同一个用户重复下两笔一模一样的单、甚至重复扣款,可我代码里明明每次只下一单,排查半天才明白是客户端超时重试、用户手抖多点了几下,让同一个请求被发了好几次、而我的接口压根没防着重复的深度复盘

我有个下单接口:接到请求就创建订单、扣一次款,逻辑清清楚楚一次请求一个订单。我自己测试点一下下一单完美无缺。可上线后客服陆续投诉:有用户只下了一单,系统却给他创建了两三笔一模一样的订单、重复扣了好几次款。我翻日志发现重复订单都来自短时间内几乎同时到达、内容完全相同的多个请求。我以为是并发 bug、循环写错,检查下单逻辑都没问题。顺着重复请求往上游追才恍然:问题不在接口处理一次请求的逻辑,而在同一个下单操作被发了不止一次请求——客户端请求超时(其实服务端已处理成功只是响应慢/丢了)就自动重试、用户点了没反应手抖又多点几下、网络层重发;而我的接口对每个请求都老实新建订单扣款,默认每个请求都是独立的新操作,没防着这几个请求其实是同一操作的重复。复盘才懂:不可靠网络下同一操作被重复请求是必然发生的,超时不代表失败、客户端无法区分真失败和成功了没收到响应所以会重试;创建扣款天然不幂等,接口不幂等就重复执行。正解是接受重复必然、让接口幂等——给每个操作全局唯一幂等键(客户端生成重试沿用)、服务端去重(查键命中返回上次结果未命中才执行并记键)、查键执行记键用唯一约束分布式锁保证原子防并发穿透、前端防抖只是辅助服务端幂等才是根本。这篇复盘从故障现场讲到为何重复请求必然、幂等定义、怎么诊断,再到幂等键去重、原子保证、业务唯一约束的完整正解与幂等守卫,以及 MQ 消费、Webhook 回调、定时任务、重试放大等同类坑,和不可靠环境下操作恰好执行一次是奢望、健壮设计要让重复无害而非指望不重复的认知。

我的下单接口偶尔会给同一个用户重复下两笔一模一样的单、甚至重复扣款,可我代码里明明每次只下一单,排查半天才明白是客户端超时重试、用户手抖多点了几下,让同一个请求被发了好几次、而我的接口压根没防着重复的深度复盘

这是一次让我对"同一个请求只会来一次"这个我从没怀疑过的假设有了刻骨认知的事故。我有个下单接口:接到请求就创建一个订单、扣一次款。代码逻辑清清楚楚——一次请求,一个订单,一次扣款。我自己测试时点一下下一单,完美无缺,从没出过问题。

可上线后,客服陆续收到投诉:有用户反映自己只下了一单,系统却给他创建了两笔甚至三笔一模一样的订单重复扣了好几次款我把这些用户的请求日志翻出来,发现一个共同特征:这些重复的订单,都来自短时间内几乎同时到达的、内容完全相同的多个请求。我一开始以为是代码有并发 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 重发);创建、扣款、累加这类操作天然不幂等,接口默认"每个请求都是一次独立的新操作"、对每个重复请求都执行一次,就会重复下单重复扣款;正解是接受重复必然发生、让接口幂等——给每个操作一个全局唯一幂等键(客户端生成、重试沿用)、服务端处理前去重(查键命中就返回上次结果、未命中才执行并记键)、"查键+执行+记键"用唯一约束/分布式锁保证原子防并发穿透、前端防抖只是辅助服务端幂等才是根本。

我立下的几条规矩

这场"同一操作重复下单扣款"的事故,换来了我设计接口时,刻进骨子里的几条铁律:

  1. 不可靠环境下"同一操作被重复请求"必然发生(超时重试、重复点击、网络/MQ 重发),别指望请求只来一次。
  2. 超时不代表失败:客户端无法区分"真失败"和"成功了但响应丢了",所以会重试、造成重复。
  3. 创建/扣款/累加等天然不幂等的写操作,必须主动设计成幂等。
  4. 幂等核心:全局唯一幂等键(客户端生成、同一操作重试沿用)+ 服务端凭键去重。
  5. "查键+执行+记键"必须原子(数据库唯一约束/分布式锁/事务),防并发重复请求同时穿过。
  6. 重复请求直接返回上次结果,不重复产生副作用;业务唯一约束(订单号等)可作兜底。
  7. 前端防抖/置灰只能减少重复(前端不可靠),服务端幂等才是根本;幂等也是安全重试的前提。

附:我现在给写操作统一套的"幂等守卫"

这是我现在给任何有副作用的写操作固定套的一层"幂等守卫"——把这次踩坑的教训(幂等键去重、查执行记原子、重复返回上次结果)固化成了一个通用包装,让重复下单/扣款再没机会发生:

/**
 * 幂等守卫: 同一 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我把一大段资料和指令拼进 prompt 喂给大模型,内容少时一切正常,内容一多模型就开始不按我的要求做、像没看见我的指令一样,排查半天才发现 prompt 超了 token 上限、被默默截断、我放在末尾的关键指令根本没送进去的深度复盘

2026-6-3 5:56:44

技术教程

我在 catch 里把异常重新抛出去、想让它继续往上传,结果线上出错时堆栈信息只指向我重新抛出的那一行、完全看不到异常最初是在哪儿发生的,排查半天才发现是 throw ex 这个写法把原始堆栈给重置了的深度复盘

2026-6-3 6:08:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索