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

以对接支付宝充值为例,如果我们系统对接支付宝充值功能,需要给支付宝提供一个回调接口,支付宝回调信息中会携带(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加锁,来防止并发操作,过程如下:

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

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

(3)悲观锁方式

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

1.接收到支付宝支付成功请求
2.打开本地事务
3.查询订单信息并加悲观锁

select * from t_order where order_id = trade_no for update;

4.判断订单是已处理
5.如果订单已处理直接返回,若未处理,继续向下执行
6.给本地系统给用户加钱
7.将订单状态置为成功
8.提交本地事务

重点在于for update:
1.当线程A执行for update,数据库会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。
2.事务提交时,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 and status = 0;//会返回影响的行数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_unique_log' (
  '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_key' ('ref_type','ref_id') COMMENT '保证业务唯一性'
) ENGINE=InnoDB;
对于任何一个业务,有一个业务类型(ref_type),业务有一个全局唯一的订单号,业务来的时候,先查询表中是否存在相关记录,若不存在,继续放行。
过程如下:
1.接收到支付宝支付成功请求
 2.查询表t_unique_log (条件ref_id,ref_type),可以判断订单是否已处理select * from t_unique_log where ref_type = '充值订单' and ref_id = trade_no;
3.判断订单是已处理
 4.如果订单已处理直接返回,若未处理,继续向下执行
 5.打开本地事务
 6.给本地系统给用户加钱
 7.将订单状态置为成功
 8.向t_unique_log(唯一约束) 插入数据,插入成功,提交本地事务,插入失败,回滚本地事务,伪代码:try{
    insert into t_unique_log (ref_type,ref_id) values ('充值订单',trade_no);
    提交本地事务:
}catch(Exception e){
    回滚本地事务;
}

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

总结

1.数据库实现幂等性常见的方式有:悲观锁(for update)、乐观锁、唯一约束
2.几种方式,按照最优排序:乐观锁 > 唯一约束 > 悲观锁