用户点击“创建订单”后,页面一直没有响应,于是又点了一次。服务端可能已经创建成功,只是响应在网络途中丢失。如果第二次请求再创建一张订单,就会出现重复业务数据。

这类问题不能只靠按钮防连点解决。客户端超时重试、网关重试、消息重复投递,都可能让同一业务意图到达服务端多次。接口需要明确:多次处理同一业务意图,最终应产生什么结果?

先分清 HTTP 方法和业务效果

HTTP 规范把幂等定义为:对同一请求执行一次或多次,其预期的服务端效果相同。规范将 PUT、DELETE 和安全方法列为幂等方法;POST 本身不保证幂等。这说的是方法语义,不能替具体业务实现兜底。例如,订单接口即使用了 PUT,如果每次调用都生成新订单,业务上依然会重复创建。

因此,设计时要先找出业务意图的身份。对于“创建一张订单”,这个身份可以是客户端生成的一次性请求号,也可以是业务上已经存在的唯一订单号。

用幂等键识别同一次操作

客户端在发起创建请求前生成一个幂等键,例如 Idempotency-Key: 8b6f...。超时重试时沿用同一个键;用户真正发起新的订单时使用新键。

服务端收到请求后,按“调用方 + 接口 + 幂等键”查找处理记录,并校验请求参数摘要:

  1. 没有记录:尝试占用这个键,执行业务操作。
  2. 已成功,且参数一致:返回先前保存的业务结果。
  3. 正在处理:返回可重试状态,或等待并查询最终结果。
  4. 键相同但参数不同:拒绝请求,避免把两次不同操作误认为一次。

幂等键的作用域和有效期要写进接口约定。有效期至少要覆盖预期的重试窗口;过期之后是否允许再次执行,也必须符合业务规则。对订单这类有持久唯一标识的对象,业务唯一约束比单纯依赖一个短期缓存键更可靠。

并发请求需要数据库约束兜底

下面这段逻辑有竞争窗口:

1
2
3
查询幂等键不存在
创建订单
保存幂等记录

两个请求可能同时查到“不存在”,然后各自创建订单。正确的做法是在持久存储中为幂等键建立唯一约束,让“占用键”成为原子竞争。订单写入与幂等记录的成功状态应尽量放在同一数据库事务中提交;若业务本身有订单号,也应为订单号建立唯一约束。

一次简化的处理流程是:

1
2
3
4
5
6
开始事务
插入幂等记录(唯一键冲突则读取既有记录)
创建订单
将订单 ID 和处理结果写入幂等记录
提交事务
返回订单结果

真正的实现还要处理事务回滚、处理中状态超时,以及服务在提交后、返回前崩溃的情况。后者正是幂等记录保存结果的价值:客户端重试后可以查到第一次已经提交的订单。

外部副作用也要纳入设计

如果创建订单后还要发消息或调用其他服务,仅保证本地数据库不重复写入是不够的。应为后续事件指定稳定的事件 ID,并让消费者按事件 ID 去重。需要保证数据库变更和“待发送事件”一起落地时,可以采用事务性 outbox:在同一事务内写订单和事件记录,再由独立任务投递。这样即使消息重复投递,消费者仍可识别同一事件。

这里追求的是业务效果只发生一次,不意味着网络请求或消息只传输一次。传输层允许重试,业务层负责识别重复。

一个实用的检查清单

  • 重试时是否沿用原幂等键?
  • 同键但参数不同的请求会怎样处理?
  • 并发到达时是否有唯一约束,而不只是“先查询再插入”?
  • 提交成功但响应丢失后,能否找回原结果?
  • 幂等记录过期后,业务唯一标识是否仍能阻止重复创建?
  • 下游消息和外部调用是否有各自的去重依据?

把这些问题说清楚,接口的重试策略才有可靠的落点。

参考:RFC 9110 §9.2.2:Idempotent Methods。