为什么你的支付接口总扣钱两次?我赌你不懂这个概念
昨天帮一个朋友看他们的订单系统,日志里一堆诡异的重复下单。查了一圈,发现代码里连最基础的幂等处理都没有。他理直气壮地说:我加了锁的!
我只能说,兄弟,你对幂等一无所知。
幂等(Idempotency)这个词,在后端开发里被提及的频率极高,但大多数人要么一知半解,要么压根没意识到自己缺这块。HTTP规范里说GET/DELETE/PUT是幂等的,但有多少人真正理解这意味着什么?
幂等不是加了锁就完事
很多新手程序员以为幂等就是在接口入口加个分布式锁,锁住就完事了。但这根本不是幂等的本质。
幂等的核心是什么?同样的请求执行一次和执行多次,结果必须完全一致。
注意这个结果——不是不出错,而是行为正确。你锁住了,第二次请求确实进不去了,但如果第一次请求本身已经触发了支付回调,你锁住了也没用,钱还是扣了两次。
真正的幂等要从请求的整个生命周期来思考:
客户端发起支付请求(带上唯一 idempotency_key)
服务端检查:这个 key 是不是已经处理过?
- 如果是,直接返回上次的结果(不管上次是成功还是失败)
- 如果不是,执行支付逻辑,存储结果,返回本次响应
这里的关键是:结果要存储,存储的结果要可查询,下次请求来了要能查到已经存在的结果并直接返回。
接口设计里的三个大坑
在实际项目中,我见过太多把幂等做歪的案例。最常见的有三类:
坑一:把防重当幂等
很多人做的其实是防重——防止用户点两下按钮发送两个请求。但这不等于幂等。
防重的逻辑是:这个请求在处理中,不能再处理另一个。但幂等的逻辑是:无论你发来多少次,只要 key 相同,我只执行一次。
区别在哪?防重不存储结果,第二次进来会告诉你正在处理中,然后就没有然后了。但如果你是客户端重试(非用户主观重试,而是网络超时后的自动重试),你期望拿到的是一个确定的结果,而不是还在处理。
坑二:把失败回滚当正确
有些接口是这样设计的:先扣钱,失败了再退回。看起来没问题,但根本经不起推敲。
你要知道,支付渠道的回调有可能重复。支付宝、微信的回调,在网络不稳定的情况下,是有可能发两次的。你没有幂等,那两次回调都执行了,两次都去查订单状态,两次都未支付→已支付,然后两次都去发货。
有人会说:数据库有唯一索引,不会插入两条记录。但问题是,你的业务逻辑是插入成功就发货,你根本还没走到唯一索引那一步,就已经发货了。
坑三:存储设计不考虑并发
即使用了数据库存储结果,也要考虑并发问题。经典场景:
请求A:查幂等表,没记录,准备插入
请求B:查幂等表,没记录,准备插入
请求C:查幂等表,没记录,准备插入
三条记录同时插入——唯一索引生效了,只有一个成功,剩下两个报错。
然后呢?剩下两个请求报错,你返回什么给客户端?客户端收到报错,以为失败了,又重试——无限循环。
正确的做法是:查询和插入要在同一个事务里,或者用INSERT ON CONFLICT(PostgreSQL的Upsert)来处理并发。
PostgreSQL里的幂等处理:Upsert的正确姿势
我见过太多人用先查后插的逻辑做幂等,然后在大并发下翻车。这里给一个正确的PostgreSQL方案:
INSERT INTO idempotency_keys (key, response, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET
response = EXCLUDED.response,
created_at = EXCLUDED.created_at
RETURNING response;
这个ON CONFLICT的逻辑是:如果key已经存在,就更新response字段,并返回已有的response;如果不存在,就插入并返回新插入的response。
但注意!这个逻辑有一个前提:你的response必须是幂等的。也就是说,你不能把处理中这种中间状态存进去,否则Upsert会覆盖掉正在处理的请求的response。
更严谨的做法是引入状态机:
INSERT INTO idempotency_keys (key, status, response, created_at)
VALUES ($1, 'processing', NULL, NOW())
ON CONFLICT (key) DO NOTHING
RETURNING id;
如果RETURNING有结果,说明这个key没人处理过,可以继续处理。如果返回NULL,说明已经有人持有了这个key,你需要:
- 查这个key的状态
- 如果是completed,直接返回response
- 如果是processing,等待或者返回正在处理
- 如果是failed,根据业务决定是重试还是直接报错
一个被忽视的关键:超时重试的幂等
很多人只考虑用户重复点击的幂等,但忽视了另一个更隐蔽的场景:客户端超时后自动重试。
用户点了支付,客户端等了30秒没收到响应,用户以为失败了,又点了一次。客户端可能在超时后自动重试了原请求,也可能用户手动重试了。不管哪种,服务器收到两个完全一样的支付请求。
这时候你用什么来识别这是同一个请求?不是用户ID,不是订单ID,而是客户端生成的全局唯一ID。这个ID要存在订单创建时由客户端生成,并通过某种方式传递给服务端。
常见的做法是在HTTP Header里传:
X-Idempotency-Key: client-uuid-v4-xxxx-xxxx
服务端用这个key做幂等控制。这里有个细节:key的生成要在客户端本地完成,不能依赖服务端返回。因为如果是网络超时导致的重试,客户端根本收不到服务端的任何响应。
幂等不只在支付里
很多人以为幂等只是支付相关的场景,其实任何有重试可能的接口都需要考虑幂等。
比如:发送短信验证码接口,用户没收到,以为失败了又点了一次,如果没做幂等,就会发两条验证码。再比如:修改用户资料接口,网络超时重试了两次,结果把用户资料改成了不同的值(因为每次请求的时间戳不同)。
幂等的本质是承认:分布式系统里,请求可能被执行任意次,而你必须保证这次执行的结果不会因为次数改变而改变。
总结一下
如果你做后端开发,幂等是你迟早要面对的一座山。不是你会用Redis锁就叫懂了幂等,也不是你在数据库里加个唯一索引就叫做了幂等。
真正的幂等设计要考虑:
- 用什么key来标识唯一请求?
- 结果存储在哪里?用什么数据结构?
- 并发时怎么处理?
- 中间状态怎么管理?
- 客户端超时重试时怎么保证一致性?
下次你再听到有人说我加了锁,你可以直接问他:锁住之后,如果第一次请求处理失败了怎么办?如果支付渠道回调了两次怎么办?如果并发进来三个请求用的是同一个key怎么办?
他要是答不上来,那就别吹牛了,踏踏实实看文档吧。
有问题或有不同观点,欢迎来聊。后端这条路上,坑永远比想象中多。