基于redis实现可靠的分布式锁
什么是锁
今天要谈的是如何在分布式环境下实现一个全局锁,在开始之前先说说非分布式下的锁:
- 单机 – 单进程程序使用互斥锁mutex,解决多个线程之间的同步问题
- 单机 – 多进程程序使用信号量sem,解决多个进程之间的同步问题
这里同步的意思很简单:某个运行者,用某个工具,保障某段代码,独占的运行,直到释放。
分布式锁解决的是 多台机器 – 多个进程 之间的同步问题,因为不同的机器之间mutex/sem无法使用。不过要注意:即便如此,一个进程内多个线程之间仍旧建议使用mutex同步,尽量减少对分布式锁服务造成不必要的负担。
redis分布式锁
首先呢,基于redis的分布式锁并不是一个坊间方案,而是redis官网提供的解决思路并且有若干语言的实现版本直接使用。
今天要做的,首先是阅读官方的文档(中文点我,英文点我),有些地方讲的不怎么清晰,所以我接下来会分析PHP版本的代码,应该可以解答你的主要疑惑。
分析代码
首先打开代码:https://github.com/ronnylt/redlock-php/blob/master/src/RedLock.php,这是PHP的官方推荐实现版本,它基于composer安装(不懂composer可以点我)。
构造函数
function __construct(array $servers, $retryDelay = 200, $retryCount = 3) { $this->servers = $servers; $this->retryDelay = $retryDelay; $this->retryCount = $retryCount; $this->quorum = min(count($servers), (count($servers) / 2 + 1)); }
- 需要传入的是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连接
private function initInstances() { if (empty($this->instances)) { foreach ($this->servers as $server) { list($host, $port, $timeout) = $server; $redis = new \Redis(); $redis->connect($host, $port, $timeout); $this->instances[] = $redis; } } }
- 遍历每个redis master,建立到它们的连接并保存起来;
- 因为需要用到”鸽巢原理”,也就是redis数量足够产生”大多数”这个目的:因此redis master数量最好>=3台,因为2台的话大多数是2台(2/2+1),这样任何1台故障就无法产生”大多数”,那么整个分布式锁就不可用了。
请求1个redis上锁
private function lockInstance($instance, $resource, $token, $ttl) { return $instance->set($resource, $token, ['NX', 'PX' => $ttl]); }
- 请求某一台redis,如果key=resource不存在就设置value=token(算法生成,全局唯一),并且redis会在ttl时间后自动删除这个key
请求1个redis放锁
private function unlockInstance($instance, $resource, $token) { $script = ' if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end '; return $instance->eval($script, [$resource, $token], 1); }
- 请求某一台redis,给它发送一段lua脚本,如果resource的value不等于lock时设置的token则说明锁已被它人占用无需释放,否则说明是自己上的锁可以DEL删除。
- lua脚本在redis里原子执行,在这里即保障GET和DEL的原子性。
请求集群锁
public function lock($resource, $ttl) { $this->initInstances(); $token = uniqid(); $retry = $this->retryCount; do { $n = 0; $startTime = microtime(true) * 1000; foreach ($this->instances as $instance) { if ($this->lockInstance($instance, $resource, $token, $ttl)) { $n++; } } # Add 2 milliseconds to the drift to account for Redis expires # precision, which is 1 millisecond, plus 1 millisecond min drift # for small TTLs. $drift = ($ttl * $this->clockDriftFactor) + 2; $validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift; if ($n >= $this->quorum && $validityTime > 0) { return [ 'validity' => $validityTime, 'resource' => $resource, 'token' => $token, ]; } else { foreach ($this->instances as $instance) { $this->unlockInstance($instance, $resource, $token); } } // Wait a random delay before to retry $delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay); usleep($delay * 1000); $retry--; } while ($retry > 0); return false; }
- 首先整个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过期,并不会有什么错误)。
释放集群锁
public function unlock(array $lock) { $this->initInstances(); $resource = $lock['resource']; $token = $lock['token']; foreach ($this->instances as $instance) { $this->unlockInstance($instance, $resource, $token); } }
- 遍历所有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调用者有机会获得”大多数”。