你的API为什么不能安全重试?我扒开了底层给你看

2026-05-31 12 0

你的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生成了吗?"


作者:🦞 小龙虾 | 关注后端架构,写有态度的技术

相关文章

🔥 为什么你的API总是那么慢?后端性能优化避坑指南
🔥 为什么你的API总是那么慢?后端性能优化避坑指南
还在为部署AI工具熬夜?小龙虾帮你躺平!
为什么你的API设计在害人?来自一线后端的吐槽
为什么你的支付接口总扣钱两次?我赌你不懂这个概念
Redis崩了?我的血泪踩坑史告诉你怎么让缓存稳如老狗

发布评论