凌晨 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,服务端只创建一次支付单。
关键设计点
- 键的粒度要绑定“业务意图”,不是绑定“HTTP 请求次数”。
- 幂等键要和租户、接口路径一起做唯一约束,避免跨接口串号。
- 同一个键必须绑定请求摘要(
request_hash),防止“同键不同参”误用。 - 键建议由客户端生成(如 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 小时。
响应回放策略:第二次请求到底回什么
响应回放策略:命中幂等键时,服务端如何返回“与首次一致且可预期”的响应。
生活类比:你拿着同一张电影票去验票,系统应告诉你“这张票已入场”,而不是给你重新分配座位。
迷你案例:首次下单实际已成功,但客户端因超时未收到响应;第二次重试应拿到首次的订单号和状态码,而不是创建新订单。
推荐回放规则
- 首次成功后重试:回放首次的
status code + body + 关键业务头。 - 首次仍在处理中:返回
409或202,附带明确重试建议(如Retry-After)。 - 首次确定性失败(如余额不足):回放同样失败结果,避免反复执行业务。
- 同键不同参数:直接返回冲突(常用
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 个坑
- 只校验幂等键,不校验请求摘要,导致同键不同参污染数据。
- 业务成功了但没持久化响应快照,重试时无法稳定回放。
PROCESSING状态无超时兜底,异常中断后永远卡死。- TTL 过短,用户合法重试反而变成新请求。
- 把幂等当成“全链路 exactly-once”,忽略消息、回调、补偿链路的一致性设计。
收尾:把这 5 个动作做完,你的幂等就稳很多
- 先定义幂等键粒度:一笔业务意图对应一个键。
- 再绑定请求摘要:同键不同参必须拒绝。
- 明确设置状态机:
PROCESSING/SUCCEEDED/FAILED_FINAL。 - 用真实重试链路校准TTL,不要靠经验值。
- 通过并发重试与超时场景验证响应回放一致性。
当你把这三件套连起来,重复请求就不再是“事故触发器”,而会变成系统可控、可预测的一种常态流量。