基于redis实现可靠的分布式锁

什么是锁

今天要谈的是如何在分布式环境下实现一个全局锁,在开始之前先说说非分布式下的锁:

  • 单机 – 单进程程序使用互斥锁mutex,解决多个线程之间的同步问题
  • 单机 – 多进程程序使用信号量sem,解决多个进程之间的同步问题

这里同步的意思很简单:某个运行者,用某个工具,保障某段代码,独占的运行,直到释放。

分布式锁解决的是 多台机器 – 多个进程 之间的同步问题,因为不同的机器之间mutex/sem无法使用。不过要注意:即便如此,一个进程内多个线程之间仍旧建议使用mutex同步,尽量减少对分布式锁服务造成不必要的负担。

redis分布式锁

首先呢,基于redis的分布式锁并不是一个坊间方案,而是redis官网提供的解决思路并且有若干语言的实现版本直接使用。

今天要做的,首先是阅读官方的文档(中文点我英文点我),有些地方讲的不怎么清晰,所以我接下来会分析PHP版本的代码,应该可以解答你的主要疑惑。

分析代码

首先打开代码:https://github.com/ronnylt/redlock-php/blob/master/src/RedLock.php,这是PHP的官方推荐实现版本,它基于composer安装(不懂composer可以点我)。

构造函数

  • 需要传入的是redis的若干master节点地址,并且这些master是纯内存模式且无slave的。
  • retryDelay是设置每一轮lock失败或者异常,多久之后重新尝试下一轮lock。
  • retryCount是指最多几轮lock失败后彻底放弃。
  • quorum体现了分布式里一个有名的”鸽巢原理”,也就是如果大于半数的节点操作成功则认为整个集群是操作成功的;在这里的意思是,如果超过1/2的(>=N/2+1)redis master调用锁成功,则认为获得了整个redis集群的锁,假设A用户获得了集群的锁,那么接下来的B用户只能获得<=1/2的redis master的锁,相当于无法获得集群的锁。

初始化redis连接

  • 遍历每个redis master,建立到它们的连接并保存起来;
  • 因为需要用到”鸽巢原理”,也就是redis数量足够产生”大多数”这个目的:因此redis master数量最好>=3台,因为2台的话大多数是2台(2/2+1),这样任何1台故障就无法产生”大多数”,那么整个分布式锁就不可用了。

请求1个redis上锁

  • 请求某一台redis,如果key=resource不存在就设置value=token(算法生成,全局唯一),并且redis会在ttl时间后自动删除这个key

请求1个redis放锁

  • 请求某一台redis,给它发送一段lua脚本,如果resource的value不等于lock时设置的token则说明锁已被它人占用无需释放,否则说明是自己上的锁可以DEL删除。
  • lua脚本在redis里原子执行,在这里即保障GET和DEL的原子性。

请求集群锁

  • 首先整个lock过程最多会重试retry次,因此外层有do while。
  • 为了获取”大多数”的锁,因此遍历每个redis master去lock,统计成功的次数。
  • 因为遍历redis master进行逐个上锁需要花费一定的时间,因此在第1个redis上锁前记录时间T1,结束最后一个redis上锁动作的时间点T2,此时第1个redis的TTL已经消逝了T2-T1这么长的时间。
  • 为了保障在锁内计算期间锁不会失效,我们剩余可以占用锁的时间实际上是TTL – (T2 – T1),因为越靠前上锁的redis其剩余时间越少,最少的就是第1个redis了。
  • drift值用于补偿不同机器时钟的精度差异,怎么理解呢:
    • 在我们的程序看来时间过去了(T2-T1),剩余的锁时间认为是TTL-(T2-T1),在接下来的剩余时间内进行计算应该不会超过锁的有效期。
    • 但是第1台redis机器的机器时钟也许跑的比较快(比如时钟多前进了1毫秒),那么数据会提前1毫秒淘汰,然而我们认为TTL-(T2-T1)秒内锁有效,而redis相当于TTL-(T2-T1)-1秒内锁有效,这可能导致我们在锁外计算。(drift+1)
    • 另外,我们计算(T2-T1)之后到返回给lock的调用者之间还有一段代码在运行,这段代码的花费也将占用一些时间,所以drift应该也考虑这个。(drift+1)
    • 最后,ttl * 0.01的意思是ttl越长,那么时钟可能差异越大,所以这里做了一个动态计算的补偿,比如ttl=100ms,那么就补偿1ms的时钟误差,尽量避免遇到锁已过期而我们仍旧在计算的情况发生。
  • 如果锁redis成功的次数>1/2,并且整个遍历redis+锁定的过程的耗时 没有超过锁的有效期,那么lock成功,将剩余的锁时间(TTL减去上锁花费的时间)+ 锁的标识token 返回给用户。
  • 如果上锁中途失败(返回key已存在)或者异常(不知道操作结果),那么都认为上锁失败;如果上锁失败的数量超过1/2,那么本次上锁失败,需要遍历所有redis进行回滚(回滚失败也没有办法,其他人只能等待我们的key过期,并不会有什么错误)。

释放集群锁

  • 遍历所有redis,利用lua脚本原子的安全的释放自己建立的锁。

故障处理

这里所有redis都是master,不开启持久化,也不需要slave。

如果某台redis宕机,那么不要立即重启它,因为宕机后redis没有任何数据,如果你此时重启它,那么其他进程就可以可以锁住一个本应还没有过期的key,这可能导致2个调用者同时在锁内进行计算,举个例子吧:

3个redis,两个用户A和B,有这么1个典型流程来说明上述情况:

  • A发起lock,锁住了2个redis(r1+r2),超过3/2+1(大多数),开始执行锁内操作。
    • r0() r1(A) r2(A)
  • r1宕机,立即重启,数据全部丢失;A仍旧在进行锁内计算,并不知情。
    • r0() r1() r2(A)
  • B发起lock,锁住了2个redis(r0+r1),超过3/2+1(大多数),开始执行锁内操作。
    • r0(B) r1(B) r2(A)

悲剧的事情发生了,因为r1宕机立即重启导致B可以成功锁住”大多数”redis,导致A和B并发操作。

红色字体就是解决这个问题的:不要立即重启,保持r1无法联通,这样的话B只能锁住r0,没有达到”大多数”从而上锁失败。那么何时重启r1呢?根据业务最大的TTL判断,当过了TTL秒后redis中所有key都会过期,遵守规则的A用户的计算也应早已结束,此时B获得锁也可以保证独占。

当然,无论宕机几台原理都是一样的,不要立即重启,等待最大TTL过期后再启动redis,你可以自己分析上述例子,假设r0和r1一起宕机看看又会发生什么。

分布式锁用途

我也没有经验,不过猜想一个场景:

库存服务通常需要高并发的update一行记录以更新商品的剩余数量,而我们知道mysql的update是行锁的,如果并发过高造成mysql的工作线程都在等待行锁,将会影响mysql处理其他请求。

如果可以把行锁用redis锁取代,那么到达mysql层的并发将永远都是1,问题将得到解决,不过要注意上述redis锁的实现有一个问题就是高并发场景下,可能导致谁都无法获取”大多数”的锁,不过好在redis一般足够稳定并且上述实现在lock失败重试时有一个随机的间隔值,从而让某个Lock调用者有机会获得”大多数”。

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~