Redis|Redis实现分布式锁

目录

原生Redis实现
基于数据库实现分布式锁的方式:
深入浅出版:
如何避免死锁?
锁被别人释放怎么办?
锁过期时间不好评估怎么办?
基于Redission实现
Redis队列解决秒杀超卖问题
java中调用LUA脚本

原生Redis实现 1、获取锁的时候,使用 setnx(SETNX key value:当且仅当 key 不存在时,set 一个 key 为 value 的字符串,返回 1;若 key 存在,则什么都不做,返回 0)加锁,锁的 value 值为一个随机生成的 UUID,在释放锁的时候进行判断。并使用 expire 命令为锁添加一个超时时间,超过该时间则自动释放锁。
2、获取锁的时候调用 setnx,如果返回 0,则该锁正在被别人使用,返回 1 则成功获取锁。 还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
3、释放锁的时候,通过 UUID 判断是不是该锁,若是该锁,则执行 delete 进行锁释放。

基于数据库实现分布式锁的方式: mysql InnoDB引擎
比如一条修改sqlupdate xx for update;
在sql后面加上for update,条件字段使用索引字段,这样就完成了行锁,也就没有并发的危险了,但是效率低下。

深入浅出版: 想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示 SET if Not exists,即如果 key 不存在,才会设置它的值,否则什么也不做。
两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lock 1 (integer) 1// 客户端1,加锁成功

客户端 2 申请加锁,因为它后到达,加锁失败:
127.0.0.1:6379> SETNX lock 1 (integer) 0// 客户端2,加锁失败

此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。
操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?
【Redis|Redis实现分布式锁】也很简单,直接使用 DEL 命令删除这个 key 即可:
127.0.0.1:6379> DEL lock // 释放锁 (integer) 1

这个逻辑非常简单,整体的路程就是这样:
Redis|Redis实现分布式锁
文章图片

但是,它存在一个很大的问题,当客户端1 拿到锁后,如果发生下面的场景,就会造成「死锁」:
  1. 程序处理业务逻辑异常,没及时释放锁
  2. 进程挂了,没机会释放锁
这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。
怎么解决这个问题呢?
如何避免死锁? 我们很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。
在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:
127.0.0.1:6379> SETNX lock 1// 加锁 (integer) 1 127.0.0.1:6379> EXPIRE lock 10// 10s后自动过期 (integer) 1

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。
但这样真的没问题吗?
还是有问题。
现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:
  1. SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
  2. SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  3. SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。
怎么办?
在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。
但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
// 一条命令保证原子性执行 127.0.0.1:6379> SET lock 1 EX 10 NX OK

这样就解决了死锁问题,也比较简单。
我们再来看分析下,它还有什么问题?
试想这样一种场景:
  1. 客户端1 加锁成功,开始操作共享资源
  2. 客户端1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端2 加锁成功,开始操作共享资源
  4. 客户端1 操作共享资源完成,释放锁(但释放的是客户端2 的锁)
看到了么,这里存在两个严重的问题:
  1. 锁过期:客户端1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端2 的锁
导致这两个问题的原因是什么?我们一个个来看。
第一个问题,可能是我们评估操作共享资源的时间不准确导致的。
例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。
过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?
这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。
为什么?
原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。
既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
有什么更好的解决方案吗?
别急,关于这个问题,我会在后面详细来讲对应的解决方案。
我们继续来看第二个问题。
第二个问题在于,一个客户端释放了其它客户端持有的锁。
想一下,导致这个问题的关键点在哪?
重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!
如何解决这个问题呢?
锁被别人释放怎么办? 解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。
例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:
// 锁的VALUE设置为UUID 127.0.0.1:6379> SET lock $uuid EX 20 NX OK

这里假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。
之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
// 锁是自己的,才释放 if redis.get("lock") == $uuid: redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。
  1. 客户端 1 执行 GET,判断锁是自己的
  2. 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁
由此可见,这两个命令还是必须要原子执行才行。
怎样原子执行呢?Lua 脚本。
我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。
因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
Redis|Redis实现分布式锁
文章图片

安全释放锁的 Lua 脚本如下:
// 判断锁是自己的,才释放 if redis.call("GET",KEYS[1]) == ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end

好了,这样一路优化,整个的加锁、解锁的流程就更「严谨」了。
这里我们先小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:
  1. 加锁:SET lock_key $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
Redis|Redis实现分布式锁
文章图片

好,有了这个完整的锁模型,让我们重新回到前面提到的第一个问题。
锁过期时间不好评估怎么办?
锁过期时间不好评估怎么办? 前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。
当时给的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。
这个方案其实也不能完美解决问题,那怎么办呢?
是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
这确实一种比较好的方案。
如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
Redis|Redis实现分布式锁
文章图片

除此之外,这个 SDK 还封装了很多易用的功能:
  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock(红锁)
这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。如果你是 Java 技术栈,可以直接把它用起来。
到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:
  • 死锁:设置过期时间
  • 过期时间评估不好,锁提前过期:守护线程,自动续期
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放

基于Redission实现
//Redis配置 Config config = new Config(); config.useClusterServers() .setScanInterval(2000) // cluster state scan interval in milliseconds .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001") .addNodeAddress("redis://127.0.0.1:7002"); RedissonClient redisson = Redisson.create(config); RLock lock = redisson.getLock("anyLock"); //加锁 lock.lock(); try { //尝试加锁,最多等待2秒,上锁以后8秒自动解锁 boolean res = lock.tryLock(2, 8, TimeUnit.SECONDS); if(res){ //成功 //处理业务 ... } } finally { lock.unlock(); }


Redis队列解决秒杀超卖问题 核心就是lpush和rpop命令,先进先出,redis是单线程的,如果pop取不到数据,就说明卖完了。
@Component public class RedisClient { @Autowired private RedisTemplate redisTemplate; /** * 存值 * @param key 键 * @param value 值 * @return */ public boolean lpush(String key, Object value) { try { redisTemplate.opsForList().leftPush(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 取值 - * @param key 键 * @return */ public Object rpop(String key) { try { return redisTemplate.opsForList().rightPop(key); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 取值 - - 推荐使用 * @param key 键 * @param timeout 超时时间 * @param timeUnit 给定单元粒度的时间段 *TimeUnit.DAYS//天 *TimeUnit.HOURS//小时 *TimeUnit.MINUTES//分钟 *TimeUnit.SECONDS//秒 *TimeUnit.MILLISECONDS//毫秒 * @return */ public Object brpop(String key, long timeout, TimeUnit timeUnit) { try { return redisTemplate.opsForList().rightPop(key, timeout, TimeUnit.SECONDS); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 查看值 * @param key 键 * @param start 开始 * @param end 结束 0 到 -1代表所有值 * @return */ public List lrange(String key, long start, long end) { try { return redisTemplate.opsForList().range(key, start, end); } catch (Exception e) { e.printStackTrace(); return null; } } }

java中调用LUA脚本

org.luaj
luaj-jse
3.0.1

测试:
test包下面新建了一个LuaTest.java和resources包下面新建一个my.lua
Redis|Redis实现分布式锁
文章图片

my.lua:
mytab = {"app","ora" }for i = 1,#mytab,1 do print(mytab[i]) end

java调用测试:
@Test public void luaTest(){ //直接调用lua脚本 String luaStr = "print 'hello,world!'"; Globals globals = JsePlatform.standardGlobals(); LuaValue chunk = globals.load(luaStr); chunk.call(); //调用lua脚本文件 LuaValue loadfile = globals.loadfile("my.lua"); loadfile.call(); }

Redis|Redis实现分布式锁
文章图片

注: Globals继承LuaValue对象,LuaValue对象用来表示在Lua语言的基本数据类型,比如:Nil,Number,String,Table,userdata,Function等。尤其要注意LuaValue也表示了Lua语言中的函数。所以,对于Lua语言中的函数操作都是通过LuaValue来实现的.
线程安全:
Luaj中的Globals对象不是线程安全的, 因此最佳实践是每个线程一个Globals对象.
事实上, 可以采用ThreadLocal的方式来存储该对象.



    推荐阅读