在秒杀、全局递增ID等场景需要加锁处理,大部分的解决方案是基于DB实现的,Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。 其次Redis提供一些命令SETNX,GETSET,可以方便实现分布式锁机制。
SETNX命令(SET if Not Exists)
语法
1
SETNX key value
作用
当且仅当 key 不存在,将 key 的值设为 value ,并返回1; 若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
GETSET命令
语法
1
GETSET key value
作用
将给定 key 的值设为 value ,并返回 key 的旧值, 当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
GET命令
语法
1
GET key
作用
返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。
DEL命令
语法
1
DEL key [KEY …]
作用
删除给定的一个或多个 key ,不存在的 key 会被忽略。
加锁
SETNX 可以直接加锁操作,比如说对某个关键词foo加锁,客户端可以尝试 SETNX foo.lock <current unix time>。
- 如果返回1,表示客户端已经获取锁,可以往下操作,操作完成后,通过
DEL foo.lock命令来释放锁。 - 如果返回0,说明foo已经被其他客户端上锁,如果锁是非堵塞的,可以选择返回调用。如果是堵塞调用,就需要进入下一个重试循环,直至成功获得锁或者重试超时。
仅仅使用SETNX加锁带有竞争条件的,在某些特定的情况会造成死锁错误。
处理死锁
在上面的处理方式中,如果获取锁的客户端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时效性检测。
因此,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去。
但是,在大并发情况,如果同时检测锁失效,并简单粗暴的删除死锁,再通过SETNX上锁,可能会导致竞争条件的产生,即多个客户端同时获取锁。
情景描述
- C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。
- C2 向foo.lock发送DEL命令。
- C2 向foo.lock发送SETNX获取锁。
- C3 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。
- C3 向foo.lock发送SETNX获取锁。
此时C2和C3都获取了锁,产生竞争条件,如果在更高并发的情况,可能会有更多客户端获取锁。
所以,DEL锁的操作,不能直接使用在锁超时的情况下,幸好我们有GETSET方法,假设我们现在有另外一个客户端C4,看看如何使用GETSET方式,避免这种情况产生。
- C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,调用GET命令获得foo.lock的时间戳T1,通过比对时间戳,发现锁超时。
- C4(调用
SETNX上锁返回0后,调用GET命令获得foo.lock的时间戳T1,通过比对时间戳,发现锁超时)向foo.lock发送GESET命令,GETSET foo.lock并得到foo.lock中老的时间戳T2。- 如果T1 = T2,说明C4获得锁。
- 如果T1 != T2,说明C4之前有另外一个客户端C5通过调用
GETSET方式获取并更改了时间戳,C4未获得锁。只能进入下次循环中。
时间戳问题
我们看到foo.lock的value值为时间戳,所以要在多客户端情况下,保证锁有效,一定要同步各服务器的时间。如果各服务器间,时间有差异,时间不一致的客户端,在判断锁超时,就会出现偏差,从而产生竞争条件。锁的超时与否,严格依赖时间戳。
锁覆盖问题
C4设置foo.lock的新时间戳,是否会对C5获取得锁产生影响?
我们可以看到C4和C5只有在调用GET命令获得foo.lock的时间戳,通过比对时间戳,发现锁超时后,几乎同时调用GETSET方式获取锁,执行的时间差值极小,并且写入foo.lock中的都是有效时间戳,所以对锁并没有影响。
为了让这个锁更加强壮,获取锁的客户端,应该在调用关键业务时,再次调用GET方法获取T1,和写入的T0时间戳进行对比,以免锁因其他情况被执行DEL意外解开而不知道。但是如果遇到上面描述得问题,则T0则会与T1不一致,当然差别一般会很小。这就是锁覆盖问题。
锁覆盖会导致什么问题呢?
当客户端的锁过期时间被覆盖,会造成锁不具有标识性,会造成客户端无法释放锁(客户端只能释放明确自己持有的锁)。
下图为Redis分布式锁的流程图: