幂等:一次故障让我彻底悟了

先来一场故障:凌晨2点,报警狂响,用户订单重复扣款。原因?前端没有防重,后端支付接口没做幂等,网络重试导致同一笔订单支付了两次。骂娘。怎么会犯这种低级错误?但——真实世界的网络就是这么不可靠。 幂等,这个词听起来学术,其实核心特简单:同一个操作,不管执行多少次,产生的结果都一样。注意,是结果一样,不是只执行一次。千万别搞混。就像你女朋友说“你走”,你重复走十次,结果还是你走了,但你可能已经被甩了十次。不,一次就够了。对API来说,你重复调用十次支付,如果第一笔成功,后续九笔应该直接返回成功,不再扣款。这叫幂等。
分布式系统幂等接口重复请求处理流程图
分布式系统幂等接口重复请求处理流程图

从数学函数到状态机:幂等的底层机制

数学上,幂等函数满足 f(f(x)) = f(x)。比如绝对值函数 abs(abs(x)) = abs(x)。GET请求天然幂等,因为它只是查询,不改变状态。麻烦的是POST、PUT这类写操作。实现幂等,本质上要让写操作具备“可重复提交但效果唯一”的特性。 业界常见的做法有三种: 唯一约束法。给每次请求分配一个幂等键(Idempotency Key),服务端存储这个键和请求结果的映射。处理流程:先检查键是否存在,存在则返回已有结果;不存在则执行业务,存储结果,最后返回。这依赖数据库的唯一索引来保证并发安全。比如在MySQL建表:`CREATE UNIQUE INDEX idx_idem_key ON idem_records(key)`。但这里有个细节:必须先插入幂等记录(状态为“处理中”),再执行业务,最后更新状态为“成功”或“失败”。否则先业务后插入,并发时可能重复执行。
数据库唯一索引实现幂等处理时序图
数据库唯一索引实现幂等处理时序图
状态机法。如果资源本身有状态流转,可以利用乐观锁。比如订单状态从“待支付”到“已支付”,更新时带上版本号或前一个状态:`UPDATE orders SET status=’paid’, version=version+1 WHERE id=123 AND version=5`。如果版本已被更改,影响行数为0,说明已处理过。这种方法更轻量,不需要额外的幂等记录表,但要求业务操作本身能映射到状态转移。 Token机制。客户端先申请一个Token,提交时带上Token,服务端校验Token有效性并删除Token(或标记已使用)。这种方式减少了服务端存储请求响应的开销,但需要保证Token生成和校验的原子性。Redis的`SETNX`就可以实现:`SETNX token:xxx “processing”`,成功则执行业务,执行完后再删除或保留结果。 说实话,大部分场景用唯一约束法就够了,简单可靠。Token机制更适合对延迟敏感、不希望额外查询数据库的场景。

一次压测:MySQL与Redis的幂等性能对决

一次压测:MySQL与Redis的幂等性能对决
一次压测:MySQL与Redis的幂等性能对决
我们团队曾对支付系统的幂等方案做了一次彻底压测。业务逻辑:接收支付请求,扣减用户余额,记录流水。幂等控制分别用MySQL唯一索引和Redis实现。压测工具wrk,并发500,持续5分钟。 MySQL方案:在支付流水表上有幂等键的唯一索引。每次请求先尝试插入流水记录,若成功则执行业务,若重复键异常则查询已有结果返回。问题:插入操作本身就成了瓶颈。TPS卡在800左右,数据库CPU飙升到70%,P99延迟500ms。 Redis方案:使用String结构,`SET key value NX EX 3600`。先尝试设置,成功则执行业务并更新value为结果;失败则获取value判断。结果TPS飙到2200,数据库CPU降到30%,P99延迟降至90ms。差距明显——Redis的O(1)内存操作对比MySQL的索引插入,不是一个量级。 不过,Redis方案也有坑:过期时间设短了,业务还没执行完Key就过期了,导致重复执行;设长了,内存占用大。我们选择先设30秒,业务完成后延长到7天(用于查询历史)。还有Redis集群模式下,如果发生网络分区,`SETNX`可能会误判?我们用RedLock?不,我们最终采用了数据库唯一索引加Redis缓存的混合方案:Redis做快速幂等判断(消费上),数据库兜底保证最终一致性。这样哪怕Redis故障,也只是重复请求被拦截得慢一点,但不会出错。

落地三大陷阱,个个要命

陷阱一:时钟是不靠谱的。很多人的幂等设计假设请求按时间顺序到达。但分布式环境下,网络延迟、时钟偏移会导致先发出的请求后到。如果你依赖时间戳来判定“哪个请求先到”,死定了。我们吃过亏:A请求先生成,但在网络上堵了一会儿;B请求后生成但先到达。结果B被处理,A到来时发现Key已存在,误以为重复,把B的结果返回给A,数据全错。解决方案:不要依赖绝对时间,要么使用严格递增的序列号(如数据库自增ID),要么用向量时钟或因果一致性协议。但最简单的,幂等键由客户端生成(UUID),服务端只负责“存在即重复”,不区分先后。 陷阱二:并发下的竞态条件。两个相同请求几乎同时到达,同时检查到幂等键不存在,都去执行业务。即便有唯一索引,插入时也会出现一个成功一个失败,但失败的那个可能返回“重复”,而实际上第一个请求的业务可能还未完成。用户看到的是“请求重复”,但实际上第一个请求还在跑,后续查询却发现没记录。解决办法:“先插入后执行业务”的变种:插入一条状态为“processing”的记录,成功插入的线程执行业务,完成后更新状态;插入失败的线程检查状态,若为“processing”则轮询等待或返回“处理中”,若为“success”则返回结果。这个方案需要控制轮询超时和死锁。 陷阱三:补偿操作需要幂等。在微服务的长事务Saga中,补偿操作本身也得幂等。比如取消订单的补偿,如果因为重试被执行两次,第一遍将库存加回,第二遍再加就超了。我们踩的坑:某个补偿接口直接调用了增量更新 `UPDATE inventory SET quantity=quantity+?`,没有记录补偿状态。重复补偿导致库存虚增。正确做法:补偿操作也要带上原事务的幂等键,并判断该补偿是否已执行过。简单加个状态表:`insert into compensation_log(saga_id, step, status) values(…)`,唯一索引,执行更新前先插入,冲突则跳过。 啰嗦这么多,其实幂等不难,难在承认网络和系统是不完美的。别信任何“这不可能重试”的担保。设计时多问一句:如果这个操作被调用了两次,系统会怎么样?回答不上来,就别上线。
微服务Saga补偿幂等执行流程图
微服务Saga补偿幂等执行流程图
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:幂等:一次故障让我彻底悟了
文章链接:https://m.lfdjt.com/info_23_7581.html