Zacard's Notes

分布式锁研究

背景

公司使用基于redis setNX的分布式锁偶现失效,对此深入研究一番

场景

大体来说,分布式锁的场景有两种:

  1. 为了效率:相当于去重,避免各个系统做重复的事情。比如重复发送一封email
  2. 为了正确性:不允许出现任何的失效,不然就可能造成数据不一致

基于单节点的redis实现

为什么强调单节点?因为我们就一个redis主从,没有redis集群。而且redis有官方的分布式锁redlock是基于redis集群的,这个对我们不适用,而且感觉有点过重。

我们一开始的方案是基于setNX(key,value,timeout)。后来发现原来这是jedis的封装,这其实是2个redis命令:setnx+expire。也就是说这不是个原子操作,很可能setnx成功,但是设置过期时间失败导致锁永远无法释放

翻看redlock的套路才知道,应该这样操作:

  1. 获取锁:set key randomValue NX PX 3000

    redis的set操作有NX(if not exist)选项和PX(过期时间)选项,可以实现原子操作

  2. 释放锁:需要使用LUA脚本实现复合操作的原子性:

    1
    2
    3
    (1) get key
    (2) 比较random value
    (3) random value一致即删除key

分析

一些疑问:

  1. 必须要设置过期时间吗?

    必须要。因为假如获取锁的程序阻塞/崩溃/与redis网络异常等情况,锁将永远不能被释放

  2. 必须要设置一个随机值吗?

    必须要。考虑以下执行序列:

    (1) 程序1获取了锁

    (2) 程序1假死导致锁超时被释放

    (3) 程序2获取了锁

    (4) 程序1从假死中恢复,直接释放了锁(没有比较随机值,相当于把程序2的锁给释放了)

    (5) 程序2的执行将得不到锁的保护

  3. 必须使用lua脚本来释放锁吗?

    必须要。因为释放锁需要3步(get->比较random value->del),需要保证3个复合操作的原子性。也不能用redis的事务消息,因为redis没有比较和if else这样命令啊…redis要是有类似cas这种操作就好了,compare and delete一步就能搞定

这样处理的redis锁已经是万无一失了吗?其实还存在2个主要问题:

  1. 过期时间设置多久合适?如果设置太短,锁就可能在程序完成对共享资源的操作之前失效,从而得不到保护;如果设置的太长,一旦某个获取锁的程序释放锁失败(比如与redis网络异常),那么就可能导致其他系统长时间无法获取锁而无法正常工作
  2. 如果程序假死(例如长时间的GC pause)将导致锁过期失效,这时候共享资源其实已经失去了保护(可能这时候有另一个程序获取了锁,而假死的程序恢复过来后同时在操作共享资源)

另外redlock还存在另一个问题就是强依赖于几个节点之间的系统时钟,一旦发生时钟跳跃,redlock很可能就失效

很多人说可以用更可靠的zookeeper来解决,那基于zookeeper的分布式锁真的万无一失吗?先来看看zookeeper分布式锁的套路

zookeeper分布式锁

具体参考官网文档:传送门

  1. 获取锁:

    (1) 客户端调用create()在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推

    (2) 客户端调用getChildren()获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得了锁,获取锁流程结束

    (3) 否则在前一个节点调用exists()并设置watch监听节点删除消息

    (4) 如果exists()返回false,重试第(2)步;否则等待节点删除通知再重试第(2)步直到获取锁

  2. 删除锁:删除创建的节点即可

这样设计有以下优点:

  • 删除节点即释放锁时,只会导致最多一个客户端被唤醒,避免了“惊群效应(herd effect)”
  • 不存在轮询或者超时
  • 临时节点,会在客户端奔溃/假死等情况自动释放锁
  • 能够直观的知道竞争锁的数量,甚至能退出锁,debug锁问题等

分析

zookeeper实现的分布式锁相对于redis确实更完善,功能也更丰富。没有redis过期时间的问题,而且能在需要的时候让锁自动释放。然而redis存在的问题中第2点,zookeeper也依然存在。假设有以下执行序列:

(1) 客户端1创建了节点/lock/lock-0000000000并且获取到了锁

(2) 客户端1进入长时间的GC pause

(3) 客户端1与zookeeper的session过期(心跳检测失败),lock-0000000000被自动删除

(4) 客户端2创建了/lock/lock-0000000001并获得了锁

(5) 客户端1从GC pause中恢复,依然认为自己持有锁

(6) 客户端1和2都认为自己持有锁,就产生了冲突

类似这种假死造成的锁失效问题,redis和zookeeper目前貌似没有完美的解决方案

这里肯定有人会说google的Chubby可以做到完美,那就来看下Chubby对这个问题的解决方案

Chubby分布式锁

Chubby分布式锁有2种实现,主要是针对之前提到问题的解决和缓解

完美实现:

获取Chubby锁的时候,锁包含了一个sequencer,里面有个单调递增的数字,要求资源服务器在对资源做修改的时候需要检查这个sequencer

Chubby提供了2种检查方式:

  • 调用Chubby提供的API,CheckSequencer(),将整个sequencer传进去进行检查。这个检查是为了保证客户端持有的锁在进行资源访问的时候仍然有效
  • 将客户端传来的sequencer与资源服务器当前观察到的最新的sequencer进行对比检查

这种实现方式类似乐观锁,有个版本号作为控制。但是要求有个“资源服务器”能在共享资源做修改的时候检查当前的sequencer。也就是说很可能需要修改“资源服务器(比如数据库)”对共享资源的操作方式。我觉得,绝大多数的“资源服务器”都不能做这个修改,这个完美方案不太普适

缓解实现

获取锁的时候,同时会设置一个lock-deploy(默认一分钟)。当Chubby服务端发现客户端被动失效后,并不是立即释放锁,而是会在lock-delay指定的时间内阻止其它客户端获得这个锁。但是正常的Release操作释放的锁可以立刻被再次获取

这种方式相当于牺牲了一定的可用性换来更普适的使用场景

分析

google Chubby的做法貌似更加优雅一点,提供了足够的方案,把选择权留给了使用者。使用者可以根据特定的场景选择特定的解决方案。

但是Chubby没有开源,非常可惜。我们只能借鉴其思想。

个人觉得zookeeper完全可以借鉴Chubby的做法,获取锁的时候同样设置lock-deloy或者使用节点序列号作为Chubby中的sequencer,对共享资源的操作可以原子的比较sequencer

总结

  • 基于场景1的分布式锁(即为了效率),使用单点的redis已经足够了,简单高效。
  • 基于场景2的分布式锁(即为了正确性),基于zookeeper已经比较适合,能够满足比redis有更好的正确性,但也无法做到绝对的正确。
  • 基于场景2的分布式锁(即为了正确性),并且需要绝对的正确,需要定制zookeeper与“资源服务器”
坚持原创技术分享,您的支持将鼓励我继续创作!

热评文章