文章目录
简述
说到Redis,我们第一想到的功能就是可以缓存数据,除此之外,Redis因为单进程、性能高的特点,它还经常被用于做分布式锁。
锁我们都知道,在程序中的作用就是同步工具,保证共享资源在同一时刻只能被一个线程访问,Java中的锁我们都很熟悉了,像synchronized 、Lock都是我们经常使用的,但是Java的锁只能保证单机的时候有效,分布式集群环境就无能为力了,这个时候我们就需要用到分布式锁。
分布式锁,顾名思义,就是分布式项目开发中用到的锁,可以用来控制分布式系统之间同步访问共享资源,一般来说,分布式锁需要满足的特性有这么几点:
1、互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
2、高可用性:在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署;
3、防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或者网络不可达时产生死锁;
4、独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了;
业界里可以实现分布式锁效果的工具很多,但操作无非这么几个:加锁、解锁、防止锁超时。
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
1.互斥性。在任意时刻,只有一个客户端能持有锁。
2.不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3.具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
4.解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
使用场景
分布式锁,是控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁,我们项目中经常使用Redis作为分布式锁。
lua脚本
Redis能做分布式锁,是因为单线程指令执行,获取锁标记过程没有安全问题
一般也是用lua脚本代替。lua脚本如下:
if redis.call(‘get’,KEYS[1]) == ARGV[1] then return redis.call(‘del’,KEYS[1]) else return 0 end;
这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是存在锁过期释放了,业务还没执行完的问题(实际上,估算个业务处理的时间,一般没啥问题了)。
redis分布式锁的实现方式
Redis是一种常用的内存数据库,通过其支持的原子性操作和分布式特性,可以很方便地实现分布式锁。以下是一种常见的基于Redis实现分布式锁的方式:
获取锁:
○ 使用Redis的SET key value NX PX timeout命令来尝试设置一个键值对,其中key为锁的名称,value为唯一标识符(如UUID),NX表示只在键不存在时才设置成功,PX表示设置键的过期时间。
○ 如果命令执行成功,说明当前客户端成功获取了锁;如果设置失败,说明锁已被其他客户端获取,当前客户端需要等待或进行重试。
释放锁:
○ 通过Lua脚本或者使用Redis的DEL key命令来删除锁对应的键值对,释放锁资源。
○ 在释放锁的过程中要确保只有持有锁的客户端才能释放该锁,避免误删其他客户端的锁。
锁的过期时间:
○ 设置锁的过期时间是为了防止锁被持有者不释放导致死锁情况发生。在获取锁时设置一个合理的过期时间,确保即使持有锁的客户端异常退出也能自动释放锁。
可重入性:
○ 可以通过为每个客户端维护一个计数器来实现重入锁,即同一个客户端可以多次获取同一个锁而不会被其他客户端抢占。
容错处理:
○ 在释放锁时需要处理异常情况,确保即使出现异常也能够正确释放锁,避免锁被永久占用。
通过以上方式,可以利用Redis实现简单而高效的分布式锁机制,确保在分布式系统中对共享资源的访问是有序且安全的。当然,在实际应用中还需考虑更多复杂情况,如锁竞争、锁有效性检查、锁续约等问题。
基于 Redission 客户端来实现分布式锁
选了Redis分布式锁的几种实现方法
命令setnx + expire分开写
setnx + value值是过期时间
set的扩展命令(set ex px nx)
set ex px nx + 校验唯一随机值,再删除
命令setnx + expire分开写
if(jedis.setnx(key,lock_value) == 1){ //加锁 expire(key,100);
//设置过期时间 try { do something //业务请求 }catch(){ } finally { jedis.del(key); //释放锁 } }
如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash掉或者要重启维护了,那这个锁就“长生不老”了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现。
setnx + value值是过期时间
long expires = System.currentTimeMillis() + expireTime;
//系统时间+设置的过期时间 String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功 if (jedis.setnx(key, expiresStr) == 1) { return true; } //
如果锁已经存在,获取锁的过期时间 String currentValueStr = jedis.get(key); // 如果获取到的过期时间,小于系统当前时间,表示已经过期 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈) String oldValueStr = jedis.getSet(key_resource_id, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁 return true; } } //其他情况,均返回加锁失败 return false; }
笔者看过有开发小伙伴是这么实现分布式锁的,但是这种方案也有这些缺点:
过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。
没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
锁过期的时候,并发多个客户端同时请求过来,都执行了jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。
set的扩展命令(set ex px nx)(注意可能存在的问题)
if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { jedis.del(key); //释放锁 } }
这个方案可能存在这样的问题:
锁过期释放了,业务还没执行完。
锁被别的线程误删。
10.4 set ex px nx + 校验唯一随机值,再删除
if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { //判断是不是当前线程加的锁,是才释放 if (uni_request_id.equals(jedis.get(key))) { jedis.del(key); //释放锁 } } }
在这里,判断当前线程加的锁和释放锁是不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

一般也是用lua脚本代替
lua脚本如下:
if redis.call(‘get’,KEYS[1]) == ARGV[1] then return redis.call(‘del’,KEYS[1]) else return 0 end;
这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是存在锁过期释放了,业务还没执行完的问题(实际上,估算个业务处理的时间,一般没啥问题了)。
分布式锁应该满足哪些特性
分布式锁就是用来控制同一时刻,只有一个 JVM 进程中的一个线程可以访问被保护的资源。
互斥:在任何给定时刻,只有一个客户端可以持有锁;
无死锁:任何时刻都有可能获得锁,即使获取锁的客户端崩溃;
容错:只要大多数 Redis的节点都已经启动,客户端就可以获取和释放锁
「加锁」、「设置超时」是两个命令,他们不是原子操作。
如果出现只执行了第一条,第二条没机会执行就会出现「超时时间」设置失败,依然出现锁无法释放。
码哥,那咋办,我想被一条龙服务,要解决这个问题
Redis 2.6.X 之后,官方拓展了 SET 命令的参数,满足了当 key 不存在则设置 value,同时设置超时时间的语义,并且满足原子性。
异常场景
释放了不是自己加的锁
No,还有一种场景会导致释放别人的锁:
客户 1 获取锁成功并设置设置 30 秒超时;
客户 1 因为一些原因导致执行很慢(网络问题、发生 FullGC……),过了 30 秒依然没执行完,但是锁过期「自动释放了;
客户 2 申请加锁成功;
客户 1 执行完成,执行 DEL 释放锁指令,这个时候就把客户 2 的锁给释放了。
码哥,我在加锁的时候设置一个「唯一标识」作为 value 代表加锁的客户端。SET resource_name random_value NX PX 30000
在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。
// 比对 value 与 唯一标识
if (redis.get("lock:168").equals(random_value)){
redis.del("lock:168"); //比对成功则删除
}
官网
https://redis.io/docs/manual/patterns/distributed-locks/
们可以通过 Lua 脚本来实现,这样判断和删除的过程就是原子操作了
// 获取锁的 value 与 ARGV[1] 是否匹配,匹配则执行 del
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
正确设置锁超时
我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁「续航」。
加锁的时候设置一个过期时间,同时客户端开启一个「守护线程」,定时去检测这个锁的失效时间。
如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。
别慌,已经有一个库把这些工作都封装好了他叫 Redisson。
在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
public void doSomething() {
try {
// 上锁
redisLock.lock();
// 处理业务
...
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
redisLock.unlock();
}
}
实现可重入锁
当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。
public synchronized void a() {
b();
}
public synchronized void b() {
// pass
}
Redis Hash 可重入锁
Redisson 类库就是通过 Redis Hash 来实现可重入锁
当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。
退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
可以看到可重入锁最大特性就是计数,计算加锁的次数。
所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
通过 Lua 脚本实现原子性,假设 KEYS1 = 「lock」, ARGV「1000,uuid」:
---- 1 代表 true
---- 0 代表 false
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end ;
return 0;
加锁代码首先使用 Redis exists 命令判断当前 lock 这个锁是否存在。
如果锁不存在的话,直接使用 hincrby创建一个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0,然后再次加 1,最后再设置过期时间。
如果当前锁存在,则使用 hexists判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使用 hincrby 加 1,最后再次设置过期时间。
最后如果上述两个逻辑都不符合,直接返回。
-- 判断 hash set 可重入 key 的值是否等于 0
-- 如果为 0 代表 该可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil;
end ;
-- 计算当前可重入次数
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
-- 小于等于 0 代表可以解锁
if (counter > 0) then
return 0;
else
redis.call('del', KEYS[1]);
return 1;
end ;
return nil;
redis分布式锁解决死锁问题
如下,执行业务代码时如果宕机了,锁就无法释放,后续其他线程无法获取锁,导致死锁。
在Redis中使用分布式锁时,通常采用以下方法来解决可能出现的死锁问题:
1.设置锁的过期时间:在获取锁时,可以为锁设置一个过期时间,确保即使出现某些异常情况导致锁没有被显式释放,也能在一定时间后自动释放,避免长时间占用锁资源。
2.使用 Lua 脚本进行原子操作:通过 Lua 脚本可以将判断锁是否存在和释放锁两个操作合并成一个原子操作,确保获取锁和释放锁的过程是不可中断的,避免出现竞争条件导致的死锁。
3.使用带有唯一标识的锁:在设置锁时,可以为每个获取锁的客户端生成一个唯一的标识,释放锁时验证标识是否匹配,确保只有持有锁的客户端才能释放锁,避免其他客户端误操作导致的死锁。
4.重入性判断:在获取锁时,可以判断当前客户端是否已经持有该锁,如果是则允许重入,避免同一个客户端多次获取同一个锁而导致死锁。
通过以上方法的综合应用,可以有效地避免Redis分布式锁可能出现的死锁问题,确保系统在并发情况下正常稳定运行。
Redlock详解
Redlock是一个用于分布式系统中实现分布式锁的算法,由Redis的创始人Salvatore Sanfilippo提出。Redlock旨在解决多个Redis节点之间的分布式锁同步问题,并且能够容忍一定程度的故障。
Redlock算法的核心思想是通过多个Redis实例来协作实现分布式锁,以应对单点故障或网络分区情况下的问题。算法的基本流程如下:
选择N个独立的Redis实例:选择N个独立的Redis实例,这些实例可以部署在不同的物理服务器上,以增加系统的容错性。
获取锁:客户端获取锁时,针对这N个Redis实例,分别尝试设置相同的锁标识(例如一个唯一的字符串)和过期时间。如果大多数(即至少超过一半)的Redis实例都成功设置了锁,则认为获取锁成功,否则获取锁失败。
释放锁:释放锁时,客户端需要向所有的Redis实例发送释放锁的请求,只有当大多数实例都成功释放锁才算释放成功。
Redlock算法的设计考虑了网络分区、节点故障等因素,能够在一定程度上保证在正常运行时能够正确地提供分布式锁服务。然而,需要指出的是,Redlock算法并不是完美的,它仍然存在一些潜在的问题,例如时钟漂移、网络延迟等因素可能导致算法失效。因此,在实际应用中,需要综合考虑具体场景和系统需求,慎重选择适合的分布式锁方案。
Redlock实现
在Redis的分布式环境中,我们假设有N个Redis master。
这些节点完全互相独立,不存在主从复制或者其他集群协调机制。
我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。
现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
获取当前Unix时间,以毫秒为单位。
依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。
当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。
例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。
这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。
如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。
当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
本文详细介绍了如何在Redis中使用lua脚本实现分布式锁,探讨了锁的特性如互斥性、高可用性和可重入性,以及如何避免死锁问题。重点讲解了Redlock算法及其在分布式环境中的应用和注意事项。
3038

被折叠的 条评论
为什么被折叠?



