基于redis的分布式锁学习

最近接到一个需求,需要从微信获取新token,每次获取微信都会更新最新的token,之前的token作废,每个token有一个过期时间,在此过期时间内客户端可直接拿去校验,不用重新获取。有点触及只是盲区,经老大指点了解下分布式锁,这里记录学习下基于redis实现的分布式锁。

分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同机器之间共享某个资源时,需要互斥来保证资源更新的一致性。分布式锁通常应具备以下条件:

  • 互斥性:任意时刻只能有一个客户端持有锁
  • 无死锁:即便持有锁的客户端发生意外无法主动释放,锁依然可以被其他客户端获取
  • 容错:只要满足多数redis节点正常,客户端就可以正常的获取和释放锁

目前常见的分布式锁实现有:

  • 基于数据库唯一索引
  • 基于redis(setnx)
  • 基于memcached(add)
  • 基于zookeeper(临时节点)

单机分布式锁

redis本身并没有提供分布式锁相关的命令,但是setnx的特性非常接近:setnx在set的基础上进行一个判断,只有在key不存在的情况下才会进行正常set,返回成功;如果key存在返回失败。又setnx本身具有原子性,所以正常情况下可以保证互斥性;同时setnx还可以设置过期时间,达到过期时间则该值无效,从而满足了无死锁特性。
释放锁redis没有类似的命令,需要用两个命令来实现:get,del:

1
2
if get(key) == value then
del(key)

两条指令就需要保证原子性,否则在get之后被别的线程抢占获取锁,再切换回来执行del会导致del掉新的锁。所以一般使用可以保证一致性的lua脚本执行释放锁的过程:

1
2
3
4
5
if redis.call("get", key) == m_random_value then
return redis.call("del", key)
else
return 0
end

lua实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function _M.new(self, r, lock_name, lock_num, lock_time)
return setmetatable({
_redis = r,
_lock_name = lock_name,
_lock_num = lock_num,
_lock_time = lock_time,
},mt)
end

function _M._lock(self)
local res, err = self._redis:set(self._lock_name, self._lock_num, "NX", "PX", self._lock_time)
return res, err
end

function _M._unlock(self)
local res, err = self._redis:get(self._lock_name)
if not res then
return res, err
end

if self._lock_num == res then
res, err = self._redis:del(self._lock_name)
return res, err
else
return nil, "locked by other"
end

end

考虑一些异常情况:

  1. case1: clientA获取锁setnx(key,value),设置过期时间1秒,0.9秒时clientA主动释放锁,但因为网络延迟该释放锁的请求在1.2秒才到达redis,在1秒到1.2秒这期间clientB获取锁setnx(key,value),设置过期时间1秒,这样在1.2秒时clientB获取的锁会被clientA延迟的释放锁命令所释放。
    解决:同一台机器或不同机器每一次获取锁时setnx的value必须不同,一般设置为以当前时间(毫秒时间戳)为种子所生成的随机数。
  2. case2: clientA获取锁,设置过期时间3s,执行更新资源的网络操作时发生阻塞5s,当时间到达3s时过期,故clientA的锁自动释放,clientB获取锁成功更新资源,等clientA在5s后回来时又将clientB刚更新的资源更新,发生数据不一致。
    解决:这个case的解决需要看具体应用场景,网上有个方法是另外起一个线程来更新过期时间,确保过期时间大于业务执行时间。这样的缺点就是网络如果一直阻塞,别的请求就没法获得锁一直等待。我觉得如果是对先后请求顺序有要求的场景下,可以考虑在每次更新资源时进行两处判断:a:是否依旧持有锁。b:需要更新的资源是否已经被更新。如果依旧持有锁时,说明未发生上述case,可以正常更新资源。但是如果此时不持有锁了也不能立刻判断是已经被别的请求上锁,此时看下需要更新的资源是否已经被更新(可以记录更新时间戳),如果已经被更新则不要再写入覆盖;如果没有更新则进行写入。这样保证了先请求的不覆盖后请求的资源更新。当然也可以直接进行b判断,不用判断是否持有锁。
  3. case3:clientA释放锁时,在get获取到锁的value和当前一致,发送del指令发生了网络延迟超过过期时间,超时锁自动释放,clientB获取锁进行相关逻辑,此时clientA的del指令到达,释放了clientB刚上的锁…
    解决:(猜想)可以使用redis脚本,将get与del写成一条redis脚本发送到redis服务器执行。
  4. case4:clientA获取锁进行共享资源的操作,此时redis的master宕机,slave切换为新master,由于redis的数据同步是异步操作,在clientA的锁没有同步到slave时clientB向新的master获取锁,获取成功进行共享资源的操作,clientA和clientB同时获取到锁,违背互斥性。
    解决:redis集群分布式锁

顺便普及下redis的数据同步流程,分为两个步骤:

  1. 同步现有数据:此步骤发生在某个redis实例第一次成为某个master的slave时,master将现有数据状态后台打包生成rdb文件,从打包时刻起所有涉及修改数据的操作都放到一个命令缓冲区,rdb文件生成完毕发送到slave进行数据载入。然后master将缓冲区的指令发送到slave进行打包后数据修改的同步。
  2. 命令传播:第一步完成后,master收到所有涉及修改数据的指令都会发送到slave执行一遍,这里的“发送”就会经过网络,经过网络就有可能发送延时。master不可能确保每个slave都执行成功才返回结果给客户端,这样效率显然会很低,而且如果某个slave宕机或者网络延迟高,那客户端岂不是要等很久。所以这里的命令传播是异步操作,即master发送同步指令成功后就回复客户端执行结果,至于此时slave是否写完成客户端不关心。
    case4就发生在命令传播的同步指令发出后且slave没有执行前。

redis集群分布式锁(redlock)

为了解决上述case4,产生了redis集群的分布式锁方法,思路为准备N个redis的master实例,获取锁时必须大多数master(大于N/2,N为奇数)获取锁成功才算最终获取成功(感觉初衷有点像单master多slave下的手动完成同步并关心各slave同步结果)。算法流程如下:

  1. 获取当前毫秒时间戳
  2. 同一请求使用相同的key和value对这N个master顺序进行获取锁操作,对于每个master实例上锁时需要定义一个操作的超时时间t,避免该master有问题还在傻等,该超时时间t应该小于锁的自动释放时间T(官方给出一个例子是锁失效时间为10s,那么每个实例操作的超时时间应该设为5-50ms,有点懵不知道怎么得出来的),超过t返回失败继续对下一个master进行上锁。
  3. 当且仅当一半以上的master成功获取锁并且总耗时小于锁的超时时间,则认为该请求成功获取锁。
  4. 每个master上锁的实际过期时间不同,因为对前面的master上锁以及等待回包都花时间,所以每个master上锁的实际时间是用预期过期时间减去前面几个master上锁所花费的时间。比如第一个master设置过期时间为3s,上锁花费100ms,第二个master上锁实际过期时间应设为3s-100ms,上锁花费150ms,第三个master上锁实际过期时间应为3s-100ms-150ms,以此类推。
  5. 如果最终获取锁失败(获取锁的master数量小于N/2),则应该立刻对所有master进行解锁,此外各请求还要各自delay一个随机时间后再来上锁。官方解释delay不同的随机值是为了避免相同时间后这么多请求再来竞争,尽量减少同时想获取锁的请求数,从而加大获取锁成功的概率。比如这一秒有100个请求来获取锁,然后失败,虽然接下来的每一秒都会有请求来获取锁,这一秒失败delay了不同的值,将这100个请求平均分配到了接下来的n秒上,但相比于delay相同的值效果会好很多。
  6. 解锁只需要对所有master发送解锁请求即可,不用判断是否获取锁成功,效果一样,全部发送写起来简单点。

回到case4,在锁已经被clientA获取的前提下,如果某个master挂掉了,clientB从salve升上来的新master处成功获得了锁,但是对于其他的N-1个master仍然获取失败,最终还是失败。不过为了防止case4的放大版(多台master同时挂掉)的发生,这N个master需要物理隔离,宕机与否彼此不受影响。