使用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)