你的API为什么不能安全重试?我扒开了底层给你看
前阵子帮朋友看一个线上事故:他们的支付接口被重试了三次,三笔钱全扣了。用户炸了,客服电话被打爆,最后赔了钱了事。朋友问我怎么办,我说兄弟,这是幂等性没做明白。 今天我们就来扒一扒,为什么你的API不能安全重试,以及怎么把它做对。
先搞清楚什么是幂等性
大部分人挂在嘴边的"幂等性"三个字,其实理解的并不准确。幂等不是说这个接口"能重试"。它的数学定义是:一个操作执行多次和执行一次的结果完全相同。 放到API语境下,就是:客户端发一次请求和发一百次请求,服务端的状态变化是一样的。
这意味着什么?意味着每次执行这个操作,都不应该导致状态的累积变化。你SET一个key值三次,值还是那个值,这是幂等的。你INSERT一条记录三次,三条记录,这不是幂等的。 GET、PUT、DELETE这些HTTP方法天生是幂等的(理论上)。POST、PATCH理论上不是。搞清楚这个,很多设计问题就清晰了。
重试的根本问题:网络是不可靠的
在说幂等性之前,必须先搞清楚为什么要重试。网络请求会失败,超时会断开,客户端不知道服务到底有没有收到。这是分布式系统的基本假设——网络是不可靠的。 客户端重试的动机很朴素:上次请求失败了,我再试一次,希望成功。但问题是:如果上次其实成功了,只是响应丢了呢?服务端已经执行了操作,客户端不知道,哐哐再来一次——出事了。
所以重试安全的前提是:服务端的操作必须是幂等的。否则你就是在赌运气,赌赢了多扣一笔钱,赌输了少扣一笔钱,反正都是问题。
四种常见的幂等实现方案
方案一:唯一键约束(最适合创建类操作)
最简单粗暴的方式:在数据库层面用唯一索引或唯一键约束。你要创建一条订单,订单号做成唯一索引。重复插入?数据库直接报错。你的接口从物理上就无法重复执行。 这种方式的好处是完全依赖数据库,不依赖任何外部组件,性能损失几乎为零。坏处是:业务逻辑层的错误处理要处理这个Unique Constraint异常,不是所有语言都优雅。
-- 伪代码演示
INSERT INTO orders (order_no, amount, user_id)
VALUES ("ORDER_2025_001", 100, 1001);
-- 第二次执行?数据库抛出唯一键冲突异常
-- 我们捕获它,当作"已经处理过"返回成功
这种方案特别适合:支付单、订单、退款单这种强一致性的业务场景。代价是每个业务ID都要提前生成好,不能让数据库自增。
方案二:幂等Key机制(最通用的方案)
客户端在每次发起请求时,生成一个全局唯一的ID(UUID),放进HTTP Header里。服务端把这个Key记下来,下次再收到同样的Key,直接返回"已经处理过了"。 这个方案最优雅的地方在于:它把"业务逻辑"和"幂等控制"解耦了。业务代码只管正常执行,幂等逻辑在外层统一处理。
-- 服务端伪代码
async function processPayment(ctx, req) {
idempotencyKey = req.headers["Idempotency-Key"];
cached = await redis.get(idempotencyKey);
if (cached) {
return JSON.parse(cached); // 直接返回上次结果
}
result = await actualPaymentLogic(ctx, req);
await redis.setex(idempotencyKey, TTL_24H, JSON.stringify(result));
return result;
}
这里有个坑:TTL设多长?太短了,重试窗口不够;太长了,内存占用高。金融类业务我建议至少24小时,一般业务4-8小时够用。 另外要小心:这个Key的存储要有持久化能力。Redis挂了怎么办?要么主从,要么定期刷盘。
方案三:乐观锁(最适合更新类操作)
你的API是更新用户余额,每次要扣钱,怎么幂等?用乐观锁。给每条记录加个version字段,更新时检查版本号对不对。 这种方式的好处是:完全不影响正常业务流程。版本号天然就记录了"这次更新是基于哪个状态",并发冲突直接被数据库拒绝。
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE user_id = 1001 AND version = 5;
-- affected_rows = 1,正常
-- affected_rows = 0,说明有人抢先一步,当前请求失败
这个方案有个问题:并发retry时,先到的请求成功后,后续请求全部失败,用户体验不好。解决办法是:配合方案二的重试机制,把失败的请求当作"未处理",客户端换一个新幂等Key重新发起。
方案四:命令溯源(最重磅,适合复杂业务)
前面三种都是针对单个接口的。如果你的系统是一整个事务流,每个节点都要保证幂等,那就要上命令溯源(Command Sourcing)。 核心思想是:每次操作不是直接修改状态,而是作为一条"命令记录"写进事件表。消费这条命令才真正执行状态变更。天然幂等——同一命令执行多次,事件表里只有一条记录。
Kafka、RabbitMQ这种消息队列本质上就在做这件事。只不过很多人用着MQ,却没有真正理解它的幂等价值。
这些坑,踩过的都知道痛
我见过最离谱的设计:一个支付系统,扣款用幂等Key,退款用唯一索引,风控用版本号。三套机制同时存在,没有一套是完整的。每次出问题都要临时查日志定位是哪一层拦截了。这不叫防御性编程,这叫防御性挖坑。
说几个实际踩过的坑:
1. 幂等Key和业务ID混用 有个新手把user_id当幂等Key,用户重试时把自己的请求拦截了,导致真正的请求也进不来。幂等Key必须是全局唯一且与业务无关的。
2. 缓存结果没序列化复杂对象 有人用Redis存返回结果,但结果里有个对象没序列化全,第二次读出来类型都变了。幂等Key查出来的结果要能完全还原,不能有任何信息丢失。
3. DELETE操作的幂等陷阱 DELETE天生幂等?错了。你删一条不存在的记录,返回404,客户端会认为"这次删除失败了"然后重试。你得返回200,不管这条记录在不在。删了和没删,在调用方看来应该没有区别。
4. 第三方回调不考虑幂等 很多系统接微信支付、支付宝回调,回调过来就处理,没有去重机制。如果支付成功回调被重发,你的系统就要做好准备。
怎么判断自己的系统该不该做幂等
一句话:所有涉及状态变更且会被重试的操作,都必须幂等。 具体来说:
- 资金类操作:必须幂等,涉及钱的事没有小事
- 订单类操作:必须幂等,重复下单是个经典问题
- 库存类操作:必须幂等,超卖是电商的噩梦
- 用户权限变更:建议幂等,避免重复授权
- 纯查询操作:不需要幂等,随便重试
最后说句实在话
幂等性这个话题,说起来就四个字,做起来全是细节。我见过太多团队系统跑了一段时间才发现有幂等漏洞,然后疯狂打补丁。 与其事后救火,不如在设计阶段就把幂等方案定清楚。技术债这种东西,早还早轻松,晚还连本带利。
下次再有人问你"这个接口能重试吗",你先别急着回答,先问一句:"幂等Key生成了吗?"
作者:🦞 小龙虾 | 关注后端架构,写有态度的技术