不要相信你的接口:API幂等性从入门到精通
凌晨两点,你被电话叫醒。财务部的小王声音颤抖:"峰哥,又有一批用户被重复扣款了!"
你揉揉眼睛,打开监控系统,看到一串熟悉的错误日志:Duplicate entry for key order_no。然后你想起上个月你刚修过这个问题,打了个补丁,测试通过了,发布上线了——然后你把这个补丁回滚了,因为性能测试发现那个全局锁把吞吐量打成了原来的十分之一。
欢迎来到"接口幂等性"的坑。这篇文章,不讲理论,讲实战。让你在生产事故之前就把这些坑填上。
什么是幂等性?
幂等(Idempotent)这个概念来自数学:如果一个操作执行一次和执行多次的结果完全一样,那么这个操作就是幂等的。
在 API 的世界里,这句话有个隐含前提:执行多次的必须是同一次请求。不是说你调用了两次 POST /order 就应该只创建一个订单——那是业务逻辑问题。幂等性关心的是:同一个请求被我收到了两次(网络重试、客户端超时重发、消息队列重试),我该怎么处理?答案:第二次应该返回和第一次完全一样的结果,而不是再执行一遍。
HTTP 方法的幂等性
- GET:天然幂等。看一百遍还是那个商品。
- PUT:幂等。给定相同的数据,结果状态一样。
- DELETE:幂等。删一次和删两次结果一样——但前提是你的实现要返回 200 而不是 404。
- POST:非幂等。创建订单,每次都该产生新订单。
- PATCH:非幂等。修改变量值,+1 执行两次就是 +2。
所以当你设计 POST 接口的时候,你得先问自己:客户端超时重试了怎么办?消息队列重试了怎么办?
方案一:Token 方案
流程:客户端先获取 token,附带 token 发业务请求,服务端用 Redis 缓存实现幂等。
async function createOrder(ctx) {
const idempotencyKey = ctx.get('Idempotency-Key');
if (!idempotencyKey) ctx.throw(400, '缺少 Idempotency-Key');
const lockKey = `lock:idempotency:${idempotencyKey}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!lockAcquired) ctx.throw(409, '请求正在处理中');
try {
const cached = await redis.get(`idempotency:${idempotencyKey}`);
if (cached) return JSON.parse(cached);
const order = await Order.create(ctx.request.body);
await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(order));
return order;
} finally {
await redis.del(lockKey);
}
}
代价是要多一次获取 token 的请求,很多第三方 SDK 根本不支持。
方案二:业务字段组合,最省事
如果业务有天然唯一约束,简单多了。比如扣款接口,请求体里有 order_no 和 user_id,组合唯一。
INSERT INTO account_transactions
(order_no, user_id, amount, status, created_at)
VALUES
('ORD_20261012_88419', 'u_10086', 100, 'success', NOW())
ON DUPLICATE KEY UPDATE
status = status;
第一次是 INSERT,第二次触发唯一键冲突,ON DUPLICATE KEY UPDATE 什么都不改,保留原样。简单、有效、不需要额外存储。
方案三:幂等表兜底
有时候业务逻辑既没有天然唯一键,又没法用复合字段解决。比如库存扣减。
CREATE TABLE idempotency_log (
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
result JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_type, biz_id)
);
async function deductInventory(skuId, orderId, quantity) {
const existing = await IdempotencyLog.findOne({
where: { biz_type: 'inventory_deduct', biz_id: orderId }
});
if (existing) return JSON.parse(existing.result);
const inventory = await Inventory.findOne({ where: { sku_id: skuId } });
if (inventory.stock < quantity) throw new Error('库存不足');
const updated = await Inventory.update(
{ stock: inventory.stock - quantity, version: inventory.version + 1 },
{ where: { sku_id: skuId, version: inventory.version } }
);
if (updated[0] === 0) throw new Error('并发冲突,请重试');
await IdempotencyLog.create({
biz_type: 'inventory_deduct',
biz_id: orderId,
result: JSON.stringify({ success: true, remaining: inventory.stock - quantity })
});
return { success: true, remaining: inventory.stock - quantity };
}
关键:幂等表查一遍,命中直接返回;乐观锁防并发;把结果记录下来。
实战经验
1. 超时重试和幂等性是两件独立的事
超时重试是客户端行为(网络抖了,客户端再发一次);幂等性是服务端行为(收到了两次请求,要保证结果一致)。两个维度,都要处理。
2. 幂等性和防并发不是一回事
有人把全局锁当幂等方案用——锁是防并发的,幂等性是防重复的。全局锁的代价是吞吐量暴跌,血的教训。
3. DELETE 的幂等性要特别注意
很多实现第二次调用抛 404,不符合幂等性。正确:不管存不存在,都返回相同语义的结果。
4. 分布式环境下的额外挑战
多实例环境下,单机内存 Set 不 work 了。需要集中式存储(Redis)或消息队列的天然幂等(Kafka exactly-once)。
总结
送一条命令自查:grep 找出所有 POST/PATCH 路由,手动审查是否有幂等保护。
如果你的接口列表里有大量没有幂等保护的写操作,而你还从没处理过重复请求的问题——那只是运气好,还没轮到你被叫醒而已。
接口幂等性不是在事故发生之后才需要考虑的,它应该在接口设计的第一天就被考虑进去。不然,你就是在用生产环境的真实流量做测试,而测试费用是用户信任。