接口超时后出现重复订单?排查 CDN 回源重试、客户端重发与幂等控制
本内容发表于:2026-11-03 10:28:51
浏览量
1043

超时后的两条请求路径携带相同订单,数据库入口通过幂等校验只接受一次创建。

用户点击提交订单,页面转了一会儿提示超时,再点一次却出现两笔订单。排查时最容易误判的是:客户端没有收到成功响应,就认为服务端没有执行。事实上,订单可能已经提交,只是返回途中连接中断,或者响应晚于代理的等待期限。

这类故障需要同时看请求经过的代理、客户端重试策略和业务写入规则。接入 CDN 只是多了一层需要核对的环节,不能看到重复请求就认定是 CDN 自动重试。本文提供设计与排查示例,未在你的生产环境实测。

先把超时与执行结果分开

一次写请求可能经历“到达源站、创建订单、提交事务、返回响应”。故障发生在不同阶段,客户端看到的都可能只是网络错误或 504。连接断开也不保证数据库事务已经回滚,具体取决于应用、驱动和事务处理方式。

先通过业务订单号、调用链标识和服务端记录确认结果,暂时不要反复点击或用脚本重放生产创建接口。若涉及支付,应核对商户订单与支付渠道的结果,不能以浏览器错误页面直接决定重新扣款。

HTTP 规范区分方法的幂等语义,并限制代理自动重试非幂等请求。POST 不应仅因遇到网络错误就被当成安全重试对象;应用若提供了明确的幂等机制,则可以在相应契约下处理重复提交。[1]

用两种标识还原请求链

为一次业务操作保留稳定的操作标识,为每次传输尝试保留独立的请求或追踪标识。用户重试同一次创建时,业务操作标识不变,但传输尝试可以有多个。这样才能区分“一个操作发送了三次”和“用户确实创建了三个订单”。

日志可记录脱敏用户标识、操作标识、尝试标识、入口时间、上游地址、状态、耗时和最终订单号。不要把完整 Authorization、Cookie、支付信息或请求正文写进普通访问日志;操作标识也应避免包含个人信息。

把客户端、CDN、负载均衡、网关和应用日志按时间串起来。源站多条记录可能来自代理重试,也可能是前端 SDK、移动网络恢复、用户双击、任务队列重复投递。下游事件消费者执行两次,也可能发生在 HTTP 请求完全正常之后。

核对实际产品的重试条件

不要把不同 CDN 的行为视为一致。应逐项确认:连接尚未建立时会不会换节点、请求已经发送后会不会重试、哪些方法允许重试、哪些状态会触发,以及超时预算如何计算。

例如,Nginx 的 proxy_next_upstream 可以控制切换上游的条件。其文档对非幂等方法有额外约束:请求已经发送给上游时,POST、LOCK、PATCH 通常不会被转交下一台上游,除非显式启用 non_idempotent。不要为了减少错误率随意加入这个参数。[2]

CloudFront 对源站连接和读取响应的处理也按阶段与方法区分。其文档描述,源站读取超时发生于 POST、PUT、PATCH、DELETE、OPTIONS 请求时,不会像部分读取方法那样重新发送请求。[3] 这不排除其他层或客户端重发,也不能据此推断所有连接失败场景相同。

若怀疑配置引入重放,先导出当前规则并对照日志,不要直接关闭全站重试。读取请求可能依赖故障切换;应按写接口路径和方法缩小改动范围,并明确回滚条件。

前端防重复点击只能减少入口噪声

提交后禁用按钮、显示处理中状态,能减少双击,但无法覆盖刷新、设备切换、SDK 自动重试和中途断网。页面恢复后应查询同一次业务操作的状态,而不是无条件重新创建。

对同一次操作,客户端需要在首次发送前生成并保存一个高熵标识,后续重试沿用它。若每次点击重试都换一个新键,服务端会把它们当作不同操作。用户确实发起新订单时,则不应误用上一笔的键。

Idempotency-Key 是常见接口约定,但不是在任意服务上加一个请求头就自动生效。后端必须实现相应语义,代理需按配置转发;浏览器跨源调用还要允许该自定义请求头,避免被 CORS 预检挡住。具体格式、作用域与保留期限以接口契约为准。

幂等判断必须与业务写入协调

一个基本设计是将“租户或用户范围、操作类型、幂等键”作为唯一组合,并保存规范化请求摘要、处理状态和业务结果。相同键却带来不同商品或金额时,应按约定拒绝,而不是返回一笔不相关的旧订单。

关键在并发控制:两个请求不能都先查到“没有记录”,然后各自创建订单。应通过数据库唯一约束、事务或可靠的原子机制保证只有一个请求取得处理资格。下面只是流程示意,不是可直接用于生产的实现:

按已认证用户与接口确定键的作用域
校验请求,并计算约定字段的请求摘要
原子取得幂等记录的处理资格
  已完成且摘要一致:返回约定的已保存结果
  正在处理:返回处理中状态或引导稍后查询
  同键但摘要不同:拒绝复用
取得资格:协调订单写入与幂等结果保存
对不确定的外部执行结果进行查询和对账

“查一次缓存再设置缓存”通常挡不住并发;缓存记录过期或丢失,也可能重新放行。涉及订单唯一性的约束,应在持久业务数据上落实,不能只依赖短期锁。多实例和多地域部署尤其要明确竞争控制的边界。

如果调用外部支付或库存系统,本地数据库事务无法自动覆盖对方的执行。应把稳定的业务标识传递给支持幂等的下游,并设计状态查询、补偿及对账。事务发件箱等方案可以协调本地数据和事件投递,但消费者仍需处理重复消息。

明确哪些结果会被保留

成功、参数校验失败、正在处理、内部错误和结果未知,不能简单使用同一套重放逻辑。接口应说明哪些状态会被记录、重复请求得到什么响应,以及客户端何时应该查询而不是继续重试。

Stripe 的文档提供了一个具体实例:其幂等机制会保存首次执行的状态码与响应体,包括部分失败结果,并检查重复请求参数是否一致;参数校验失败或与执行中请求冲突等情形则有相应例外。[4] 这些是该服务的契约,不能直接推断自建接口也如此。

幂等记录的保留时间需要覆盖业务允许的重试与恢复窗口,并评估存储成本。记录清理后,旧请求再次出现可能被视为新操作;订单层唯一约束与对账仍然重要。不要在文章示例里随意承诺“缓存一天就绝不会重复”。

返回已保存的结果时,必须重新检查调用者身份及访问权限。不同用户碰巧使用同一个键,不应读到彼此订单。包含个人信息的写接口响应也不应进入共享 CDN 缓存,幂等结果存储与边缘响应缓存不是同一种机制。

超时预算与重试次数一起设计

客户端、CDN、网关和业务下游都有等待期限。应让应用尽可能在外层超时前返回可理解的结果,或者采用受控异步任务:返回操作标识,再由客户端查询处理进度。返回“已受理”不能等同于订单或支付已经成功。

盲目延长所有超时会增加连接与资源占用,也无法消除响应丢失。自动重试应有次数上限、总时间预算与退避,并只用于符合契约的场景。对结果未知的写操作,先查状态通常比立刻创建另一笔更安全。

在预发布环境模拟最危险的时间点

使用测试账号、测试订单及支付沙箱,分别覆盖:请求未到达源站、写入前失败、事务提交后响应断开、同键并发、同键不同参数、幂等记录清理和下游执行结果未知。不要用生产扣款接口做故障注入。

验收不能只看 HTTP 返回成功,还应检查实际订单数量、支付流水、库存变化和事件处理次数。对同一操作的重复传输,应符合“一次业务效果”的契约;用户真正发起两次独立操作时,也不应被错误合并。

上线前备份代理规则和应用配置,先灰度观察重复订单、处理中积压、冲突率及超时分布。回滚重试策略相对容易,但删除幂等记录可能放行旧请求,撤掉数据库约束也可能引入新的重复写入,不能把这些动作当作普通清缓存。

已有重复订单应按审核和对账流程处理,避免直接批量删除。把根因定位到具体阶段:谁重发、何时提交、为什么第二次执行没有被识别。回答清楚这些问题,才能知道该改代理、客户端,还是业务写入逻辑。

参考资料

资料核验日期:2026 年 11 月 3 日。本文涉及的供应商行为以所引文档及实际部署版本为准。

  1. RFC 9110:Idempotent Methods

  2. Nginx:proxy_next_upstream

  3. CloudFront:Origin response timeout

  4. Stripe:Idempotent requests