posts - 496,comments - 227,trackbacks - 0
http://chaopeng.me/blog/2014/01/26/redis-lock.html

http://blog.csdn.net/ugg/article/details/41894947

http://www.jeffkit.info/2011/07/1000/

redis有一系列的命令,特点是以nx结尾,nx是not exists的缩写,如setnx命令就应该理解为:set if not exists。这系列的命令非常有用,这里讲使用setnx来实现分布式锁。

用setnx实现分布式锁

利用setnx非常简单地实现分布式锁。例如:某客户端要获得一个名字foo的锁,客户端使用下面的命令进行获取:

setnx lock.foo

  •  如返回1,则该客户端获得锁,把lock.foo的键值设置为时间值表示该键已被锁定,该客户端最后可以通过del lock.foo来释放该锁。
  •  如返回0,表明该锁已被其他客户端取得,这时我们可以先返回或进行重试等对方完成或等待锁超时。

解决死锁

上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或崩溃了不能释放锁,该怎么解决?我们可以通过锁的键对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于lock.foo的值,说明该锁已失效,可以被重新使用。

发生这种情况时,可不能简单的通过del来删除锁,然后再setnx一次,当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞态条件,让我们模拟一下这个场景:

  1.  c0操作超时了,但它还持有着锁,c1和c2读取lock.foo检查时间戳,先后发现超时了。
  2.  c1 发送del lock.foo
  3.  c1 发送setnx lock.foo 并且成功了。
  4.  c2 发送del lock.foo
  5.  c2 发送setnx lock.foo 并且成功了。

这样一来,c1,c2都拿到了锁!问题大了!

幸好这种问题是可以避免d,让我们来看看c3这个客户端是怎样做的:

  1. c3发送setnx lock.foo 想要获得锁,由于c0还持有锁,所以redis返回给c3一个0
  2. c3发送get lock.foo 以检查锁是否超时了,如果没超时,则等待或重试。
  3. 反之,如果已超时,c3通过下面的操作来尝试获得锁:
    getset lock.foo
  4. 通过getset,c3拿到的时间戳如果仍然是超时的,那就说明,c3如愿以偿拿到锁了。
  5. 如果在c3之前,有个叫c4的客户端比c3快一步执行了上面的操作,那么c3拿到的时间戳是个未超时的值,这时,c3没有如期获得锁,需要再次等待或重试。留意一下,尽管c3没拿到锁,但它改写了c4设置的锁的超时值,不过这一点非常微小的误差带来的影响可以忽略不计。

注意:为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做del操作,因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。

示例伪代码

根据上面的代码,我写了一小段fake代码来描述使用分布式锁的全过程:

  1. # get lock
  2. lock = 0
  3. while lock != 1:
  4.     timestamp = current unix time lock timeout 1
  5.     lock = setnx lock.foo timestamp
  6.     if lock == 1 or (now() > (get lock.foo) and now() > (getset lock.foo timestamp)):
  7.         break;
  8.     else:
  9.         sleep(10ms)
  10.  
  11. # do your job
  12. do_job()
  13.  
  14. # release
  15. if now() < get lock.foo:
  16.     del lock.foo

是的,要想这段逻辑可以重用,使用python的你马上就想到了decorator,而用java的你是不是也想到了那谁?aop annotation?行,怎样舒服怎样用吧,别重复代码就行。



背景
在 很多互联网产品应用中,有些场景需要加锁处理,比如:秒杀,全局递增id,楼层生成等等。大部分的凯发天生赢家一触即发官网的解决方案是基于db实现的,redis为单进程单线程模 式,采用队列模式将并发访问变成串行访问,且多客户端对redis的连接并不存在竞争关系。其次redis提供一些命令setnx,getset,可以方 便实现分布式锁机制。

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

如果返回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
并得到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机制。

参考资料



http://www.blogjava.net/caojianhua/archive/2013/01/28/394847.html


引子

redis是一个很强大的数据结构存储的nosql数据库,很方便针对业务模型进行效率的优化。最近我的工作是负责对现有java服务器框架进行整理,并将网络层与逻辑层脱离,以便于逻辑层和网络层的横向扩展。 尽管我在逻辑层上使用了akka作为核心框架,尽可能lockfree,但是还是免不了需要跨jvm的锁。所以我需要实现一个分布式锁。

官方的实现

官方在 这一页给了一个实现。

  • c4 sends setnx lock.foo in order to acquire the lock
  • the crashed client c3 still holds it, so redis will reply with 0 to c4.
  • c4 sends get lock.foo to check if the lock expired. if it is not, it will sleep for some time and retry from the start.
  • instead, if the lock is expired because the unix time at lock.foo is older than the current unix time, c4 tries to perform: getset lock.foo (current unix timestamp lock timeout 1)
  • because of the getset semantic, c4 can check if the old value stored at key is still an expired timestamp. if it is, the lock was acquired.
  • if another client, for instance c5, was faster than c4 and acquired the lock with the getset operation, the c4 getset operation will return a non expired timestamp. c4 will simply restart from the first step. note that even if c4 set the key a bit a few seconds in the future this is not a problem.

但是使用官方推荐的getset实现的话,未竞争到锁的一方确实可以判断到自己未能竞争到锁,但却将持有锁一方的时间修改了,这样的直接后果就是,持有锁的一方无法解锁!!!

基于lua的实现

其实官方实现出现的问题,是因为使用redis独立的命令不能将get-check-set这个过程进行原子化,所以我决定引入redis-lua,将get-check-set这个过程使用lua脚本来实现。

加锁:

  • script params: lock_key, current_timestamp, lock_timeout
  • setnx lock_key (current_timestamp lock_timeout). if not success, set lock_key (current_timestamp lock_timeout) if current_timestamp > value
  • client save current_timestamp(lock_create_timestamp)

解锁:

  • script params: lock_key, lock_create_timestamp, lock_timeout
  • delete if lock_create_timestamp lock_timeout == value

具体的实现:

lua
  1. ---lock

  2. local now = tonumber(argv[1])
  3. local timeout = tonumber(argv[2])
  4. local to = now timeout
  5. local locked = redis.call('setnx', keys[1], to)
  6. if (locked == 1) then
  7. return 0
  8. end
  9. local kt = redis.call('type', keys[1]);
  10. if (kt['ok'] ~= 'string') then
  11. return 2
  12. end
  13. local keyvalue = tonumber(redis.call('get', keys[1]))
  14. if (now > keyvalue) then
  15. redis.call('set', keys[1], to)
  16. return 0
  17. end
  18. return 1

  19. ---unlock

  20. local begin = tonumber(argv[1])
  21. local timeout = tonumber(argv[2])
  22. local kt = redis.call('type', keys[1]);
  23. if (kt['ok'] == 'string') then
  24. local keyvalue = tonumber(redis.call('get', keys[1]))
  25. if ((keyvalue - begin) == timeout) then
  26. redis.call('del', keys[1])
  27. return 0
  28. end
  29. end
  30. return 1

已知问题

redis的分布式锁会有单点的问题。当然我们的业务量也没有达到挂掉专门做锁的redis单点的水平。

posted on 2016-05-12 17:52 simone 阅读(793) 评论(0)  编辑  收藏 所属分类: java
"));
网站地图