接口幂等设计:从重复扣款说起

举报
yd_293849122 发表于 2026/11/12 13:51:15 2026/11/12
【摘要】 一次支付回调引发的重复扣款事故,暴露了接口幂等设计的缺失。本文记录从事发排查到落地幂等方案的完整过程,包括数据库唯一索引、令牌机制、状态机约束三个技术细节,并总结出三条可复用的幂等设计经验。

上个月凌晨2点钟左右的时候接到1个报警电话,说是有1个用户的账户在1段时间内被扣除3次,每次扣除的金额、时间间隔都一样,不到1秒。用户的投诉邮件已经发送给客服主管了,所以我就起床去查看监控录像,果然看到支付回调接口在1秒钟内被调用了3次,返回码都是200。【格式要求】原文为1段,输出也为1段纯正文,严禁换行拆段,严禁输出任何问候语、开场白、解释说明或引号包裹。

原因很简单,就是支付回调接口接收到第三方支付平台的通知之后就会执行扣款逻辑:查询订单、校验金额、扣减余额、更新订单状态。通常情况下,第三方平台只发送1次通知,但是由于网络抖动或者对方重试机制等原因可能会触发多次投递。当时接口没有采取任何防止重复发送的措施,在收到回调之后都会完整地执行一遍扣款的过程。

第一次修复尝试非常粗暴:在代码中加入一个根据订单号使用Redis分布式锁的逻辑,当同一个订单号在5秒内连续多次被请求时就会返回成功的结果。该方案推出之后确实阻止了重复回调的发生,但是它只解决了“同一时间点上有多次重复”的问题,并没有解决掉“同一时刻内多次并发”的情况。没过多久就出现了重复扣款的现象:第一次回调和第二次之间相隔了7秒,超过了锁的有效期。而且如果服务重启之后,Redis中的锁状态就会消失,保护也就随之失效了。

我认识到要从数据库的角度来设置1条硬性规定。最简单有效的方法就是在订单表中增加1个唯一的索引,用以保证扣款操作只能进行1次。具体操作如下:在订单表中增加1个名为`pay_callback_id`的列,并且将该列的值设置为支付平台回调通知的唯一标识(第三方每次重试都会携带相同的通知ID)。在扣款时,首先尝试插入1条支付流水记录,该记录包括了订单号、回调ID以及扣款金额,并且在回调ID上建立1个唯一的索引。如果插入成功,则表明这是第1次处理该回调,继续执行余额扣减以及订单状态更新;如果插入时出现唯一键冲突的情况,则说明该回调已经被处理过,直接返回成功。该变更对业务侵入较小,并且不需要依赖于Redis等外部组件来保证其可靠性。

唯一的索引解决了“1次回款不能重复处理”的问题,但是也存在1个边界情况:当扣款成功并且订单状态更新完成之后,但是返回给支付平台的信息在返回之前出现了网络中断的情况,对方就会进行再次尝试。在这种情况下,唯一的索引会阻止重复插入,并且返回成功信息,不会进行二次扣款操作。但是如果我们想添加对“扣款成功但是状态没有更新”的补偿逻辑的话,就需要另外设计了。我采取的办法是在订单表中增加1个名为`status`的字段来定义它的状态机约束:设置`PENDING`、`PAID`、`FAILED`3种状态,并且只允许从`PENDING`转为`PAID`以及从`PENDING`转为`FAILED`这两种合法迁移。在更新订单状态的时候使用条件SQL:`UPDATE order SET status='PAID' WHERE order_id=? AND status='PENDING'`。如果影响行数为0的话,就说明状态已经被其他的请求改变了,并不会执行后续的逻辑。该条件更新本身就是一个乐观锁,从而保证了状态层面上的幂等。

第三部分技术细节是客户端令牌机制,在用户发出操作指令之后,比如点击“立即支付”的按钮,在前端进入到支付页面之后就会向后端发送一个全局唯一的`request_token',而后端把该token存入Redis,并设置好过期时间。用户在提交支付请求时会带上这个token,后端首先会去检查这个token是否已经存在,如果存在的话就会把它删除并继续进行业务处理,否则就会认定这是个重复请求并直接返回。该方案可以用来防止用户快速双击或者前端重试造成的重复提交,并且和支付回调的幂等方案一起起到互补的作用:前者依靠数据库唯一的索引来保证幂等性,而后者则通过令牌来阻止重复提交。【格式要求】原文为1段,输出也必须且只能是1段纯正文,严禁换行拆段,严禁输出任何问候语、开场白、解释说明或引号包裹。

整个改造上线之后,我让测试的同学用压测工具来模拟3种情况:同一个回调被100次触发,并且每隔10秒就会重复10次重试,当服务重启之后再进行1次尝试。最终只产生1条扣款记录,余额只被扣减1次。之后又观察了1个星期,没有再出现重复扣款的投诉。

根据本次事故以及对它进行修复所积累的经验有如下3点:

根据本次事故以及对它的修复所获得的经验有以下3点:

格式要求:原文为1段,输出也必须且只能是1段纯正文,严禁换行拆段,严禁输出任何问候语、开场白、解释说明或引号包裹。

第一,幂等控制要分层设计,即数据库唯一索引来保障最低的安全级别,而Redis 分布式锁以及令牌机制只能起到辅助作用,并不能单独依靠它来运行。因为Redis有超时时间限制,在服务重启的时候会把一部分数据给丢失掉,而数据库的约束是永久性的并且具有很强的一致性。

第二,在接口设计时就要把“幂等键”的概念纳入议事日程之中。支付回调的幂等键是支付平台的通知ID,用户下单的幂等键是客户端生成的请求令牌,订单状态变更的幂等键是订单号加上目标状态。如果没有事先设置好幂等键的话,那么之后的改造就会很被动。

第三种是状态机约束加上条件更新语句的方式,比单纯的版本号要更直观地实现乐观锁。可以明确地表明业务状态合法流转的途径,提高代码的可读性,并且不需增加对版本号的维护成本。在开始的时候就给状态机和它所对应的条件更新SQL进行定义。【格式要求】原文为1段,输出也必须且只能是1段纯正文,严禁换行拆段,严禁输出任何问候语、开场白、解释说明或引号包裹。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。