基于Redis实现分布式锁

背景


在不少互联网产品应用中,有些场景须要加锁处理,好比:秒杀,全局递增ID,楼层生成等等。大部分的解决方案是基于DB实现的,Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的链接并不存在竞争关系。其次Redis提供一些命令SETNX,GETSET,能够方便实现分布式锁机制。php

Redis命令介绍


使用Redis实现分布式锁,有两个重要函数须要介绍数据库

SETNX命令(SET if Not eXists)
语法:
SETNX key value
功能:
当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不作任何动做,并返回0。安全

GETSET命令
语法:
GETSET key value
功能:
将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。服务器

GET命令
语法:
GET key
功能:
返回 key 所关联的字符串值,若是 key 不存在那么返回特殊值 nil 。网络

DEL命令
语法:
DEL key [KEY …]
功能:
删除给定的一个或多个 key ,不存在的 key 会被忽略。并发

兵贵精,不在多。分布式锁,咱们就依靠这四个命令。但在具体实现,还有不少细节,须要仔细斟酌,由于在分布式并发多进程中,任何一点出现差错,都会致使死锁,hold住全部进程。分布式

加锁实现

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 向foo.lock发送GESET命令,
GETSET foo.lock <current unix time>
并获得foo.lock中老的时间戳T2

若是T1=T2,说明C4得到时间戳。
若是T1!=T2,说明C4以前有另一个客户端C5经过调用GETSET方式获取了时间戳,C4未得到锁。只能sleep下,进入下次循环中。

如今惟一的问题是,C4设置foo.lock的新时间戳,是否会对锁产生影响。其实咱们能够看到C4和C5执行的时间差值极小,而且写入foo.lock中的都是有效时间错,因此对锁并无影响。
为了让这个锁更增强壮,获取锁的客户端,应该在调用关键业务时,再次调用GET方法获取T1,和写入的T0时间戳进行对比,以避免锁因其余状况被执行DEL意外解开而不知。以上步骤和状况,很容易从其余参考资料中看到。客户端处理和失败的状况很是复杂,不只仅是崩溃这么简单,还多是客户端由于某些操做被阻塞了至关长时间,紧接着 DEL 命令被尝试执行(但这时锁却在另外的客户端手上)。也可能由于处理不当,致使死锁。还有可能由于sleep设置不合理,致使Redis在大并发下被压垮。最为常见的问题还有

GET返回nil时应该走那种逻辑?

第一种走超时逻辑
C1客户端获取锁,而且处理完后,DEL掉锁,在DEL锁以前。C2经过SETNX向foo.lock设置时间戳T0 发现有客户端获取锁,进入GET操做。
C2 向foo.lock发送GET命令,获取返回值T1(nil)。
C2 经过T0>T1+expire对比,进入GETSET流程。
C2 调用GETSET向foo.lock发送T0时间戳,返回foo.lock的原值T2
C2 若是T2=T1相等,得到锁,若是T2!=T1,未得到锁。

第二种状况走循环走setnx逻辑
C1客户端获取锁,而且处理完后,DEL掉锁,在DEL锁以前。C2经过SETNX向foo.lock设置时间戳T0 发现有客户端获取锁,进入GET操做。
C2 向foo.lock发送GET命令,获取返回值T1(nil)。
C2 循环,进入下一次SETNX逻辑

两种逻辑貌似都是OK,可是从逻辑处理上来讲,第一种状况存在问题。当GET返回nil表示,锁是被删除的,而不是超时,应该走SETNX逻辑加锁。走第一种状况的问题是,正常的加锁逻辑应该走SETNX,而如今当锁被解除后,走的是GETST,若是判断条件不当,就会引发死锁,很悲催,我在作的时候就碰到了,具体怎么碰到的看下面的问题

GETSET返回nil时应该怎么处理?

C1和C2客户端调用GET接口,C1返回T1,此时C3网络状况更好,快速进入获取锁,并执行DEL删除锁,C2返回T2(nil),C1和C2都进入超时处理逻辑。
C1 向foo.lock发送GETSET命令,获取返回值T11(nil)。
C1 比对C1和C11发现二者不一样,处理逻辑认为未获取锁。
C2 向foo.lock发送GETSET命令,获取返回值T22(C1写入的时间戳)。
C2 比对C2和C22发现二者不一样,处理逻辑认为未获取锁。

此时C1和C2都认为未获取锁,其实C1是已经获取锁了,可是他的处理逻辑没有考虑GETSET返回nil的状况,只是单纯的用GET和GETSET值就行对比,至于为何会出现这种状况?一种是多客户端时,每一个客户端链接Redis的后,发出的命令并非连续的,致使从单客户端看到的好像连续的命令,到Redis server后,这两条命令之间可能已经插入大量的其余客户端发出的命令,好比DEL,SETNX等。第二种状况,多客户端之间时间不一样步,或者不是严格意义的同步。

时间戳的问题

咱们看到foo.lock的value值为时间戳,因此要在多客户端状况下,保证锁有效,必定要同步各服务器的时间,若是各服务器间,时间有差别。时间不一致的客户端,在判断锁超时,就会出现误差,从而产生竞争条件。
锁的超时与否,严格依赖时间戳,时间戳自己也是有精度限制,假如咱们的时间精度为秒,从加锁到执行操做再到解锁,通常操做确定都能在一秒内完成。这样的话,咱们上面的CASE,就很容易出现。因此,最好把时间精度提高到毫秒级。这样的话,能够保证毫秒级别的锁是安全的。

分布式锁的问题

1:必要的超时机制:获取锁的客户端一旦崩溃,必定要有过时机制,不然其余客户端都降没法获取锁,形成死锁问题。
2:分布式锁,多客户端的时间戳不能保证严格意义的一致性,因此在某些特定因素下,有可能存在锁串的状况。要适度的机制,能够承受小几率的事件产生。
3:只对关键处理节点加锁,良好的习惯是,把相关的资源准备好,好比链接数据库后,调用加锁机制获取锁,直接进行操做,而后释放,尽可能减小持有锁的时间。
4:在持有锁期间要不要CHECK锁,若是须要严格依赖锁的状态,最好在关键步骤中作锁的CHECK检查机制,可是根据咱们的测试发现,在大并发时,每一次CHECK锁操做,都要消耗掉几个毫秒,而咱们的整个持锁处理逻辑才不到10毫秒,玩客没有选择作锁的检查。
5:sleep学问,为了减小对Redis的压力,获取锁尝试时,循环之间必定要作sleep操做。可是sleep时间是多少是门学问。须要根据本身的Redis的QPS,加上持锁处理时间等进行合理计算。
6:至于为何不使用Redis的muti,expire,watch等机制,能够查一参考资料,找下缘由。

锁测试数据

未使用sleep


第一种,锁重试时未作sleep。单次请求,加锁,执行,解锁时间 


能够看到加锁和解锁时间都很快,当咱们使用

ab -n1000 -c100 'http://sandbox6.wanke.etao.com/test/test_sequence.php?tbpm=t'
AB 并发100累计1000次请求,对这个方法进行压测时。 


咱们会发现,获取锁的时间变成,同时持有锁后,执行时间也变成,而delete锁的时间,将近10ms时间,为何会这样?
1:持有锁后,咱们的执行逻辑中包含了再次调用Redis操做,在大并发状况下,Redis执行明显变慢。
2:锁的删除时间变长,从以前的0.2ms,变成9.8ms,性能降低近50倍。
在这种状况下,咱们压测的QPS为49,最终发现QPS和压测总量有关,当咱们并发100总共100次请求时,QPS获得110多。当咱们使用sleep时

使用Sleep时

单次执行请求时


咱们看到,和不使用sleep机制时,性能至关。当时用相同的压测条件进行压缩时 


获取锁的时间明显变长,而锁的释放时间明显变短,仅是不采用sleep机制的一半。固然执行时间变成就是由于,咱们在执行过程当中,从新建立数据库链接,致使时间变长的。同时咱们能够对比下Redis的命令执行压力状况 

上图中细高部分是为未采用sleep机制的时的压测图,矮胖部分为采用sleep机制的压测图,通上图看到压力减小50%左右,固然,sleep这种方式还有个缺点QPS降低明显,在咱们的压测条件下,仅为35,而且有部分请求出现超时状况。不过综合各类状况后,咱们仍是决定采用sleep机制,主要是为了防止在大并发状况下把Redis压垮,很不行,咱们以前碰到过,因此确定会采用sleep机制。

相关文章
相关标签/搜索