redis–20.1–锁–分布式锁(自己实现)
1、分布式锁
目前JVM提供的锁(synchronized)只能作用到当前系统,跨系统是不支持锁操作的,这个时候就要使用分布式锁了。
1.1、分布式锁的几点考虑
1.1.1、互斥
同一时刻只能有一个线程获得锁
1.1.2、防止死锁
非正常原因导致代码无法执行释放锁的命令,导致其它线程都无法获得锁,造成死锁。
所以分布式锁有必要设置锁的有效时间,确保系统出现故障后,在一定时间内能够主动去释放锁,避免造成死锁的情况。
1.1.3、性能
对于访问量大的共享资源,需要考虑减少锁等待的时间,避免导致大量线程阻塞。
所以在锁的设计时,需要考虑两点。
- 锁的颗粒度要尽量小。比如你要通过锁来减库存,那这个锁的名称你可以设置成是商品的ID,而不是任取名称。这样这个锁只对当前商品有效,锁的颗粒度小。
- 锁的范围尽量要小。比如只要锁2行代码就可以解决问题的,那就不要去锁10行代码了。
1.1.4、重入
我们知道ReentrantLock是可重入锁,那它的特点就是:同一个线程可以重复拿到同一个资源的锁。重入锁非常有利于资源的高效利用。
1.2、怎么实现分布式锁?我们听过问题来阐述这个问题。
1.3、使用场景
分布式环境,操作共享资源的情况
1.3.1、定时任务
集群环境下的定时任务,存在A服务器执行任务t1,B服务器也执行了任务t1,如果t1是创建订单,那么就会出现重复订单。
这个是否可以使用分布式锁来解决问题。
1.4、分布式锁原理
2、常见分布式锁方案对比
2.1、基于ZooKeeper(不推荐)
2.1.1、实现原理
- 加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。
- 若是,则表示获取到锁;
- 若否,则则watch /lock目录下序号比自身小的前一个节点
- 解锁:删除节点
2.1.2、优点
- 由zk保障系统高可用
- Curator框架已原生支持系列分布式锁命令,使用简单
2.1.3、缺点
维护一套zk集群,维保成本高
2.2、基于redis命令(不推荐)
2.2.1、实现原理
- 加锁:执行setnx,若成功再执行expire添加过期时间
- 解锁:执行delete命令
2.2.2、优点
实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好
2.2.3、缺点
- setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁
- delete命令存在误删除非当前线程持有的锁的可能
- 不支持阻塞等待、不可重入
2.3、基于redis Lua脚本(推荐)
2.3.1、实现原理
- 加锁
-- KEYS[1]为lock_name,ARGV[1]为random_value, ARGV[2]为seconds,
redis.call('SET', KEYS[1]),ARGV[1]);
redis.call('pexpire', KEYS[1], ARGV[2]);
- 解锁:执行Lua脚本,释放锁时验证random_value
-- KEYS[1]为lock_name,ARGV[1]为random_value
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2.3.2、优点
实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好
2.3.3、缺点
- 不支持阻塞等待、不可重入
3、代码实现
3.1、代码实现–不包含锁续命
3.1.1、代码
@RequestMapping("lockTest")
public void testLockLua(@RequestParam("proId") String proId) {
// 当前商品id获取到锁(秒杀商品场景)
String locKey = "lock:" + proId;
// 获取锁
Boolean lock = RedisUtil.lock(locKey, 3000, 2000000L);
if (lock) {
//获取缓存中的数字
Object value = RedisUtil.get("num");
// 如果是空,
if (StringUtils.isEmpty(value)) {
value = 0;
}
// 转int 类型
int num = Integer.parseInt(value + "");
// 使num 每次+1 放入缓存
RedisUtil.set("num", String.valueOf(++num));
//当前的库存数
System.out.println("当前线程id:" + Thread.currentThread().getId() + ",当前数字:" + num);
}
//分布式锁:释放锁
RedisUtil.unlock(locKey);
}
/**
* 分布式锁:获取锁
*
* @param lockKey redis 的key
* @param expireTime 过期时间,单位毫秒,过期时间是为了防止死锁
* @param outTime 超时时间,单位毫秒,获取锁的超时时间,过了这个时间就不获取锁了,默认5秒
* @return java.lang.Boolean
* @author <a href="920786312@qq.com">周飞</a>
* @since 2024/3/23 20:20
*/
public static Boolean lock(String lockKey, long expireTime, Long outTime) {
if (outTime == null) {//如果超时时间没有值,设置默认值
outTime = defaultOutTime;
}
// 声明一个lockValue,这个lockValue要保证唯一 ,用于保证加锁和解锁必须是同一个线程
// 这里使用当前线程的id
String lockValue = lockKeyPrefix + Thread.currentThread().getId();
// 定义一个锁key前缀,防止key冲突
lockKey = lockKeyPrefix + lockKey;
long startTime = System.currentTimeMillis();//当前时间
while (true) {//没有获取锁,不断尝试,超过超时时间,就不尝试了。
// 获取锁, 这里的命令是: set locKey lockValue NX EX 3
Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
if (lock) {
//开启一个异步线程,定时给锁续命
return true;
}
//超过超时时间,就不尝试了。
if (System.currentTimeMillis() - startTime > outTime) {
break;//跳出循环
}
try {
Thread.sleep(100); // 睡眠100毫秒后继续尝试
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return false;
}
/**
* 分布式锁:释放锁
*
* @param lockKey
* @return void
* @author <a href="920786312@qq.com">周飞</a>
* @since 2024/3/23 20:36
*/
public static void unlock(String lockKey) {
// 定义一个锁key前缀,防止key冲突
lockKey = lockKeyPrefix + lockKey;
//线程id
String lockValue = lockKeyPrefix + Thread.currentThread().getId();
// 使用lua脚本来保证释放锁的原子性
// 定义lua 脚本,格式如下
// redis.call('命令名称','key类型的参数','其他参数'......)
// key类型的参数:会放入KEYS数组
// 其他参数:会放入ARGV数组
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 使用redis执行lua执行
redisScript.setScriptText(script);
// 设置一下返回值类型 为Long
// 因为删除判断的时候,返回的0,给其封装为数据类型。如果不封装那么默认返回String类型,那么返回字符串0,会有发生错误。
//
redisScript.setResultType(Long.class);
// 第1个参数:script脚本
// 第2个参数:lockKey,对应lua脚本的 KEYS[1]
// 第3个参数:lockValue,对应lua脚本的 ARGV[1]
redisTemplate.execute(redisScript, Arrays.asList(lockKey), lockValue);
}
3.1.2、测试
ab -n 2000 -c 200 -k http://192.168.43.45:8080/lockTest?proId=111
3.2、代码实现–包含锁续命
/**
* 锁续命方法
*
* @param lockKey redis 的key
* @param expireTime 过期时间,单位毫秒,过期时间是为了防止死锁
* @return void
* @author <a href="920786312@qq.com">周飞</a>
* @since 2024/3/24 18:00
*/
public static void addLockLife(String lockKey, long expireTime) {
AtomicInteger num = new AtomicInteger(0);//续命次数
long initialDelay = expireTime / 3; //延迟执行时间
long period = expireTime - initialDelay;//定时任务执行间隔时间,要小于过期时间
ScheduledFuture<?> scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
if (!expire(lockKey, expireTime)) {//重置过期时间失败,说明key已经不存在,key不存在说明线程结束了
//结束这个定时任务。
logger.debug("分布式锁lock:lockKey:" + lockKey + ",结束续命,续命总次数" + num.get());
futures.get(lockKey).cancel(false);
futures.remove(lockKey);//从map中删除
}
logger.debug("分布式锁lock:lockKey:" + lockKey + ",锁续命次数" + num.incrementAndGet());
}, initialDelay, period, TimeUnit.MILLISECONDS);//开始定时任务实现续命,每period毫秒续命一次
futures.put(lockKey, scheduledFuture);
}