实现幂等性的几种方式
什么是幂等性?
对于同一笔业务操作,不管调用多少次,结果都是一样的。
幂等性设计 比如充值功能,我们需要提供给支付宝一个充值成功后的回调接口,支付宝回调信息中携带订单号(商户系统中唯一)、交易号(支付宝中唯一)。支付宝为了保证商户系统的接口调用成功,有可能会多次调用商户的回调接口。如果不做幂等性设计,则本地可能会给用户加两次余额。
方案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:唯一约束
依赖数据库中的唯一约束实现。需要创建一张表来保存订单号执行记录。并把订单号设置为唯一。
- 接收到支付宝支付成功的回调请求
- 根据订单号查询执行表,可判断订单是否已处理
- 根据订单号查询当前订单是否已经处理过。
- 如果没有被处理继续执行
- 开启本地事务
- 给用户加钱
- 设置订单状态为成功
- 向订单执行表中添加一条记录。如果插入失败,则回滚本地事务,插入成功,提交事务。
- 因为订单号唯一,所以执行表中不能存在两条订单号相同的数据,最终只会一个操作成功,从而保证幂等性。不过在业务量大的场景下,执行表插入数据会成为系统平静。需要考虑分表操作解决性能问题。
- 插入操作会锁定表,所以执行表中新增记录要放在最后执行。提高系统并发性。
方案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客户端的锁给解除了。
转载自:https://juejin.cn/post/7067103678506729480