实现幂等性的几种方式

什么是幂等性?

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

幂等性设计 比如充值功能,我们需要提供给支付宝一个充值成功后的回调接口,支付宝回调信息中携带订单号(商户系统中唯一)、交易号(支付宝中唯一)。支付宝为了保证商户系统的接口调用成功,有可能会多次调用商户的回调接口。如果不做幂等性设计,则本地可能会给用户加两次余额。

方案1:Lock锁

单机环境中,通过java中的lock加锁,来防止并发操作。

  • 接收到支付宝支付成功的回调请求
  • 调用java中的lock锁
  • 根据订单号查询当前订单是否已经处理过。
  • 如果没有被处理继续执行
  • 开启本地事务
  • 给用户加钱
  • 设置订单状态为成功
  • 提交事务,释放lock锁

  问题分析:Lock只能在一个jvm中生效,如果在分布式环境中,支付宝回调请求会被分发到不同的机器上,Lock锁就起不到作用。此时就需要分布式锁来处理。

方案2:悲观锁

使用数据库的悲观锁。类似java中的Lock,但是是依靠数据库实现。

select * from t_order where order_id=order_no for update。

  • 接收到支付宝支付成功的回调请求
  • 开启本地事务
  • 根据订单号查询当前订单是否已经处理过并且加悲观锁。
  • 如果没有被处理继续执行
  • 开启本地事务
  • 给用户加钱
  • 设置订单状态为成功
  • 提交事务,释放lock锁
  • 重点在于for upate,
  • 当线程A执行for update,数据库会对当前记录加锁,当其他线程执行到此行代码时,会等线程A释放锁之后才获取锁,继续后续操作。
  • 事务提交后,线程A自动释放锁。

  该方法可以保证接口的实现幂等性,但是存在一些缺点:如果业务处理比较耗时,在高并发场景下后台线程会长期处于等待状态,占用很多线程。不利于系统并发操作。

方案3:乐观锁

  • 接收到支付宝支付成功的回调请求
  • 根据订单号查询当前订单是否已经处理过。
  • 如果没有被处理继续执行
  • 开启本地事务
  • 给用户加钱
  • 设置订单状态为成功

// sql
// update t_order set status = 1 where order_no=orderNo and status=0;

if(影响行数==1){
提交事务;
}else{
回滚事务;
}

复制代码

  这里用乐观锁来实现,使用status=0作为条件更新。多个线程同时执行这条sql语句时,数据库会保证update同一条记录会排队执行,最终只有一条update执行成功。其他未成功的影响行数为0,可以根据影响行数来提交或者回滚操作。

方案4:唯一约束

  依赖数据库中的唯一约束实现。需要创建一张表来保存订单号执行记录。并把订单号设置为唯一。

  1. 接收到支付宝支付成功的回调请求
  2. 根据订单号查询执行表,可判断订单是否已处理
  3. 根据订单号查询当前订单是否已经处理过。
  4. 如果没有被处理继续执行
  5. 开启本地事务
  6. 给用户加钱
  7. 设置订单状态为成功
  8. 向订单执行表中添加一条记录。如果插入失败,则回滚本地事务,插入成功,提交事务。
  9. 因为订单号唯一,所以执行表中不能存在两条订单号相同的数据,最终只会一个操作成功,从而保证幂等性。不过在业务量大的场景下,执行表插入数据会成为系统平静。需要考虑分表操作解决性能问题。
  10. 插入操作会锁定表,所以执行表中新增记录要放在最后执行。提高系统并发性。

方案5:Redis实现分布式锁

  以上分布式方案都是基于数据库来实现,但是在高并发场景下,还需要通过Redis实现分布式锁来实现。

  首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

执行过程:

  • 接收到支付宝支付成功的回调请求
  • 通过Redis获取锁
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if ("OK".equals(result)) {
            return true;
        }
        return false;

复制代码
  • 根据订单号查询当前订单是否已经处理过。
  • 如果没有被处理继续执行
  • 开启本地事务
  • 给用户加钱
  • 设置订单状态为成功
  • 提交事务,释放redis锁
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }

复制代码

可以看到,加锁只需要一行代码: jedis.set(lockKey, value, nx, expx, time);共有五个形参:

  • lockKey:使用key作为锁,key是唯一的。
  • value: 传入requestId,因为解锁需要自己进行解锁。所以根据value可以知道锁是哪个请求加的
  • nxx:填写NX,意思是set if not exist,即当key不存在时执行set操作,如果值存在,不做任何操作。
  • expx:传入PX,意思是给key添加一个国企时间,具体时间是time决定。
  • time:与第四个参数对应,表示key的过期时间。

释放锁通过lua脚本实现,"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";这行脚本的功能是什么呢?很简单,先获取对应的key值,然后验证key的value和传入的第二个值是否相同,如果相同,则删除。否则不执行。为什么删除key要保证原子性操作?非原子性会带来什么问题?

   if (requestId.equals(jedis.get(lockKey))) {
       // 若在此时,这把锁突然不是这个客户端的,则会误解锁
       jedis.del(lockKey);
   }
复制代码

问题在于调用jedis.del()方法的时候,这时候如果锁已经不属于当前线程了,则会删除其他人的锁。是否会有这种场景?答案是肯定的,比如客户端A加了锁,一段时间后锁过期了,这时候B客户端加锁成功,A客户端此时执行jedis.del()方法就会将B客户端的锁给解除了。

Supongo que te gusta

Origin juejin.im/post/7067103678506729480
Recomendado
Clasificación