使用Redis SETNX 命令实现分布式锁
使用Redis的 SETNX 命令可以实现分布式锁,本文介绍其实现方法。
目录
直接进入正题,现在分布式的应用场景很多,为了保持数据的一致性,经常碰到需要对资源加锁的情形。 利用redis来实现分布式锁就是其中的一种实现方案。
SETNX命令简介
命令格式
SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值
设置成功,返回 1 。 设置失败,返回 0 。
示例
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
SETNX分布式锁实现方案
利用SETNX的特性,很容易的想到,在需要加锁的时候,调用SETNX命令,如果返回了1,表示设置成功,获得了当前锁,之后做一些想要的操作,完成之后调用DEL命令释放锁。
redis> SETNX lock true # 获得锁成功
(integer) 1
... do thing ...
redis> DEL lock # 释放锁
(integer) 1
但是这样存在一个问题,如果在执行DEL命令之前,当前程序发生错误,那么这个锁就永远得不到释放,其他程序也永远无法加锁成功。
于是我们可以在加锁之后为这个锁设置一个过期时间,过期时间之后,如果没有释放,就自动删除,防止锁被一直占用。
redis> SETNX lock true # 获得锁成功
(integer) 1
redis> EXPIRE lock 5 # 设置5秒的过期时间
(integer) 1
... do thing ...
redis> DEL lock # 释放锁
(integer) 1
但是这样还是有问题,如果在SETNX和EXPIRE之间程序又发生了错误,当前锁又无法释放。所以根本原因还是需要一个原子的操作,在获得锁的同时能够同时设置锁的过期时间。
为了解决这个问题,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行, 这个可以在下一篇介绍。 本文介绍另一种方式。
SETNX设置锁
在设置锁的时候,我们可以利用锁的值来实现过期的特性
SETNX lock <current Unix time + lock timeout>
我们不是设置一个简单的值到lock中,而是将过期的时间写入到lock中。 获得锁的判断条件仍旧是跟之前一样, 如果返回了1的话,表示获得了锁,可以进行下一步的操作。
判断过期条件
正常情况下,操作完成之后,仍旧执行DEL操作将当前锁释放。那么如果当前程序发生了错误退出了,当前锁没有正常释放,其他的进程如何获得锁呢。
假设上一个进程加锁之后异常退出,没有释放锁。当前的进程想要加锁,在调用SETNX的时候发现加锁失败,然后需要调用GET命令获得当前锁的值,即上一个进程写入的过期时间。 如果获得的过期时间未到,那么当前进程继续等待; 如果锁的过期时间已经到了,很大的概率上一个获得锁的进程已经发生了错误,因为我们这个过期时间一般会设置的比正常的运行时间要长。在这种情况下, 当前进程可以重新写入这个锁并进行后续的操作。
解决竞争条件
但是这样又带来一个新的问题: 假设有P1和P2两个进程同时想获得锁,他们都检测到了当前的锁已经过期了, 他们可以写入,他们调用SET命令写入都会成功,那么如果决定到底是哪个进程获得了锁呢。
所以在这边重新写入的时候不能简单的调用SET命令, 还有另一个命令可以考虑: GETSET。GETSET命令在设置值的同时,会将设置之前的值返回。
仍旧考虑刚才的情形, P1和P2同时在竞争锁,发现锁的时间T已经过期了,然后他们同时调用GETSET命令设置新的锁。假设P1先设置成功时间T1,那么调用GETSET得到的值就是T; P2调用GETSET虽然将锁的时间设置成了T2,但是他得到的值是T1。
通过判断GETSET返回的值,就能判断自己是否获得了锁。如果返回的值仍然是一个过期的时间,那么说明正确的加锁了;否则的话,说明正好有别的进程已经设置了锁,当前进程只是更新了一下锁而已,就继续等待。
可能会说这边有一个小问题,P1设置的锁的过期时间被P2更改了。考虑到产生这种竞态条件的时候肯定时间间隔是非常小的, 即使重新设置了过期时间,这种很短的时间修改在大多数情况下都可以忽略不计。
伪代码
所以,我们能够得到最终的一个过程,用伪代码表示
while 1:
lock = redis.SETNX(key, time.now() + timeout)
if lock == 1:
// 获得锁
break
lock_ts = redis.GET(key)
if (lock_ts < time.now()) && (redis.GETSET(key, time.now() + timeout) < time.now()):
// 锁已经过期,用GETSET重新写锁
// 返回的原来的时间仍旧过期,说明加锁成功
break
else:
sleep
.... do something ...
// 完成之后释放锁
redis.DEL(key)