Lua脚本在redis分布式锁场景的运用
锁和分布式锁
锁是什么?
锁是一种可以封锁资源的东西。这种资源通常是共享的,通常会发生使用竞争的。
为什么需要锁?
需要保护共享资源正常使用,不出乱子。
比方说,公司只有一间厕所,这是个共享资源,大家需要共同使用这个厕所,所以避免不了有时候会发生竞争。如果一个人正在使用,另外一个人进去了,咋办呢?如果两个人同时钻进了一个厕所,那该怎么办?结果如何?谁先用,还是一起使用?特别的,假如是一男一女同时钻进了厕所,事情会怎样呢?反正我是不懂……
如果这个时候厕所门前有个锁,每个人都没法随便进入,而是需要先得到锁,才能进去。而得到这个锁,就需要里边的人先出来。这样就可以保证同一时刻,只有一个人在使用厕所,这个人在上厕所的期间不会有不安全的事情发生,不会中途被人闯进来了。
Java中的锁
在 java 编码的时候,为了保护共享资源,使得多线程环境下,不会出现“不好的结果”。我们可以使用锁来进行线程同步。于是我们可以根据具体的情况使用synchronized 关键字来修饰一个方法,或者一段代码。这个方法或者代码就像是前文中提到的“受保护的厕所,加锁的厕所”。也可以使用 java 5以后的 Lock 来实现,与 synchronized 关键字相比,Lock 的使用更灵活,可以有加锁超时时间、公平性等优势。
分布式锁
上面我们所说的 synchronized 关键字也好,Lock 也好。其实他们的作用范围是啥,就是当前的应用啊。你的代码在这个 jar 包或者这个 war 包里边,被部署在 A 机器上。那么实际上我们写的 synchronized 关键字,就是在当前的机器的 JVM在执行代码的时候发生作用的。假设这个代码被部署到了三台机器上 A,B,C。那么 A 机器中的部署的代码中的synchronized 关键字并不能控制 B,C 中的内容。
假如我们需要在 A,B,C 三台机器上运行某段程序的时候,实现“原子操作”,synchronized 关键字或者 Lock 是不能满足的。很显然,这个时候我们需要的锁,是需要协同这三个节点的,于是,分布式锁就需要上场了,他就像是在A,B,C的外面加了一个层,通过它来实现锁的控制。
redis 如何实现加锁
在redis中,有一条命令,可以实现类似 “锁” 的语法是这样的:
SETNX key value
他的作用是,将 key
的值设为 value
,当且仅当 key
不存在。若给定的 key
已经存在,则 SETNX 不做任何动作。设置成功,返回 1
;设置失败,返回 0
。
使用 redis 来实现锁的逻辑就是这样的
线程 1 获取锁 -- > setnx mylock lockvalue -- > 1 获取锁成功 线程 2 获取锁 -- > setnx mylock lockvalue -- > 0 获取锁失败 (继续等待,或者其他逻辑) 线程 1 释放锁 -- > 线程 2 获取锁 -- > setnx mylock lockvalue -- > 1 获取成功
锁超时
在这个例子中,我们梳理了使用 redis setnx 命令 来实现锁的逻辑。这里还需要考虑的是,锁超时的问题 ,因为当线程 1 获取了锁之后,如果业务逻辑执行很长很长时间,那么其他线程只能死等,这可不行。所以需要加上超时,结合这些考虑的情况,实际的 Java 代码可以这样写:
public static boolean lock(String key,String lockValue,int expire){ if(null == key){ return false; } try { Jedis jedis = getJedisPool().getResource(); String res = jedis.set(key,lockValue,"NX","EX",expire); jedis.close(); return res!=null && res.equals("OK"); } catch (Exception e) { return false; } }
retry
这里执行加锁,不一定能成功。当别人正在持有锁的时候,加锁的线程需要继续尝试。这个“继续尝试”通常是“忙等待”,实现代码如下:
/** * 获取一个分布式锁 , 超时则返回失败 * @param key 锁的key * @param lockValue 锁的value * @param timeout 获取锁的等待时间,单位为 秒 * @return 获锁成功 - true | 获锁失败 - false */ public static boolean tryLock(String key,String lockValue,int timeout,int expire){ final long start = System.currentTimeMillis(); if(timeout > expiredNx) { timeout = expiredNx; } final long end = start + timeout * 1000; boolean res = false; // 默认返回失败 while(!(res = lock(key,lockValue,expire))){ // 调用了上面的 lock方法 if(System.currentTimeMillis() > end) { break; } } return res; }
redis 如何释放锁
根据上面所述,我们在加锁的时候执行了:setnx mylock lockvalue
, 这种加锁的本质其实就是 “占座位”,我把一本书放在自习室第一排的第一个座位上,别人就不能坐了,就得等着我走了,把东西拿走了,他就可以使用这个座位了。所以很容易想到,在我们需要释放锁的时候,只需要调用 del mylock
就行了,这样别的线程想去执行加锁的时候执行就可以执行 setnx mylock lockvalue
了。
不该释放的锁
但是,直接执行del mylock
是有问题的,我们不能直接执行 del mylock
为什么?—— 会导致 “信号错误”,释放了不该释放的锁 。假设如下场景:
时间线 | 线程1 | 线程2 | 线程3 |
---|---|---|---|
时刻1 | 执行 setnx mylock val1 加锁 | 执行 setnx mylock val2 加锁 | 执行 setnx mylock val2 加锁 |
时刻2 | 加锁成功 | 加锁失败 | 加锁失败 |
时刻3 | 执行任务... | 尝试加锁... | 尝试加锁... |
时刻4 | 任务继续(锁超时,自动释放了) | setnx 获得了锁(因为线程1的锁超时释放了) | 仍然尝试加锁... |
时刻5 | 任务完毕,del mylock 释放锁 | 执行任务中... | 获得了锁(因为线程1释放了线程2的) |
... | |||
上面的表格中,有两个维度,一个是纵向的时间线,一个是横线的线程并发竞争。我们可以发现线程 1 在开始的时候比较幸运,获得了锁,最先开始执行任务,但是,由于他比较耗时,最后锁超时自动释放了他都还没执行完。 因此,线程 2 和线程3 的机会来了。而这一轮,线程2 比较幸运,得到了锁。可是,当线程2正在执行任务期间,线程1 执行完了,还把线程2的锁给释放了。这就相当于,本来你锁着门在厕所里边尿尿,进行到一半的时候,别人进来了,因为他配了一把和你一模一样的钥匙!这就乱套了啊 |
因此,我们需要安全的释放锁——“不是我的锁,我不能瞎释放”。所以,我们在加锁的时候,就需要标记“这是我的锁”,在释放的时候在判断 “ 这是不是我的锁?”。这里就需要在释放锁的时候加上逻辑判断,合理的逻辑应该是这样的:
1. 线程1 准备释放锁 , 锁的key 为 mylock 锁的 value 为 thread1_magic_num 2. 查询当前锁 current_value = get mylock 3. 判断 if current_value == thread1_magic_num -- > 是 我(线程1)的锁 else -- >不是 我(线程1)的锁 4. 是我的锁就释放,否则不能释放(而是执行自己的其他逻辑)。
为了实现上面这个逻辑,我们是无法通过 redis 自带的命令直接完成的。如果,再写复杂的代码去控制释放锁,则会让整体代码太过于复杂了。所以,我们引入了lua脚本。结合Lua 脚本实现释放锁的功能,更简单,redis 执行lua脚本也是原子的,所以更合适,让合适的人干合适的事,岂不更好。
通过Lua脚本实现锁释放
Lua是啥,Lua是一种功能强大,高效,轻量级,可嵌入的脚本语言。其官方的描述是:
Lua is a powerful, efficient, lightweight, embeddable scripting language. It supports procedural programming, object-oriented programming, functional programming, data-driven programming, and data description.
Lua 调用 redis 非常简单,并且 Lua 脚本语法也易学,对于有别的编程语言基础的程序员来说,在不学习Lua脚本语法的情况下,直接看 Lua 的代码 也是可以看懂的。例子如下:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
上面的代码,逻辑很简单,if 中的比较如果是true , 那么 执行 del 并返回del结果;如果 if 结果为false 直接返回 0 。这不就满足了我们释放锁的要求吗?——“ 是我的锁,我就释放,不是我的锁,我不能瞎释放”。
其中的KEYS[1] , ARGV[1] 是参数,我们只调用 jedis 执行脚本的时候,传递这两个参数就可以了。
使用redis + lua 来实现释放锁的代码如下:
private static final Long lockReleaseOK = 1L; static String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";// lua脚本,用来释放分布式锁 public static boolean releaseLock(String key ,String lockValue){ if(key == null || lockValue == null) { return false; } try { Jedis jedis = getJedisPool().getResource(); Object res =jedis.eval(luaScript,Collections.singletonList(key),Collections.singletonList(lockValue)); jedis.close(); return res!=null && res.equals(lockReleaseOK); } catch (Exception e) { return false; } }
如此,我们便实现了锁的安全释放。同时,我们还需要结合业务逻辑,进行具体健壮性的保证,比如如果结束了一定不能忘记释放锁,异常了也要释放锁,某种情况下是否需要回滚事务等。总结这个分布式锁使用的过程便是:
- 加锁时 key 同,value 不同。
- 释放锁时,根据value判断,是不是我的锁,不能释放别人的锁。
- 及时释放锁,而不是利用自动超时。
- 锁超时时间一定要结合业务情况权衡,过长,过短都不行。
- 程序异常之处,要捕获,并释放锁。如果需要回滚的,主动做回滚、补偿。保证整体的健壮性,一致性。
用redis做分布式锁真的靠谱吗
上面的文字中,我们讨论如何使用redis作为分布式锁,并讨论了一些细节问题,如锁超时的问题、安全释放锁的问题。目前为止,似乎很完美的解决的我们想要的分布式锁功能。然而事情并没有这么简单,用redis做分布式锁并不“靠谱”。
不靠谱的情况
上面我们说的是redis,是单点的情况。如果是在redis sentinel集群中情况就有所不同了。关于redis sentinel 集群可以看这里。在redis sentinel集群中,我们具有多台redis,他们之间有着主从的关系,例如一主二从。我们的set命令对应的数据写到主库,然后同步到从库。当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue
,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue
, 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的,虽然这个情况发生的记录很小,只会在主从failover的时候才会发生,大多数情况下、大多数系统都可以容忍,但是不是所有的系统都能容忍这种瑕疵。
redlock
为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法,使用redlock算法,需要多个redis实例,加锁的时候,它会想多半节点发送 setex mykey myvalue
命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于【大多数都同意】的一种机制。感兴趣的可以查询相关资料。在实际工作中使用的时候,我们可以选择已有的开源实现,python有redlock-py,java 中有Redisson redlock。
redlock确实解决了上面所说的“不靠谱的情况”。但是,它解决问题的同时,也带来了代价。你需要多个redis实例,你需要引入新的库 代码也得调整,性能上也会有损。所以,果然是不存在“完美的解决方案”,我们更需要的是能够根据实际的情况和条件把问题解决了就好。
至此,我大致讲清楚了redis分布式锁方面的问题(日后如果有新的领悟就继续更新)。