欢迎加入QQ讨论群258996829
麦子学院 头像
苹果6袋
6
麦子学院

如何基于redis在分布式环境下实现一个全局锁?

发布时间:2016-11-25 18:51  回复:0  查看:3058   最后回复:2016-11-25 18:51  

今天和大家分享的是redis学习中,如何在分布式环境下实现一个全局锁,在开始之前先说说非分布式下的锁:

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

  · 单机 – 多进程程序使用信号量sem,解决多个进程之间的同步问题

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

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

  redis分布式锁

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

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

  分析代码

  构造函数

  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/2redis master的锁,相当于无法获得集群的锁。

  初始化redis连接

  private function initInstances()

  {

  if (empty($this->instances)) {

  foreach ($this->serversas $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台故障就无法产生大多数,那么整个分布式锁就不可用了。

  请求1redis上锁

  private function lockInstance($instance, $resource, $token, $ttl)

  {

  return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);

  }

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

  请求1redis放锁

  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脚本,如果resourcevalue不等于lock时设置的token则说明锁已被它人占用无需释放,否则说明是自己上的锁可以DEL删除。

  · lua脚本在redis里原子执行,在这里即保障GETDEL的原子性。

  请求集群锁

  public function lock($resource, $ttl)

  {

  $this->initInstances();

  $token = uniqid();

  $retry = $this->retryCount;

  do {

  $n = 0;

  $startTime = microtime(true) * 1000;

  foreach ($this->instancesas $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->instancesas $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 masterlock,统计成功的次数。

  · 因为遍历redis master进行逐个上锁需要花费一定的时间,因此在第1redis上锁前记录时间T1,结束最后一个redis上锁动作的时间点T2,此时第1redisTTL已经消逝了T2-T1这么长的时间。

  · 为了保障在锁内计算期间锁不会失效,我们剩余可以占用锁的时间实际上是TTL – (T2 – T1),因为越靠前上锁的redis其剩余时间越少,最少的就是第1redis了。

  · drift值用于补偿不同机器时钟的精度差异,怎么理解呢:

  · 在我们的程序看来时间过去了(T2-T1),剩余的锁时间认为是TTL-(T2-T1),在接下来的剩余时间内进行计算应该不会超过锁的有效期。

  · 但是第1redis机器的机器时钟也许跑的比较快(比如时钟多前进了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->instancesas $instance) {

  $this->unlockInstance($instance, $resource, $token);

  }

  }

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

  故障处理

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

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

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

  · A发起lock,锁住了2redis(r1+r2),超过3/2+1(大多数),开始执行锁内操作。

  · r0() r1(A) r2(A

  · r1宕机,立即重启,数据全部丢失;A仍旧在进行锁内计算,并不知情。

  · r0() r1() r2(A)

  · B发起lock,锁住了2redis(r0+r1),超过3/2+1(大多数),开始执行锁内操作。

  · r0(B) r1(B) r2(A)

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

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

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

  分布式锁用途

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

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

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

 

来源:鱼儿的博客

您还未登录,请先登录

热门帖子

最新帖子