文章目录
参考资料:
(15条消息) redis分布式锁及秒杀系统实战_艾-普-西-隆的博客-CSDN博客_redis释放锁就是删除key吗?
基于 Redis + Lua 脚本实现分布式锁,确保操作的原子性_Java Punk的博客-CSDN博客_redis分布式锁如何保证原子性
redisson中的看门狗机制总结 - 郭慕荣 - 博客园 (cnblogs.com)
在面试时候被面试官问趴了,特来总结一波~~
分布式锁
限制程序的并发执行。
在实现并发的基础上保持数据的一致性。
为什么使用分布式锁?
单机应用架构中,比如秒杀案例使用ReentrantLcok或者synchronized来达到秒杀商品互斥的目的。然而在分布式系统中,会存在多台机器并行去实现同一个功能。也就是说,在多进程中,如果还使用以上JDK提供的进程锁,来并发访问数据库资源就可能会出现商品超卖的情况。因此,需要我们来实现自己的分布式锁。
redis分布式锁的原理非常简单:在运行实际的业务代码之前,首先到redis中去获得唯一的redis锁,如果获取到,则继续执行业务代码,并在业务代码结束后主动释放锁;若未成功获取到锁,则不执行业务代码。
//通过向redis服务器插入一组键值对来获取锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if (lock) {
//业务代码在此
//......
//通过删除redis中的lock键值对释放锁
redisTemplate.delete("lock");
}
所谓的“获取锁”,“释放锁”操作,本质上就是向redis服务器插入和删除键值对。
分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 ifelse 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁library 可以休息了。 > set lock:codehole true ex 5 nx OK … do something critical … > del lock:codehole 上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的奥义所在。
实现一个分布式锁应该具备的特性:
- 高可用、高性能的获取锁与释放锁
- 在分布式系统环境下,一个方法或者变量同一时间只能被一个线程操作
- 具备锁失效机制,网络中断或宕机无法释放锁时,锁必须被删除,防止死锁
- 具备阻塞锁特性,即没有获取到锁,则继续等待获取锁
- 具备非阻塞锁特性,即没有获取到锁,则直接返回获取锁失败
- 具备可重入特性,一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁
常用的分布式锁的实现方式有三种:
- 基于数据库乐观锁(CAS)来实现;
- 基于 Redis 来实现;
- 基于 ZooKeeper 来实现。
Expire命令
为什么要设置过期时间
代码正常获取到锁并开始运行业务代码,但是业务代码有bug,抛错了,因为业务代码抛错,导致之后的释放锁代码没有执行,进而出现锁永久生效的情况。除非我们手动删除锁。
总结下:通过给redis分布式锁设置一个自动过期时间,可以防止业务代码抛错导致产生死锁。
那为什么不直接等它自动过期,而还是要主动释放呢?
如果不主动释放锁,那么每一次业务代码的执行成功与否都高度依赖锁的自动过期时间。倘若业务代码执行的快,在锁自动过期前结束,那么程序还必须等待锁自动释放后才能继续执行,这样显然是低效的,尤其对于高并发的场景;如果业务代码执行的慢,在锁自动过期后还没结束,相当于没锁,这可能导致不可预知的问题。所以主动释放锁是必须的。
Redis Expire
命令设置 key
的过期时间(seconds)。 设置的时间过期后,key 会被自动删除。带有超时时间的 key 通常被称为易失的(volatile)。
超时时间只能使用删除 key 或者覆盖 key 的命令清除,包括 DEL, SET, GETSET 和所有的 *STORE
命令。 对于修改 key 中存储的值,而不是用新值替换旧值的命令,不会修改超时时间。例如,自增 key 中存储的值的 INCR , 向list中新增一个值 LPUSH, 或者修改 hash 域的值 HSET ,这些都不会修改 key 的过期时间。
通过使用 PERSIST 命令把 key 改回持久的(persistent) key,这样 key 的过期时间也可以被清除。
key使用 RENAME 改名后,过期时间被转移到新 key 上。
已存在的旧 key 使用 RENAME 改名,那么新 key 会继承所有旧 key 的属性。例如,一个名为 KeyA 的 key 使用命令 RENAME Key_B Key_A
改名,新的 KeyA 会继承包括超时时间在内的所有 Key_B 的属性。
特别注意,使用负值调用 EXPIRE/PEXPIRE 或使用过去的时间调用 EXPIREAT/PEXPIREAT ,那么 key 会被删除 deleted 而不是过期。 (因为, 触发的key event 将是 del
, 而不是 expired
).
过期时间的精度与持久
Redis 2.4 过期时间并不精准,一般在 0到 1 秒多。
从Redis 2.6起,过期时间精度提高到 0 到 1 毫秒多 。
key 的过期时间以绝对 Unix 时间戳的方式存储。这意味无论 Redis 是否运行,过期时间都会流逝。
服务器的时间必须稳定准确,这样过期时间才能更准确。如果在两个时间相差较多的机器之间移动 RDB 文件,那么可能会出现所有的 key 在加载的时候都过期了。
运行的 Redis 也会不停的检查服务器的时间,如果你设置一个带有 1000 秒过期时间的key,然后你把服务器的时间向前调了 2000 秒,那么这个 key 会立刻过期,不是等 1000 秒后过期。
Redission看门狗机制
redisson中的看门狗机制总结 - 郭慕荣 - 博客园 (cnblogs.com)
业务运行时间可能就是比较长,过期时间怎么设置?如果设置的短,可能业务还没结束就主动释放锁了;如果过期时间设置的太长,会导致并发效率大幅下降。这该怎么办?这就需要一个锁自动延期的机制
我们在网上看到的redis分布式锁的工具方法,大都满足互斥、防止死锁的特性,有些工具方法会满足可重入特性。如果只满足上述3种特性会有哪些隐患呢?redis分布式锁无法自动续期,比如,一个锁设置了1分钟超时释放,如果拿到这个锁的线程在一分钟内没有执行完毕,那么这个锁就会被其他线程拿到,可能会导致严重的线上问题,我已经在秒杀系统故障排查文章中,看到好多因为这个缺陷导致的超卖了
Redisson 锁的加锁机制:
线程去获取锁,获取成功则执行lua脚本,保存数据到redis数据库。如果获取失败: 一直通过while循环尝试获取锁(可自定义等待时间,超时后返回失败),获取成功后,执行lua脚本,保存数据到redis数据库。Redisson提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这在Redisson中称之为 Watch Dog 机制。同时 redisson 还有公平锁、读写锁的实现。
public void test() throws Exception{
RLock lock = redissonClient.getLock("guodong"); // 拿锁失败时会不停的重试
// 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
lock.lock();
// 尝试拿锁10s后停止重试,返回false 具有Watch Dog 自动延期机制 默认续30s
boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);
// 没有Watch Dog ,10s后自动释放
lock.lock(10, TimeUnit.SECONDS);
// 尝试拿锁100s后停止重试,返回false 没有Watch Dog ,10s后自动释放
boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
Thread.sleep(40000L);
lock.unlock();
}
- watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
- watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
- 如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
- 要使 watchLog机制生效 ,lock时 不要设置 过期时间
- watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
- watchdog 会每 lockWatchdogTimeout/3时间,去延时。
- watchdog 通过 类似netty的 Future功能来实现异步延时
- watchdog 最终还是通过 lua脚本来进行延时
redis淘汰过期key
Redis 的 key 有两种过期淘汰的方式:被动方式、主动方式。
被动过期:用户访问某个 key 的时候,key 被发现过期。
当然,被动方式过期对于那些永远也不会再次被访问的 key 并没有效果。不管怎么,这些 key 都应被过期淘汰,所以 Redis 周期性主动随机检查一部分被设置生存时间的 key,那些已经过期的 key 会被从 key 空间中删除。
Redis每秒执行10次下面的操作:
- 从带有生存时间的 key 的集合中随机选 20 进行检查。
- 删除所有过期的key。
- 如20里面有超过25%的key过期,立刻继续执行步骤1。
这是一个狭义概率算法,我们假设我们选出来的样本 key 代表整个 key 空间,我们继续过期检查直到过期 key 的比例降到 25% 以下。
这意味着在任意时刻已经过期但还占用内存的 key 的数量,最多等于每秒最多写操作的四分之一。
Redis过期KEY的删除策略:
惰性删除和定期删除两张策略配合使用。
惰性删除:不去主动删除,而是在访问数据的时候,再检查当前key是否过期,如果过期则执行删除并返回null给客户端,如果没有过期则返回正常信息给客户端。它的优点是简单,不需要对过期的数据做额外的处理,只有再每次访问的时候才会检查key是否过期,缺点就是删除过期key不及时,造成了一定的空间浪费。
定期删除:Redis会周期性地随机测试一批设置了过期时间的key进行处理。测试的已过期的key将被删除。
为什么要配合使用?
只使用惰性删除,过期key占用的内存不会及时得到释放,内存永远不会释放,从而造成内存泄漏。
只用定期删除,难以确认删除操作执行的时长与效率,若太频繁,对CPU不友好,删除过期键会占用一部分的cpu时间,对服务器的响应时间和吞吐量造成影响;若用的太少,过期key占用的内存不会及时得到释放,最重要的是如果在获取某个key时候过期时间已经到了,但是还没有执行定期删除,就会返回这个键的值,这是业务不能容忍的错误。
模式: 导航 session
假设你有个 web 服务并且你关注用户最近最新访问的 N 个页面,每个相邻新页面的访问时间在 60 秒内,概念上我们可把这一系列的页面访问作为一个用户的导航会话。 这里面包含了很多关于用户正在寻找什么样产品的有用信息,你可以据此给用户推荐产品。
在 Redis 中使用下面的策略,我们可以给这种模式建模:用户每访问一个页面,我们都执行下面的指令:
MULTI
RPUSH pagewviews.user:<userid> http://.....
EXPIRE pagewviews.user:<userid> 60
EXEC
如果用户访问的页面空闲时间超过 60 秒,那么这个 key 将会被删除,只有那些接下来小于 60 秒空闲的页面访问将会被保留。
这个模式可以修改为使用计数指令 INCR 替换列表的 RPUSH。
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。
为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。
tag = random.nextint() # 随机数
if redis.set(key, tag, nx=True, ex=5):
do_something()
redis.delifequals(key, tag) # 假象的 delifequals 指令
有一个更加安全的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。
# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
为什么使用LUA脚本?
Redis使用同一个Lua解释器来执行所有命令,同时,Redis保证以一种原子性的方式来执行脚本:当lua脚本在执行的时候,不会有其他脚本和命令同时执行,这种语义类似于 MULTI/EXEC。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。
然而这也意味着,执行一个较慢的lua脚本是不建议的,由于脚本的开销非常低,构造一个快速执行的脚本并非难事。但是你要注意到,当你正在执行一个比较慢的脚本时,所以其他的客户端都无法执行命令。
Lua脚本是**高并发、高性能的必备脚本语言,**大部分的开源框架(如:redission)中的分布式锁组件,都是用纯lua脚本实现的。
Lua 脚本可以保证连续多个指令的原子性执行。
为什么要保证原子执行?
和上面将的道理类似,这里用一个场景来描述:误解锁
1、有业务1和业务2,两个业务并发抢用redis锁(想象两个人同时抢购同一商品)
2、业务1的运行时间t1非常长,甚至长过了redis锁的自动失效时间timeout,即t1>timeout
3、那么在业务1运行timeout时间后,锁自动释放
4、在业务1结束之前,业务2开始运行,并成功得到了redis锁(业务1的锁已经到期自动释放)
5、在业务2运行期间,业务1结束了,并尝试主动释放锁。
6、因为业务1获取的锁早就已经在timeout之后自动释放了,此时业务1释放的会是业务2的锁!显然这样是不合理的,之后业务2的操作其实没有上锁,也就无法保证并发的正确性。
引入LUA脚本,将if语句和删锁操作写在一个脚本语句中,进而确保原子性:所以,只有确保判断锁和删除锁是一步操作时,才能避免上面的问题,才能确保原子性。Redis会将整个脚本作为一个整体执行,中间不会被其他进程或者进程的命令插入。将if语句块修改为以下形式:
//使用LUA脚本执行原子操作,避免锁误删
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<>();
redisScript.setScriptText(script);
// 设置一下返回值类型 为Long
// 因为删除判断的时候,返回的0,给其封装为数据类型。如果不封装那么默认返回String 类型,
// 那么返回字符串与0 会有发生错误。
redisScript.setResultType(Long.class);
// 第一个要是script 脚本 ,第二个需要判断的key,第三个就是key所对应的值。
redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
Redis + lua脚本实现分布式锁
1.加锁的LUA脚本:getLock.lua
--- -1 failed
--- 1 success
--- getLock key
local key = KEYS[1]
local requestId = KEYS[2]
local ttl = tonumber(KEYS[3])
local result = redis.call('setnx', key, requestId)
if result == 1 then
--PEXPIRE:以毫秒的形式指定过期时间
redis.call('pexpire', key, ttl)
else
result = -1;
-- 如果value相同,则认为是同一个线程的请求,则认为重入锁
local value = redis.call('get', key)
if (value == requestId) then
result = 1;
redis.call('pexpire', key, ttl)
end
end
-- 如果获取锁成功,则返回 1
return result
2.解锁的Lua脚本: releaseLock.lua
--- -1 failed
--- 1 success
-- releaseLock key
local key = KEYS[1]
local requestId = KEYS[2]
local value = redis.call('get', key)
if value == requestId then
redis.call('del', key);
return 1;
end
return -1
将它们放在资源文件夹下:
3.JAVA调用LUA脚本
// 简单加锁
public static boolean getLock(String key, String requestId, String expireTime) {
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/getLock.lua")));
Object result = redisTemplate.execute(redisScript,argsSerializer,resultSerializer, Collections.singletonList(key),requestId,expireTime);
if(EXEC_RESULT.equals(result)) {
return true;
}
return false;
}
// 简单解锁
public static boolean releaseLock(String key, String requestId) {
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/releaseLock.lua")));
Object result = redisTemplate.execute(redisScript,argsSerializer,resultSerializer,Collections.singletonList(key),requestId);
if(EXEC_RESULT.equals(result)) {
return true;
}
return false;
}
可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。
重入就是,同一个线程多次获取同一把锁是允许的,不会造成死锁,这一点synchronized偏向锁提供了很好的思路,synchronized的实现重入是在JVM层面,JAVA对象头MARK WORD中便藏有线程ID和计数器来对当前线程做重入判断,避免每次CAS。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁标志是否设置成1:没有则CAS竞争;设置了,则CAS将对象头偏向锁指向当前线程。
再维护一个计数器,同个线程进入则自增1,离开再减1,直到为0才能释放
以上还不是可重入锁的全部,精确一点还需要考虑内存锁计数的过期时间,代码复杂度将会继续升高。不推荐使用可重入锁,它加重了客户端的复杂性,在编写业务方法时注意在逻辑结构上进行调整完全可以不使用可重入锁。
仿造该方案,我们需改造Lua脚本:
1.需要存储 锁名称lockName、获得该锁的线程id和对应线程的进入次数count
2.加锁
每次线程获取锁时,判断是否已存在该锁
- 不存在
- 设置hash的key为线程id,value初始化为1
- 设置过期时间
- 返回获取锁成功true
- 存在
- 继续判断是否存在当前线程id的hash key
- 存在,线程key的value + 1,重入次数增加1,设置过期时间
- 不存在,返回加锁失败
3.解锁
每次线程来解锁时,判断是否已存在该锁
- 存在
- 是否有该线程的id的hash key,有则减1,无则返回解锁失败
- 减1后,判断剩余count是否为0,为0则说明不再需要这把锁,执行del命令删除
加锁lock.lua
local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- lockname不存在
if(redis.call('exists', key) == 0) then
redis.call('hset', key, threadId, '1');
redis.call('expire', key, releaseTime);
return 1;
end;
-- 当前线程已id存在
if(redis.call('hexists', key, threadId) == 1) then
redis.call('hincrby', key, threadId, '1');
redis.call('expire', key, releaseTime);
return 1;
end;
return 0;
解锁unlock.lua
local key = KEYS[1];
local threadId = ARGV[1];
-- lockname、threadId不存在
if (redis.call('hexists', key, threadId) == 0) then
return nil;
end;
-- 计数器-1
local count = redis.call('hincrby', key, threadId, -1);
-- 删除lock
if (count == 0) then
redis.call('del', key);
return nil;
end;
代码
/**
* @description 原生redis实现分布式锁
* @date 2021/2/6 10:51 下午
**/
@Getter
@Setter
public class RedisLock {
private RedisTemplate redisTemplate;
private DefaultRedisScript<Long> lockScript;
private DefaultRedisScript<Object> unlockScript;
public RedisLock(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
// 加载加锁的脚本
lockScript = new DefaultRedisScript<>();
this.lockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));
this.lockScript.setResultType(Long.class);
// 加载释放锁的脚本
unlockScript = new DefaultRedisScript<>();
this.unlockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
}
/**
* 获取锁
*/
public String tryLock(String lockName, long releaseTime) {
// 存入的线程信息的前缀
String key = UUID.randomUUID().toString();
// 执行脚本
Long result = (Long) redisTemplate.execute(
lockScript,
Collections.singletonList(lockName),
key + Thread.currentThread().getId(),
releaseTime);
if (result != null && result.intValue() == 1) {
return key;
} else {
return null;
}
}
/**
* 解锁
* @param lockName
* @param key
*/
public void unlock(String lockName, String key) {
redisTemplate.execute(unlockScript,
Collections.singletonList(lockName),
key + Thread.currentThread().getId()
);
}
}
JAVA版本的可重入锁实现
public class RedisWithReentrantLock {
private ThreadLocal<Map> lockers = new ThreadLocal<>();
private Jedis jedis;
public RedisWithReentrantLock(Jedis jedis) {
this.jedis = jedis;
}
private boolean _lock(String key) {
return jedis.set(key, "", "nx", "ex", 5L) != null;
}
private void _unlock(String key) {
jedis.del(key);
}
private Map <String, Integer> currentLockers() {
Map <String, Integer> refs = lockers.get();
if (refs != null) {
return refs;
}
lockers.set(new HashMap<>());
return lockers.get();
}
public boolean lock(String key) {
Map refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt != null) {
refs.put(key, refCnt + 1);
return true;
}
boolean ok = this._lock(key);
if (!ok) {
return false;
}
refs.put(key, 1);
return true;
}
public boolean unlock(String key) {
Map refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt == null) {
return false;
}
refCnt -= 1;
if (refCnt > 0) {
refs.put(key, refCnt);
} else {
refs.remove(key);
this ._unlock(key);
}
return true;
}
public static void main(String[] args) {
Jedis jedis = new Jedis();
RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
System.out.println(redis.lock("codehole"));
System.out.println(redis.lock("codehole"));
System.out.println(redis.unlock("codehole"));
System.out.println(redis.unlock("codehole"));
}
}
最强分布式工具redisson的分布式锁原理
最强分布式工具Redisson(一):分布式锁 - 掘金 (juejin.cn)
今日推歌
-----《寻》 华晨宇
沿途风景如歌变幻 再辗转
人山人海的对白换一句等待
看不懂黑白却听得到钟摆
去新世界冒险和内心作伴
风吹刹那不知你在向哪片日落张望
很多话想说转过身只看见荒漠空旷
一个人难免崇拜流浪
却变成和自己的迷藏
最好的旅途是让我们记住爱的模样