写入接口的幂等性:请求超时后,服务端怎样认出同一件事

从业务已提交但响应丢失的场景出发,设计幂等键作用域、请求指纹和事务边界,并处理异步执行、外部副作用、保留期与重试冲突。

·6 min
携带同一幂等键的多个请求进入唯一记录,复用同一份业务结果回执。

客户端发送创建订单请求。服务端提交了事务,响应却在网络中丢失。客户端超时后重试,服务端必须认出两次发送表达的是同一笔订单,否则它会再创建一笔。

幂等协议给业务操作分配稳定身份,并规定服务端怎样处理重复请求。RFC 9110关注多次相同请求对服务端产生的预期效果,并不要求每次响应完全一致。POST 请求头里的一个键只是协议输入,数据库约束和事务才落实业务语义。

一个业务意图只生成一个键

用户点击“创建订单”时,客户端生成一个键,之后因超时产生的重试继续使用这个键。若每次发送都重新生成 UUID,服务端仍然会把它们看成不同操作。

服务端应把键绑定到可信身份和操作类型,例如:

(tenant_id, principal_id, operation, idempotency_key)

具体作用域由业务决定。代表同一业务主体的多个调用方是否共享操作,需要明确约定。不能只以全局键作为查询条件,否则一个调用方可能误取其他人的结果。重放响应前也要重新检查当前授权,尤其是权限已经撤销的情况。

同一个键再次到来时,还要检查请求是否相同。可以对规范化后的业务参数计算指纹,排除追踪 ID、发送时间等传输字段,同时纳入目标对象、金额、币种等影响业务结果的字段。JSON 的属性顺序不能让同一份参数产生不同解释;服务端默认值和 API 版本也应进入规范化规则。

同键不同指纹应返回明确冲突。在下文的接口约定中使用 409,这是应用选择,不是声称所有服务都必须使用这个状态码。

在一笔数据库事务中提交业务和结果

如果业务副作用仅发生在同一个关系数据库里,幂等记录和业务变更可以在同一事务提交。下面是 PostgreSQL 风格的简化结构示例,不是生产迁移脚本:

CREATE TABLE idempotency_records (
  tenant_id bigint NOT NULL,
  principal_id bigint NOT NULL,
  operation text NOT NULL,
  idempotency_key text NOT NULL,
  request_hash text NOT NULL,
  response_status integer,
  response_body jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (
    tenant_id, principal_id, operation, idempotency_key
  )
);

请求入口还需要限制键长度与格式,控制响应大小,并设计保留期。对于敏感数据,可以只保存结果对象引用,读取时重新鉴权;不要默认永久保存完整响应。

同步事务协议可按以下方式实现。这里假设 PostgreSQL READ COMMITTED,遇到语句失败回滚,绝不在业务提交与幂等结果更新之间另开事务:

authenticate and authorize
canonicalize input and compute request_hash

BEGIN
  INSERT idempotency key
  ON CONFLICT DO NOTHING
  RETURNING idempotency_key

  if inserted:
      apply business change in this transaction
      save completed response in idempotency row
      COMMIT
      return response
  else:
      SELECT existing record in a new statement
      compare request_hash
      if different:
          ROLLBACK
          return conflict
      if completed result is absent:
          ROLLBACK
          return controlled error and investigate
      COMMIT
      return stored result after authorization checks

唯一约束处理并发竞争。输掉插入的一方不能在同一条语句的旧视图中假定能取得另一事务刚提交的完整结果,因此协议把读取写成下一条语句。隔离级别改变后,等待、可见性和重试行为需要重新确认。

在这个同步协议中,尚未完成的记录不会单独提交。业务失败则连同幂等记录一起回滚;业务成功则与可重放结果一起提交。因此进程在提交前崩溃,不应留下一笔已完成业务却没有幂等结果的半成品。

数据库自身的业务唯一约束仍然有用。同一个用户也可能用两个不同幂等键重复提交同一业务操作;传输层去重不能取代订单号、外部业务号等领域约束。

长任务需要租约和执行代号

持续较久的任务可能需要先提交接收记录,返回任务 ID,再异步执行。此时 processing 是持久状态,worker 崩溃后必须有人接管。

旧 worker 暂停后恢复时,新 worker 可能已经接管任务。租约配合递增执行代号,可以要求每次状态更新验证当前代号。外部副作用也要接受同一代号的隔离,或使用稳定的幂等身份。否则两个 worker 可能各调用一次外部服务,最后却只留下一个“完成”状态。

客户端查询任务时,需要能区分仍在处理、已完成、可重试失败与结果未知。服务端可以返回 202 和状态查询入口,但应把轮询间隔及最终结果保留时间写入契约。

外部服务留下结果未知窗口

订单库和支付服务无法因为调用代码写在同一个函数里就一起提交。向外部服务发送请求后超时,本地数据库不知道对方是否完成操作。

如果对方支持幂等键,应使用稳定的业务操作 ID,并理解对方的保留期与同键异参规则。如果对方支持查询,先核对结果再决定重试。两者都没有时,需要保留不确定状态和人工对账流程;自动重试可能重复执行,自动回滚本地记录也无法撤销远端结果。

事务 outbox 可以把“本地业务已提交”和“存在一条待发送消息”写入同一本地事务,降低双写不一致风险。但发送成功后、标记已发送前崩溃仍可能导致重复投递,下游必须继续去重。AWS 的 outbox 模式说明明确列出了重复消息这一约束。

用保留期定义重试窗口

幂等记录过期后,同一个键再次到达,系统可能把它当成新请求。因此保留期必须覆盖可接受的重试窗口。离线客户端、人工补单和队列延迟都会影响这个窗口。

对不可重复的业务,应保留更持久的业务身份约束,不能只依靠会过期的缓存键。错误结果也需要分类:输入验证失败是否占用键,事务前的临时故障能否重试,业务拒绝是否重放,都要写清楚。

验收测试应覆盖提交前中断、提交后丢响应、相同键并发到达、同键更换参数,以及外部服务完成后连接超时。测试需要核对最终业务记录和副作用次数,接口返回成功只是其中一项观测。每个故障点都应对应明确的状态、重试行为和人工处置入口。