网站制作与网页建设,建视频网站需要多大空间,一诺网站建设,酒仙网网站推广方式开发抽奖时遇到的分布式锁问题#xff0c;特此记录一下几种实现方案 背景
开发中遇到个抽奖需求#xff0c;会根据当前奖池内道具数量随机道具并发送给用户。这里面涉及到超发的问题#xff0c;需要使用到分布式锁#xff0c;特此记录一下常用的几种方案。 “超发”#… 开发抽奖时遇到的分布式锁问题特此记录一下几种实现方案 背景
开发中遇到个抽奖需求会根据当前奖池内道具数量随机道具并发送给用户。这里面涉及到超发的问题需要使用到分布式锁特此记录一下常用的几种方案。 “超发”在并发情况下假设某类道具只剩1个线程A随机到该道具随机完成但还没来得及减库存这时线程B也进来了判断还有库存继续随机也随机到该道具这时出现超发。库存只有1个道具但却随机给用户出了两件道具。也就是发出去的数量大于库存数量。 加分布式锁的方案其实有多种这里介绍四种
1、悲观锁
2、乐观锁
3、redislua
4、Redisson
1、悲观锁
可以理一下思路之所以会出现超发就是因为存在多个线程修改共享数据如果能在一个线程读库存时就将数据锁定不允许别的线程进行读写操作直到库存修改完成才释放锁那么就不会出现超发问题了。
这种借助数据库锁机制在修改数据之前先锁定再修改的方式被称之为悲观锁。
步骤为
1.开启事务查询要随机的道具并对该记录加for update。在查询库存时使用了 for update这样在事务执行的过程中就会锁定查询出来的数据其他事务不能对其进行读写注意读也不行这就避免了数据的不一致直至事务完成释放锁。
begin; select nums from t_items where item_id {$item_id} for update;2、如果还有库存则减少库存并提交事务。
update t_items set nums nums - {$num} where item_id {$item_id}; commit;这里存在几个点要注意 1、select…for update语句执行中所有扫描过的行都会被锁上因此在MySQL中用悲观锁务必须确定走了索引而不是全表扫描否则将会将整个数据表锁住。 2、使用悲观锁需要关闭 mysql 的自动提交功能将 set autocommit 0 (一般显式开启事务即可无需修改 MySQL配置); 优点思路清晰从数据库层面解决超发问题。
缺点独占锁的方式对于性能影响较大。
因为悲观锁依靠数据库的锁机制实现以保证操作最大程度的独占性。如果加锁的时间过长其他用户长时间无法访问影响了程序的并发访问性同时这样对数据库性能开销影响也很大特别是对长事务而言这样的开销往往无法承受这时就引出了乐观锁。
2、乐观锁
悲观锁有效但不高效为了提高性能出现了乐观锁方案不使用数据库锁不阻塞线程并发。
乐观锁顾名思义就是对数据的处理持乐观态度乐观的认为数据一般情况下不会发生冲突只有提交数据更新时才会对数据是否冲突进行检测。
思路乐观锁的实现不依靠数据库提供的锁机制需要我们自已实现实现方式一般是记录数据版本通过版本号的方式。
给表加一个版本号字段version读取数据时将版本号一同读出数据更新时将版本号加 1。
当我们提交数据更新时判断当前的版本号与第一次读取出来的版本号是否相等。如果相等则予以更新否则认为数据过期拒绝更新让用户重新操作。
步骤为
查询要卖的商品并获取版本号。
begin; select nums, version from t_items where item_id {$item_id};如果库存还有则减少库存。(更新时判断当前 version 与第 1 步中获取的 version 是否相同)
update t_items set nums nums - {$num}, version version 1 where item_id {$item_id} and version {$version};判断更新操作是否成功执行如果成功则提交否则就回滚。
注意通过 version版本号就可以知道自己读取的数据在更新时是不是旧的如果是旧数据就不能更新了。从而可以知道在并发量很大的时候失败的概率会比较高。 为了提升成功率可以引入重试机制当更新失败后再走一遍流程读取、更新。可以规定一个次数例如3次如果重试了3次还是失败就放弃还可以规定一个时间段比如在 100ms 内循环操作期间如果某次成功了就退出否则一直重试到时间到为止。 优点没有阻塞性性能优于悲观锁。
缺点实现思路较复杂增加version控制还需要加入重试机制。并且高并发的修改下失败率较高。
3、redis lua
错误做法可能直接会想到redis的setnxexpire命令。如下所示
public boolean tryLock(String key,String requset,int timeout) {Long result jedis.setnx(key, requset);// result 1时设置成功否则设置失败if (result 1L) {return jedis.expire(key, timeout) 1L;} else {return false;}
}Redis的SETNX命令setnx key value将key设置为value当键不存在时才能成功若键存在什么也不做成功返回1失败返回0 。 因为分布式锁还需要超时机制所以利用expire命令来设置。
实际上上面的步骤是有问题的setnx和expire是分开的两步操作不具有原子性如果执行完第一条指令应用异常或者重启了锁将无法过期。
正确的做法应该是使用jedis的set指令
private static final String LOCK_SUCCESS OK;private static final String SET_IF_NOT_EXIST NX;private static final String SET_WITH_EXPIRE_TIME PX;/*** 尝试获取分布式锁* param jedis Redis客户端* param lockKey 锁* param requestId 请求标识* param expireTime 超期时间* return 是否获取成功*/public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, long expireTime) {String result jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);if (LOCK_SUCCESS.equals(result)) {return true;}return false;}可以看到加锁就一行代码jedis.set(String key, String value, String nxxx, String expx, long time)这个set()方法一共有五个形参
第一个为key使用key来当锁因为key是唯一的。
第二个为value传的是requestId。requestId可以使用UUID.randomUUID().toString()方法生成。
假如value不是随机字符串而是一个固定值那么就可能存在下面的问题 1.客户端1获取锁成功 2.客户端1在某个操作上阻塞了太长时间 3.设置的key过期了锁自动释放了 4.客户端2获取到了对应同一个资源的锁 5.客户端1从阻塞中恢复过来因为value值一样所以执行释放锁操作时就会释放掉客户端2持有的锁这样就会造成问题 所以通常来说在释放锁时我们需要对value进行验证。通过给value赋值为requestId我们就知道这把锁是哪个请求加的了在解锁的时候就可以有依据。
第三个为nxxx这个参数填的是NX意思是SET IF NOT EXIST即当key不存在时我们进行set操作若key已经存在则不做任何操作如果是XX的话就是只在key存在时候进行set操作。
第四个为expx这个参数传的是PXEX代表过期时间单位为 秒PX代表毫秒。具体时间由第五个参数决定。
第五个为time与第四个参数相呼应代表key的过期时间。
总的来说执行上面的set()方法就只会导致两种结果1. 当前没有锁key不存在那么就进行加锁操作并对锁设置个有效期同时value表示加锁的客户端。2. 已有锁存在不做任何操作。
那么怎么释放锁呢
private static final Long RELEASE_SUCCESS 1L;/*** 释放分布式锁* param jedis Redis客户端* param lockKey 锁* param requestId 请求标识* return 是否释放成功*/public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {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;}return false;}释放锁时需要验证value值也就是说我们在获取锁的时候需要设置一个value不能直接用del key这种粗暴的方式因为直接del key任何客户端都可以进行解锁了所以解锁时我们需要判断锁是否是自己的基于value值来判断。
第二行代码我们将Lua代码传到jedis.eval()方法里并使参数KEYS[1]赋值为lockKeyARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。那么这段Lua代码的功能是什么呢其实很简单首先获取锁对应的value值检查是否与requestId相等如果相等则删除锁解锁。
为什么要使用Lua语言来实现呢因为要确保上述操作是原子性的。
执行eval()方法可以确保原子性源于Redis的特性下面是官网对eval命令的部分解释 简单来说就是在eval命令执行Lua代码的时候Lua代码将被当成一个命令去执行并且直到eval命令执行完成Redis才会执行其他命令。 实际上在Redis集群的时候也会出现问题比如说A客户端在Redis的master节点上拿到了锁但是这个加锁的key还没有同步到slave节点master故障发生故障转移一个slave节点升级为master节点B客户端也可以获取同个key的锁但客户端A也已经拿到锁了这就导致多个客户端都拿到锁。
4、Redission
对于Java用户而言我们经常使用JedisJedis是Redis的Java客户端除了Jedis之外Redisson也是Java的客户端。
Redisson是一个在Redis的基础上实现的Java驻内存数据网格In-Memory Data Grid。它不仅提供了一系列的分布式的Java常用对象还提供了许多分布式服务。Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离Separation of Concern从而让使用者能够将精力更集中地放在处理业务逻辑上。
具体使用过程如下。
首先加入pom依赖
dependencygroupIdorg.redisson/groupIdartifactIdredisson/artifactIdversion3.10.6/version
/dependency使用Redisson代码如下
// 1. 配置文件
Config config new Config();
config.useSingleServer().setAddress(redis://127.0.0.1:6379).setPassword(RedisConfig.PASSWORD).setDatabase(0);
//2. 构造RedissonClient
RedissonClient redissonClient Redisson.create(config);//3. 设置锁定资源名称
RLock lock redissonClient.getLock(redlock);
lock.lock();
try {System.out.println(获取锁成功实现业务逻辑);Thread.sleep(10000);
} catch (InterruptedException e) {e.printStackTrace();
} finally {lock.unlock();
}并且在redisson中还封装了其他的锁算法比如红锁。
详情可以看官方githubhttps://github.com/redisson/redisson/wiki/Table-of-Content 附录 在传统单体应用单机部署的情况下可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行锁控制。但是在分布式集群系统后由于分布式系统多线程、多进程并且分布在不同机器上这将使原单机部署情况下的并发控制锁策略失效单纯的Java API并不能提供分布式锁的能力。