接口幂等性这么重要,它是什么?怎么实现?

本文通过一个真实的案例讲述了由于缺乏幂等性导致的严重后果,深入剖析了什么是幂等性,并详细介绍了在分布式系统中实现幂等性的五种方法:普通方式、JVM加锁、数据库悲观锁、乐观锁和唯一约束,以及分布式锁。强调了在处理并发操作时,幂等性的重要性以及如何避免类似问题的发生。
摘要由CSDN通过智能技术生成
一、场景

凌晨2点,正在做梦,突然接到了技术总监的电话:明天来公司收拾收拾,办理离职!说实话当时我头脑一片空白,直接懵了。第二天到公司,才知道我写的一段代码,昨天一天让公司损失了100多万,被定性为重大事故,导致了我直接被炒鱿鱼,而我的一些领导也受到了牵连,让我十分愧疚。这个业务应该很多人都会遇到,所以拿出来分享一下,避免大家踩坑。

我们公司是做投资理财的,用户可以充值、投资、提现,充值这块是我做的,使用第三方支付进行充值,过程如下:

  • step1:用户网站中输入充值金额;

  • step2:后端创建充值订单入库,此时订单是待支付状态;

  • step3:跳转到第三方支付页面,输入银行卡,然后确认支付;

  • step4:第三方支付通过我方提供的回调接口异步将充值结果告知我方。

问题出在了step4,逻辑如下:

//返回通知处理结果,true:处理成功;false:处理失败,第三方会继续重试
public boolean rechargeNotice(第三方支付充值结果){
    try{
        //第三方充值结果中包含了我方的订单id,从db中获取充值订单信息
        OrderModel order = this.getOrderById(订单id); //@1
        //判断订单状态是否是待支付状态
        if(订单状态 == 待支付状态){ //@2
            //将订单状态置为充值成功
            order.status(充值成功);
            orderService.update(order);
            //用户账户可用余额增加
            this.accountService.incrBalance(用户id,充值金额);
            return true;
        }else{
            //订单已处理过,返回true
            return true;
        }
    }catch(Exception e){
        //记录异常信息,返回通知失败
        return false;
    }
}

昨天由于网络不稳定,第三方支付对于多笔订单,产生了并发通知的情况,并发情况时,上面逻辑是有问题的。

同一笔订单,同时进行2次通知,此时都会走到@1,此时看到order的状态都是待支付状态,然后都会进入@2,最后导致账户余额重复增加了,最后导致,充值1000,账户余额增加2000,用户发现系统有这个bug,然后他们直接去提现了,导致公司重大损失,晚上公司对账发现了这个问题,技术总监进行了紧急修复。

这个问题,就是我们常说的幂等性的问题,是非常非常重要的一个技术点,所以大家一定要吃透,在日常开发中要时刻考虑幂等性的问题,以减少这种不必要的损失。


二、什么是幂等性?

对于同一笔业务操作,不管调用多少次,得到的结果都是一样的。

幂等性设计,我们以对接支付宝充值为例,来分析支付回调接口如何设计?

如果我们系统中对接过支付宝充值功能的,我们需要给支付宝提供一个回调接口,支付宝回调信息中会携带(out_trade_no【商户订单号】,trade_no【支付宝交易号】),trade_no在支付宝中是唯一的,out_trade_no在商户系统中是唯一的。

回调接口实现有以下实现方式:

1. 普通方式

过程如下:

  • 1)接收到支付宝支付成功请求;
  • 2)根据trade_no查询当前订单是否处理过;
  • 3)如果订单已处理直接返回,若未处理,继续向下执行;
  • 4)开启本地事务;
  • 5)本地系统给用户加钱;
  • 6)将订单状态置为成功;
  • 7)提交本地事务。

上面的过程,对于同一笔订单,如果支付宝同时通知多次,会出现什么问题?当多次通知同时到达第2步时候,查询订单都是未处理的,会继续向下执行,最终本地会给用户加两次钱。

此方式适用于单机,只能用于自己写着玩玩。


2. JVM加锁方式

方式1中由于并发出现了问题,此时我们使用Java中的Lock(或者synchronized关键字)加锁,来防止并发操作,过程如下:

  • 1)接收到支付宝支付成功请求;
  • 2)调用java中的Lock加锁;
  • 3)根据trade_no查询当前订单是否处理过;
  • 4)如果订单已处理直接返回,若未处理,继续向下执行;
  • 5)开启本地事务;
  • 6)本地系统给用户加钱;
  • 7)将订单状态置为成功;
  • 8)提交本地事务;
  • 9)释放Lock锁。

分析问题:

Lock锁只能在当前JVM中起作用(synchronized关键字加锁也是一样),如果多个请求都被部署在同一台机器上的系统处理,上面这种使用Lock的方式是没有问题的。不过互联网系统中,多数是采用集群方式部署系统,同一套代码后面会部署多套,如果支付宝同时发来多个通知经过负载均衡转发到不同的机器,上面的锁就不起效了。此时对于多个请求相当于无锁处理了,又会出现方式1中的结果。此时我们需要分布式锁来做处理。


3. 数据库加悲观锁

使用数据库中悲观锁实现。悲观锁类似于方式二中的Lock,只不过是依靠数据库来实现的。数据中悲观锁使用for update来实现,过程如下:

  • 1)接收到支付宝支付成功请求;
  • 2)打开本地事物;
  • 3)查询订单信息并加悲观锁select * from t_order where order_id = trade_no for update;
  • 4)判断订单是已处理;
  • 5)如果订单已处理直接返回,若未处理,继续向下执行;
  • 6)给本地系统给用户加钱;
  • 7)将订单状态置为成功;
  • 8)提交本地事物。

重点在于for update,对for update,做一下说明:当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。事物提交时,for update获取的锁会自动释放。

方式3可以正常实现我们需要的效果,能保证接口的幂等性,不过存在一些缺点:如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作


4. 乐观锁方式

依靠数据库中的乐观锁来实现,过程如下:

  • 1)接收到支付宝支付成功请求;
  • 2)查询订单信息,select * from t_order where order_id = trade_no;
  • 3)判断订单是已处理;
  • 4)如果订单已处理直接返回,若未处理,继续向下执行;
  • 5)打开本地事物;
  • 6)给本地系统给用户加钱;
  • 7)将订单状态置为成功,伪代码:
update t_order set status = 1 where order_id = trade_no where status = 0;
//上面的update操作会返回影响的行数num
if(num==1){
 //表示更新成功
 提交事务;
}else{
 //表示更新失败
 回滚事务;
}

注意:
update t_order set status = 1 where order_id = trade_no where status = 0;是依靠乐观锁来实现的,status=0作为条件去更新,类似于Java中的CAS操作。执行这条sql的时候,如果有多个线程同时到达这条代码,数据库内部会保证update同一条记录会排队执行,最终只有一条update会执行成功,其他未成功的,他们的num为0,然后根据num来进行提交或者回滚操作。


5. 唯一约束方式

依赖数据库中唯一约束来实现,我们可以创建一个表:

CREATE TABLE `t_uq_dipose` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联对象类型',
  `ref_id` varchar(64) NOT NULL DEFAULT '' COMMENT '关联对象id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_1` (`ref_type`,`ref_id`) COMMENT '保证业务唯一性'
);

对于任何一个业务,有一个业务类型ref_type,业务有一个全局唯一的订单号,业务来的时候,先查询t_uq_dipose表中是否存在相关记录,若不存在,继续放行。

过程如下:

  • 1)接收到支付宝支付成功请求;
  • 2)查询t_uq_dipose(条件ref_id,ref_type),可以判断订单是否已处理;select * from t_uq_dipose where ref_type = '充值订单' and ref_id = trade_no;
  • 3)判断订单是已处理;
  • 4)如果订单已处理直接返回,若未处理,继续向下执行;
  • 5)打开本地事物;
  • 6)给本地系统给用户加钱;
  • 7)将订单状态置为成功;
  • 8)向t_uq_dipose插入数据,插入成功,提交本地事务,插入失败,回滚本地事务,伪代码:
try{
    insert into t_uq_dipose (ref_type,ref_id) values ('充值订单',trade_no);
    //提交本地事务:
}catch(Exception e){
    //回滚本地事务;
}

说明:

对于同一个业务,ref_type是一样的,当并发时,插入数据只会有一条成功,其他的会违反唯一约束,进入catch逻辑,当前事务会被回滚,最终最有一个操作会成功,从而保证了幂等性操作。关于这种方式可以写成通用的方式,不过业务量大的情况下,t_uq_dipose插入数据会成为系统的瓶颈,需要考虑分表操作,解决性能问题。

上面的过程中向t_uq_dipose插入记录,最好放在最后执行,原因:插入操作会锁表,放在最后能让锁表的时间降到最低,提升系统的并发性。

联想到使用消息中间件,比如RabbitMq,消费者如何保证消息处理的幂等性?每条消息都有一个唯一的消息id,类似于上面业务中的trade_no,使用上面的方式(存在数据库或存在redis里)即可实现消息消费的幂等性。


6. 分布式锁

目前主流实现分布式锁的方法有两种,一种是Redis,一种是Zookeeper,具体使用哪种还需要根据业务场景来选用。


三、总结
  • 分布式系统中实现幂等性常见的方式有:悲观锁(for update)、乐观锁、唯一约束、分布式锁;
  • 几种方式,按照最优排序:分布式锁 > 乐观锁 > 唯一约束 > 悲观锁。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值