API 幂等设计一次讲透:幂等键、过期策略、响应回放策略

5 阅读6分钟

凌晨 2 点,财务群里跳出一句话:“今天有用户只点了一次支付,却扣了两次。”
这种事故通常不是业务逻辑不会写,而是接口在“重试”这件事上没有设计好。只要网络抖一下、网关超时一下、客户端自动重发一下,系统就会迎来重复请求风暴。

这篇文章就做一件事:把 API 幂等设计的三件套讲透,并给你一套可以直接落地的实现路径。

先把“幂等”翻译成人话

幂等:同一个请求执行一次和执行多次,系统最终状态应该一致。
生活类比:电梯里的“关门”按钮,你狂按 10 次,门不会关出 10 层厚度。
迷你案例:用户下单时连续点了 3 次“立即支付”,最终只能产生 1 笔订单和 1 次扣款。

注意一个常见误区:幂等不是“绝不失败”,也不是“绝对只执行一次”,而是“重复请求不会放大副作用”。

flowchart TD
    A[收到请求] --> B{是否带幂等键}
    B -- 否 --> C[按普通请求处理<br/>高风险接口不建议]
    B -- 是 --> D[查询幂等存储]
    D --> E{是否已有记录}
    E -- 否 --> F[写入 PROCESSING 锁]
    F --> G[执行业务事务]
    G --> H[保存响应快照+状态]
    H --> I[返回首次响应]
    E -- 是 --> J{请求参数哈希一致?}
    J -- 否 --> K[返回冲突错误]
    J -- 是 --> L{记录状态}
    L -- SUCCEEDED --> M[回放历史响应]
    L -- PROCESSING --> N[返回处理中/稍后重试]
    L -- FAILED_FINAL --> O[回放确定性失败响应]

看完这张流程图,你下一步要做的是先明确你的接口状态机(PROCESSING/SUCCEEDED/FAILED_FINAL),再开始写代码。

幂等键:给请求发一张“票根”

幂等键:客户端为一次“业务意图”生成的唯一标识,服务端据此识别重复请求。
生活类比:电影院票根,同一张票可以重复出示,但只对应同一场电影同一个座位。
迷你案例:移动端支付超时后自动重试 2 次,三次请求都带同一个 Idempotency-Key,服务端只创建一次支付单。

关键设计点

  1. 键的粒度要绑定“业务意图”,不是绑定“HTTP 请求次数”。
  2. 幂等键要和租户、接口路径一起做唯一约束,避免跨接口串号。
  3. 同一个键必须绑定请求摘要(request_hash),防止“同键不同参”误用。
  4. 键建议由客户端生成(如 UUID),这样客户端重试时能稳定复用。
方案最适合场景优点风险建议
客户端生成幂等键App/前端可控,存在自动重试重试链路稳定,服务端简单客户端实现不规范会漏传作为主方案
服务端按参数指纹去重老系统无法改客户端无需改调用方合法重复业务可能被误杀只做兜底
业务号天然幂等(如外部订单号)上游已有全局唯一业务号语义清晰,可追踪依赖上游质量推荐与幂等键并用

看完这张对比表,你下一步要做的是选定“主方案 + 兜底方案”,不要只靠参数指纹硬扛全场景。

过期策略:这张票根保留多久

过期策略:幂等记录在存储里保留的时间与清理规则。
生活类比:快递柜不是永久仓库,保留太短你取不到,保留太长柜子会爆满。
迷你案例:支付链路允许用户在 24 小时内重试,但清算回调可能 48 小时才到;如果你 12 小时就删记录,就可能重放失败并触发重复扣款。

实用计算方式

可以先用这条保守公式:

TTL >= 客户端最大重试窗口 + 网关/队列延迟上界 + 业务补偿观察窗口

对资金类接口,宁可长一点也别过短;对高频写接口,再配合分层存储控制成本。

sequenceDiagram
    participant C as Client
    participant A as API
    participant S as IdempotencyStore
    C->>A: T0 首次请求(K1)
    A->>S: 写入 PROCESSING(K1, expires_at=T0+72h)
    A-->>C: 网络超时(客户端未收到)
    C->>A: T0+5s 重试(K1)
    A->>S: 命中同键记录
    A-->>C: 回放首次成功响应
    C->>A: T0+50h 再次重试(K1)
    A->>S: 仍在TTL内
    A-->>C: 继续回放同一结果

看完这条时序图,你下一步要做的是把 TTL 和你真实的“最长重试链路”对齐,而不是拍脑袋写 1 小时。

响应回放策略:第二次请求到底回什么

响应回放策略:命中幂等键时,服务端如何返回“与首次一致且可预期”的响应。
生活类比:你拿着同一张电影票去验票,系统应告诉你“这张票已入场”,而不是给你重新分配座位。
迷你案例:首次下单实际已成功,但客户端因超时未收到响应;第二次重试应拿到首次的订单号和状态码,而不是创建新订单。

推荐回放规则

  1. 首次成功后重试:回放首次的 status code + body + 关键业务头
  2. 首次仍在处理中:返回 409202,附带明确重试建议(如 Retry-After)。
  3. 首次确定性失败(如余额不足):回放同样失败结果,避免反复执行业务。
  4. 同键不同参数:直接返回冲突(常用 409/422),并提示“更换幂等键”。

一个工程细节:不要原样回放所有头。像 Date、链路追踪 ID 这类每次都变的头,应该由当前请求重新生成;真正要回放的是业务语义。

一套可复现的落地实现(下单接口)

下面给你一套最小可用版本,按步骤就能跑起来。

第 1 步:幂等存储建模

CREATE TABLE api_idempotency (
  tenant_id        VARCHAR(64)  NOT NULL,
  endpoint         VARCHAR(128) NOT NULL,
  idem_key         VARCHAR(128) NOT NULL,
  request_hash     CHAR(64)     NOT NULL,
  status           VARCHAR(16)  NOT NULL, -- PROCESSING/SUCCEEDED/FAILED_FINAL
  http_code        INT,
  response_body    TEXT,
  response_headers TEXT,
  resource_type    VARCHAR(32),
  resource_id      VARCHAR(64),
  expires_at       TIMESTAMP    NOT NULL,
  created_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (tenant_id, endpoint, idem_key)
);

第 2 步:请求入口处理

extract idem_key
if idem_key missing:
  reject for high-risk POST endpoint

req_hash = hash(canonical_request_body)
row = select for update by (tenant_id, endpoint, idem_key)

if row not exists:
  insert row(status=PROCESSING, req_hash, expires_at=now+ttl)
  execute business transaction
  save (status=SUCCEEDED/FAILED_FINAL, http_code, response_snapshot)
  return first response

if row exists and row.req_hash != req_hash:
  return 409 conflict ("same key, different payload")

if row.status == SUCCEEDED or row.status == FAILED_FINAL:
  replay stored response

if row.status == PROCESSING:
  return 409/202 with retry hint

第 3 步:清理任务

  • 定时任务按 expires_at 清理。
  • 清理前确保超出“最长重试 + 补偿窗口”。
  • 对高价值交易可做归档而非直接删除,便于审计和排障。

如果你想快速验证是否生效,做一个 3 次并发重试压测:预期只能产生 1 条订单记录,其余请求全部命中回放或处理中分支。

最容易踩的 5 个坑

  1. 只校验幂等键,不校验请求摘要,导致同键不同参污染数据。
  2. 业务成功了但没持久化响应快照,重试时无法稳定回放。
  3. PROCESSING 状态无超时兜底,异常中断后永远卡死。
  4. TTL 过短,用户合法重试反而变成新请求。
  5. 把幂等当成“全链路 exactly-once”,忽略消息、回调、补偿链路的一致性设计。

收尾:把这 5 个动作做完,你的幂等就稳很多

  1. 定义幂等键粒度:一笔业务意图对应一个键。
  2. 绑定请求摘要:同键不同参必须拒绝。
  3. 明确设置状态机:PROCESSING/SUCCEEDED/FAILED_FINAL
  4. 用真实重试链路校准TTL,不要靠经验值。
  5. 通过并发重试与超时场景验证响应回放一致性。

当你把这三件套连起来,重复请求就不再是“事故触发器”,而会变成系统可控、可预测的一种常态流量。