
一个深夜的”幽灵扣款”
相信不少开发者都遇过这样的场景:用户在支付页面等太久,焦急地连点了两下”立即支付”;又或者在网络抖动时,客户端自动又发起了一次请求。结果是什么呢?同一笔订单被处理了两遍,账户被扣了两次款,客服电话第二天就被打爆。
问题往往不在逻辑本身有多复杂,而在于——你的接口天生”不幂等”。在网络世界里,超时、丢包、重连是常态,客户端为了保证”一定送达”会重试,服务端为了让”一定会返回结果”也会重试。可一旦两边都在重试,而接口又无法区分”同一次意图的第N次尝试”和”全新的请求”,灾难就来了。今天我们就来把幂等性和重试这对”孪生兄弟”彻底讲明白。

什么叫幂等?为什么默认的接口几乎都不幂等
“幂等”(Idempotency)听起来高深,其实意思很简单:同一个操作执行一次和执行一百次,产生的结果一模一样。往数据库里插入一条记录显然不幂等——你插十次就会得到十条;把某个字段置成固定值则是幂等的——你置多少遍结果都一样。
最典型的反例就是”创建订单/发起支付”。一次创建请求落到后端,如果前一次其实已经成功了,只是响应在网络上丢失,客户端不知道又发了一次,结果就凭空多出一张订单。要判断一个接口是否幂等,你只要问自己一个问题:“如果同一个请求因为网络原因被送达了多次,副作用会不会重复?”如果会,你就必须在服务端做去重保护。
这里要澄清一个误区:很多人以为加了 try-catch 或者让客户端”别重复提交”就够了。可超时导致的重复请求,客户端自己经常也感知不到——它以为自己只发了一次。所以幂等保护必须做在服务端,而不是寄希望于调用方自觉。这是分布式系统里一条朴素的真理:你不能信任网络,也不能信任对方的记忆,只能信任你自己对”意图”的判别。
三大法宝:幂等键、唯一约束、状态机
做主流的服务端幂等,目前有几种被验证过的成熟手段。第一种是幂等键(Idempotency Key),也是 Stripe、支付宝等支付类 API 的标准做法:客户端在每次”业务意图”开始时生成一个全局唯一的 key(通常是 UUID),随请求带给服务端。服务端用这个 key 建一张去重表,key 重复到达时,直接返回第一次已经计算好的结果,而不是重新执行。
第二种是数据库唯一约束,适用于”同一意图只能产生一条记录”的场景。比如给订单表加上 UNIQUE(biz_key) 约束,并发或重复插入时,数据库会拒绝第二条并抛出唯一键冲突,你在代码里捕获这个冲突当作”已存在”处理即可。比起傻傻地在应用层加锁,依赖数据库本身的原子性往往更可靠、也天然支持多实例并发。
第三种是状态机推进,适用于有明显生命周期的实体,比如订单从”已创建→已支付→已发货”。幂等的关键是:每个状态只允许单向迁移到特定目标,重复或过期的操作请求因为”当前状态不允许”而被直接丢弃。把”能做/不能做”固化进状态流转,副作用自然不可能重复发生。
重试的艺术:不是简单重发,而是”指数退避+抖动”
说完幂等,再谈重试。哪怕接口做得很幂等,盲目重试也会把正常服务打到雪崩——你可以回忆一下某个依赖的下游一抖动,全网请求瞬间倍增的惨状。负责任的重试,至少要做到三点。
第一是指数退避(Exponential Backoff):重试间隔按 1s、2s、4s、8s……翻倍递增,而不是固定间隔死缠烂打,给下游喘息的时间。第二是加抖动(Jitter):在退避间隔里随机加一点扰动,避免一大群客户端步调一致地同时”抬头”,再次打满服务。第三是区分可重试与不可重试的错误:网络超时、5xx 服务端错误可以重试;而 4xx 客户端参数错误、认证失败这类问题,你再重试一万遍也是失败,反而该立即放弃并报警。
同时记住一条铁律:重试必须配合幂等,否则重试就是在放大灾难。幂等保证”重了也没关系”,重试才敢放心大胆地去补偿”没送达”的请求。两者一个管”别做重复的副作用”,一个管”把该做的送达”,搭配起来才是完整的可靠性设计。

写在最后
写代码时人人都会乐观地假设”请求只到一次、网络永远畅通、下游永远在线”。但真实世界恰恰相反——不确定性才是唯一确定的事。把幂等性和重试机制当成接口的一等公民来设计,而不是事后补救的补丁,你的系统才算真正有了”背锅”的底气。
下次再有同事问”为什么我接口会重复扣款”,你可以把这篇转给他,再补一句:先问问自己,它幂等了吗?