API 幂等性:三个最容易漏的地方
接口重试是分布式系统的日常:网络抖动、超时、网关重发。只要客户端会重试,服务端就必须面对"同一个请求来了两次"的问题。
幂等不是一个开关,而是一组设计决策。这篇讲三个最常被漏掉的点。
一、幂等键由谁来生成
最直觉的做法是服务端自己去重,比如"同一个用户 5 秒内提交相同内容算一次"。这个方案看起来很省事,但它有个致命缺陷:服务端无法区分"用户真的点了两次"和"客户端重试了一次"。
用户在弱网下点两次,可能真的是想提交两单;而客户端因为超时重发,是同一次意图。两者在服务端看起来一模一样。
正确的做法是由客户端生成幂等键(也叫请求 ID):
POST /api/orders
Idempotency-Key: 7f3a9c21-4b8e-4d2a-9f1c-6e5d8b7a2c40
Content-Type: application/json
{"sku": "A-100", "qty": 2}
服务端拿到这个 key,先去查是否处理过:
- 没处理过 → 正常处理,把结果和 key 一起存下来
- 处理过 → 直接返回上次的结果(注意:返回原结果,不是报错)
要点在于:同一个 key 的重试必须返回同一个响应。如果第一次返回 200 + 订单号,第二次重试却返回 409 "重复提交",客户端的重试逻辑就会认为失败,可能再试一次,陷入循环。
async function createOrder(req) {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'missing idempotency key' });
const cached = await idempotencyStore.get(key);
if (cached) return res.status(cached.status).json(cached.body); // 原样返回
const result = await doCreateOrder(req.body);
await idempotencyStore.set(key, { status: 200, body: result }, { ttl: 86400 });
return res.status(200).json(result);
}
UUID 是最省事的选择,但要注意:同一个业务意图必须复用同一个 key。如果客户端每次重试都生成新 key,那幂等就形同虚设。key 应该在"用户发起这次操作"时生成一次,之后所有重试都用它。
二、幂等键的存储生命周期
第二件容易漏的事:这个 key 要存多久?
存太短:用户隔了一小时因为网络问题重试,key 已过期,重复创建。
存太长:存储成本高,且 key 空间无限增长。
实践中常见的做法是 24 小时,理由是:绝大多数重试发生在几分钟内,24 小时足够覆盖异常场景,同时存储压力可控。
但更重要的是存储要和业务在同一个事务边界内。如果先写业务数据、再写幂等记录,中间进程挂了,就会留下"业务成功但幂等没记录"的状态——下次重试又会创建一遍。
正确顺序是先占位再处理:
-- 1. 先插入幂等记录(唯一索引兜底)
INSERT INTO idempotency (key, status) VALUES (?, 'processing');
-- 如果唯一索引冲突,说明有并发请求,直接返回"处理中"
-- 2. 处理业务
-- 3. 更新幂等记录为完成,写入响应
UPDATE idempotency SET status = 'done', response = ? WHERE key = ?;
用数据库的唯一索引做兜底,比"先查再插"可靠得多——后者在并发下必然漏。
三、幂等键覆盖的范围
第三个坑:只对"创建"做了幂等,忘了"更新"和"删除"。
很多人觉得更新天然幂等("把状态改成 X,改两次结果一样"),但下面这种就不成立:
PATCH /api/orders/123
{"action": "add_balance", "amount": 100}
这是"增量"语义,执行两次就加 200。任何带增量、计数、追加语义的操作,都不是天然幂等的。
判断方法很简单:问"这个请求执行两次,结果和一次一样吗?"
SET status = 'paid'→ 幂等balance = balance + 100→ 不幂等INSERT INTO logs ...→ 不幂等DELETE FROM cart WHERE id = 1→ 幂等(第二次删不到东西,但结果状态一致)
对于不幂等的操作,要么改成幂等语义(用版本号、用绝对值而不是增量),要么同样套用幂等键。
PATCH /api/orders/123
Idempotency-Key: ...
{"balance": 200} // 绝对值,天然幂等
四、一个常被忽略的细节:并发同 key
两个请求带着同一个 key 同时到达(客户端超时后重试,但第一次其实还在处理中),怎么办?
三种选择:
- 返回 409 + "处理中",让客户端稍后重试 —— 简单,但客户端要处理这个状态
- 阻塞等待第一个请求的结果,然后返回同样的响应 —— 体验最好,但要控制等待上限
- 直接放行 —— 等于没做幂等,不可取
推荐第 1 种,配合 Retry-After 头:
HTTP/1.1 409 Conflict
Retry-After: 1
关键是不要在这种情况下返回 500,否则客户端可能立刻重试,加剧并发。
五、小结
三件事,归纳起来:
幂等键由客户端生成,同一业务意图复用同一个 key,重试返回原结果而不是报错。
存储生命周期要覆盖重试窗口,且与业务在同一事务边界内,用唯一索引兜底并发。
幂等不只针对"创建",任何增量、计数、追加语义的操作都要单独处理。
最后给一个自检清单,评审接口时可以逐条过:
- 这个接口如果收到两次完全相同的请求,会发生什么?
- 客户端重试时,带的是同一个 key 吗?
- key 过期后重试,会不会重复创建?
- 并发同 key 时,第二个请求返回什么?
- 增量类操作有没有改成绝对值或版本号?
文中示例为通用 HTTP 语义,具体实现依框架和存储而异。
- 点赞
- 收藏
- 关注作者
评论(0)