引言
目前很多系统都是使用redis作为分布式锁,如果redis是单节点部署,基本上不会出现什么问题。但如果redis是多节点的集群部署,那么使用redis集群作为分布式锁就会存在一些问题。这两篇文章进行了详细的讲解。http://zhangtielei.com/posts/blog-redlock-reasoning.html http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html
一、基于单节点的redis锁
客户端获取锁
SET resource_name my_random_value NX PX 30000
如果返回成功,则说明客户端获取锁成功,然后就可以访问公共资源了,如果失败则获取锁失败。对于这条命令,需要注意
my_random_value:必须是一个随机字符,并且唯一。如果不唯一,可能会出现以下情况:
1.客户端1获取资源成功
2.客户端1阻塞超时,锁自动释放
3.客户端2获取锁成功
4.客户端1从阻塞中醒来,释放了客户端2的锁必须设置NX,表示只有resource_name不存在时才会设置成功,保证只有第一个请求的客户端获取锁成功
PX 30000 表示过期时间为30s,为了保证原子操作必须在SET时设置过期时间
客户端释放锁
释放锁时使用下面的redis lua脚本执行来保证原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
只有当resource_name的值和客户端持有的数据相等时才能够调用del删除resource_name,否则不进行删除操作。从而防止一个客户端释放另一个客户端持有的锁。
安全性和可靠性:
分析一下redis锁的原理,我们在redis实例中创建一个键值,同时设置该键值的超时时间。创建该键值的客户端获取锁成功,访问公共资源。同时如果客户端宕机则锁会自动释放。客户端需要释放锁时只需要删除该键即可。但一旦单节点的Redis宕机则不能再提供服务,即使是基于Master-Slave模式的故障切换也是不安全的,例如下面场景
- 客户端1从Master获取锁
- Master宕机,但锁key还没有同步到Slave上
- Slave升级为Master
- 客户端2从新的Master上获取锁成功
二、分布式Redlock
分布式Redlock是基于多个redis示例实现的锁服务
算法实现
- 客户端获取当前时间start_time
- 客户端按照顺序依次向N个Redis节点获取锁操作,这个过程类似于上述单个节点获取锁过程。为了防止在获取某个Redis节点锁超时,客户端会设置一个很小的超时时间(timeout),timeout要远远小于锁本身超时时间。
- 当向所有Redis节点发送获取锁操作完成后,记录当前时间endtime。并且获取锁总消耗时间elapsed_time = (endtime-starttime),可用时长:validity = ttl - elapsed_time - drift,获取锁成功数n。当n > (N/2+1) && validity > 0(或其他值) 获取锁成功,并修改占有锁时长为validity
- 如果获取锁失败,则需要向所有客户端发起释放锁的操作
python源码:
import logging
import string
import random
import time
from collections import namedtuple
import redis
from redis.exceptions import RedisError
# Python 3 compatibility
string_type = getattr(__builtins__, 'basestring', str)
try:
basestring
except NameError:
basestring = str
Lock = namedtuple("Lock", ("validity", "resource", "key"))
class CannotObtainLock(Exception):
pass
class MultipleRedlockException(Exception):
def __init__(self, errors, *args, **kwargs):
super(MultipleRedlockException, self).__init__(*args, **kwargs)
self.errors = errors
def __str__(self):
return ' :: '.join([str(e) for e in self.errors])
def __repr__(self):
return self.__str__()
class Redlock(object):
default_retry_count = 3 //默认重试次数,指客户端获取锁重试次数,并不是向单个redis master加锁请求重试次数
default_retry_delay = 0.2 //默认重试间隔(实际应用中应该设置为随机值)
clock_drift_factor = 0.01 //不同服务器时间漂移比例因子
//释放锁的lua脚本
unlock_script = """
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end"""
def __init__(self, connection_list, retry_count=None, retry_delay=None):
self.servers = []
for connection_info in connection_list:
try:
if isinstance(connection_info, string_type):
server = redis.StrictRedis.from_url(connection_info)
elif type(connection_info) == dict:
server = redis.StrictRedis(**connection_info)
else:
server = connection_info
self.servers.append(server)
except Exception as e:
raise Warning(str(e))
self.quorum = (len(connection_list) // 2) + 1
if len(self.servers) < self.quorum:
raise CannotObtainLock(
"Failed to connect to the majority of redis servers")
self.retry_count = retry_count or self.default_retry_count
self.retry_delay = retry_delay or self.default_retry_delay
//向单个redis服务器加锁请求
def lock_instance(self, server, resource, val, ttl):
try:
assert isinstance(ttl, int), 'ttl {} is not an integer'.format(ttl)
except AssertionError as e:
raise ValueError(str(e))
return server.set(resource, val, nx=True, px=ttl)
def unlock_instance(self, server, resource, val):
try:
server.eval(self.unlock_script, 1, resource, val)
except Exception as e:
logging.exception("Error unlocking resource %s in server %s", resource, str(server))
//获取锁机制
def get_unique_id(self):
CHARACTERS = string.ascii_letters + string.digits
return ''.join(random.choice(CHARACTERS) for _ in range(22)).encode()
def lock(self, resource, ttl):
retry = 0
val = self.get_unique_id() //随机值
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min
# drift for small TTLs.
//表示不同服务器之间时间飘移 默认加锁时长的1% + 2ms
drift = int(ttl * self.clock_drift_factor) + 2
redis_errors = list()
while retry < self.retry_count:
n = 0
start_time = int(time.time() * 1000)
del redis_errors[:]
for server in self.servers:
try:
if self.lock_instance(server, resource, val, ttl):
n += 1
except RedisError as e:
redis_errors.append(e)
//加锁消耗时长
elapsed_time = int(time.time() * 1000) - start_time
//剩余时长
validity = int(ttl - elapsed_time - drift)
//剩余时长 > 0 && 加锁成功 >= n/2+1
if validity > 0 and n >= self.quorum:
if redis_errors:
raise MultipleRedlockException(redis_errors)
return Lock(validity, resource, val)
else:
for server in self.servers:
try:
self.unlock_instance(server, resource, val)
except:
pass
retry += 1
time.sleep(self.retry_delay)
return False
//解锁,向所有服务器发送解锁请求
def unlock(self, lock):
redis_errors = []
for server in self.servers:
try:
self.unlock_instance(server, lock.resource, lock.key)
except RedisError as e:
redis_errors.append(e)
if redis_errors:
raise MultipleRedlockException(redis_errors)
安全性
1.访问共享资源期间所有锁未过期
- 根据redlock算法可知,每个redis实例锁的过期时间为ttl
- 客户端获取锁成功后将锁时间修改为validity = (ttl - elapsed_time - drift)
- 此时距离第一个实例加锁已经过去了elapsed_time,第一个实例锁释放剩余时间为 ttl - elapsed_time
- 由于drift的存在,所以在客户端占有资源的时间内第一个实例的锁是不会过期的
所以,在客户端访问资源期间,所有实例上的锁都不会自动过期,其他客户端也无法获取这个锁。
2.故障恢复问题
多节点的Redis系统没有单节点failover带来的问题,但当某个节点崩溃重启时,仍然会有问题。如下步骤:
- 客户端1锁住了A,B,C三个节点,由于种种原因未锁住D和E
- C崩溃,key并未持久化到硬盘,C重启
- 客户端2锁住C,D,E三个节点,获取锁成功。
出现上述情况时就会导致访问资源冲突。当然,我们可以将redis的AOF持久化方式设置为每次修改数据都进行fsync,但这样会降低 系统性能,并且即使每次更新都执行fsync,操作系统仍不能完全保证数据持久化到硬盘上,因此antirez提出了延迟重启(delayed restarts)的概念:当一个节点崩溃后并不直接重启,而是过一段时间重启。这段时间应该大于锁的有效期。
RedLock缺陷
1.客户端长时间阻塞问题
著名分布式大师Martion在2016-2-8日发布了“How to do distributed locking”博客,博客中给出了一个时序图:
Martin指出,即使锁服务能够正常工作,但仍会出现问题。例如上面所示时序图:
client1获取锁成功
client1触发了full gc(或者阻塞时间太长或者业务耗时太长)
锁超时释放
client2获取锁成功,访问公共资源
-
client1恢复,访问公共资源,造成冲突
一般来说client在访问公共资源时首先check是否还持有锁,如果不持有锁再访问数据。但由于GC Pause可能在任何时间点发生,有可能在check之后发生,仍不能避免上述问题,即使我们使用非JAVA类语言即不存在Long GC Pause,但仍然有可能会因为某些原因导致长阻塞。
基于fencing机制的分布式锁
Martin提出了一种基于fencing tocken的解决方案。fencing token是一个单调递增的数字,客户端在加锁时获取token,在访问资源时待着token进行访问。这样就可以通过比较所带的token和公共资源上token大小来避免过期的token堆资源进行访问。如下所示时序图
- client1获取锁成功,同时获取一个Token 33
- client1进入GC Pause,锁超时释放
- client2获取锁成功,同时获取Token 34
- client2访问公共资源,并将Token 34 写到资源上
- client1从GC Pause恢复,访问公共资源,发现所携带的Token小于正在访问公共资源的Token,则访问失败,直接返回,避免了访问冲突
其实上述fencing机制并不能完全解决客户端阻塞问题,因为GC有可能发生在任何时刻。如果在check Token之后发生长时间的GC仍然有可能造成访问资源冲突
2. 对系统计时(timing)太过于依赖
Martin在博客中指出Redlock对系统的计时(timing)太过于依赖,例如文中给出的一个示例:
- client1从Redis节点A,B,C获取锁,D,E获取失败
- 节点C上的时间向前发生了跳跃,导致其维护的锁失效
- client2获取C,D,E节点锁成功
- client1和client2同时获取了锁
上边这种情况的发生本质上就是Redlock对系统时钟有比较强的依赖。redis是通过gettimeofday函数来判断key是否过期的,而这种做法是不推荐的(https://blog.habets.se/2010/09/gettimeofday-should-never-be-used-to-measure-time.html)。当发生时间跳跃或者管理员修改了机器的本地时间,Redlock就无法保证其安全性。
三、基于ZooKeeper的分布式锁
很多人都认为如果要构建一个更加安全的分布式锁,那么需要使用Zookeeper,而不是Redis。另一个著名的分布式专家Flavio Junqueira在Martin发表blog后也写了一篇博客,介绍基于ZK的分布式锁,博客地址:https://fpj.me/2016/02/10/note-on-fencing-and-distributed-locks/
文中指出,基于ZK创建分布式锁的一种方式:client1创建临时节点/lock,如果创建成功则说明其拿到锁,其他客户端无法创建。由于/lock节点是临时节点,所以创建它的客户端一旦崩溃就会自动删除/lock节点。ZK是如何检测到客户端崩溃的呢,实际上ZK和客户端维护者一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
设想如下的执行序列:
- 客户端1创建了znode节点/lock,获得了锁。
- 客户端1进入了长时间的GC pause。
- 客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
- 客户端2创建了znode节点/lock,从而获得了锁。
- 客户端1从GC pause中恢复过来,它仍然认为自己持有锁。
最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。对于这种情况,Flavio提出了对资源进行访问时先进行Mark,其实类似于Martin提出的Fencing机制,每次访问共享资源时对资源进行mark,防止旧的(比当前mark小的)客户端访问资源。
Zookeeper作为分布式锁的另一个优势是其具有watch机制,当客户端创建/lock节点失败时并不一定立即返回,其进入等待状态。当/lock节点被删除时ZK可以通过watch机制通知它,这样客户端就可以继续完成创建节点,直到获取锁。显然Redis是无法提供这样特性的。
四、总结
- 按照Martin提到的两种用途,如果我们使用分布式锁仅仅是为了效率,那么我们可以选择任何一种分布式锁的实现。但如果是为了正确性,那么我们就需要谨慎点,认真选择。
- 对于客户端出现长时间GC Pause的情况,通过fencing机制可以解决,但如果客户端在访问公共资源时出现长时间的GC Pause,目前暂未有解决方案。
- 相比于Redis,Zookeeper提供了更加灵活地加锁方式,同时其也可以避免客户端崩溃而长期持有锁。