大纲
1.Redis分布式锁的8大问题
2.Redis的RedLock算法分析
3.基于Redis和zk的分布式锁实现原理
4.Redis分布式锁的问题以及使用建议
1.Redis分布式锁的8大问题
(1)非原子操作(set+lua)
(2)忘了释放锁(手动+超时)
(3)释放了其他线程的锁(lua+唯一值)
(4)加锁失败的处理(自旋+睡眠)
(5)锁重入问题(key是锁名+field是请求ID+值加1)
(6)锁竞争问题(读写锁+分段锁)
(7)锁超时失效或锁提前过期问题(自动续期)
(8)主从复制问题(RedLock算法)
(1)非原子操作(set+lua)
使用Redis实现分布式锁,首先想到的可能是setnx命令,而且通过设置超时时间可以避免死锁。如下这段代码确实可以加锁成功,但是存在一个问题。
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}
一.存在的问题
加锁操作和设置锁的超时时间是分开的,并非原子操作。假如加锁成功,但设置超时时间失败了,该lockKey就变成永不失效。极端情况下,获取锁的客户端如果宕机了,那么就没法释放锁了。那么应该如何保证原子性的加锁命令呢?
二.优化措施
由于Redis的setnx命令加锁和设置超时时间是分开的,并非原子操作。所以可以通过Redis的set命令来实现原子操作,该命令可指定多个参数。
//lockKey:锁的标识
//requestId:请求ID
//NX:只在键不存在时,才对键进行设置操作
//PX:设置键的过期时间为millisecond毫秒
//expireTime:过期时间
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
(2)忘了释放锁(手动+超时)
一.存在的问题
使用Redis的set命令加锁,表面上看起来没有问题。但如果加锁后,每次都要达到超时时间才释放锁,就有点不合理了。加锁后,如果不及时释放锁,会有很多问题。
二.优化措施
所以分布式锁更合理的用法是:
步骤一:手动加锁
步骤二:执行业务操作
步骤三:手动释放锁
步骤四:如果手动释放锁失败了,那么达到超时时间Redis才自动释放锁
如何释放锁的代码如下:捕获业务代码的异常,然后在finally中释放锁,保证无论代码执行成功或失败,都执行释放锁。
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}
(3)释放了其他线程的锁(lua+唯一值)
一.存在的问题
只在finally中释放锁还是会存在问题,因为在多线程场景中可能会出现释放了其他线程的锁的情况。
假如线程A和线程B,都使用lockKey加锁。线程A加锁成功,但是由于业务处理耗时很长,超过设置的超时时间。这时Redis会自动释放lockKey锁,此时线程B就能给lockKey加锁成功。接下来当线程B进行业务处理时,线程A刚好执行完业务处理。于是线程A就在finally方法中释放lockKey锁。从而导致:线程B的锁被线程A给释放了。
二.优化措施
在使用set命令加锁时,给lockKey锁设置一个唯一值如requestId。requestId的作用就是在释放锁时,防止释放其他线程的锁。
在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同。如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。也就是每个线程只能释放自己加的锁,不允许释放其他线程加的锁。
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
也可以使用lua脚本来实现释放锁的原子操作:
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then" +
"return redis.call('del', KEYS[1]);" +
"else" +
"return 0;" +
"end;";
(4)加锁失败的处理(自旋+睡眠)
一.存在的问题
如果有两个线程同时上传文件到sftp,上传文件前先要创建目录,假设两个线程需要创建的目录名都是当天的日期。如果不做任何控制,直接并发创建目录,第二个线程必然会失败。如果加一个Redis分布式锁后在目录不存在时才进行创建,那么第二个请求加锁失败时,是返回失败,还是返回成功?第二个请求加锁失败肯定不能返回成功,因为可能还没创建文件。但也不能直接返回失败,因为锁释放后,还是可以处理成功的。
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if (!exists(path)) {
mkdir(path);
}
return true;
}
} finally {
unlock(lockKey, requestId);
}
return false;
二.优化措施
在规定的时间内,通过自旋 + 睡眠去尝试加锁。比如在规定的500毫秒内,不断自旋尝试加锁。如果成功,则直接返回。如果失败,则睡眠50毫秒,再发起新一轮加锁的尝试。如果到了超时时间还未成功加锁,则直接返回失败。
try {
Long timeout = 500L;
Long start = System.currentTimeMillis();
while (true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if (!exists(path)) {
mkdir(path);
}
return true;
}
long time = System.currentTimeMillis() - start;
if (time >= timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
unlock(lockKey, requestId);
}
return false;
(5)锁重入问题(key是锁名+field是请求ID+值加1)
一.存在的问题
假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。以菜单树为例,需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。
需要注意的是菜单不是一成不变的,在后台中运营可以动态添加、修改和删除菜单。
为了保证在并发情况下,每次都能获取最新数据,可以加Redis分布式锁。但接着问题来了,在递归方法中会递归遍历多次,每次都加同一把锁。
递归第一层当然是可以加锁成功的,但递归第二层、第三层、第N层,不就会加锁失败了吗?
递归方法中加锁的伪代码如下:看起来好像没有问题,但最终执行程序之后发现出现了异常。因为第一层递归加锁成功,还没释放锁,就直接进入第二层递归。由于锁名为lockKey且值为requestId的锁已经存在,所以第二层递归会加锁失败,然后返回到第一层,第一层接下来正常释放锁,然后整个递归方法直接返回了。所以这个递归方法其实只执行了第一层递归就返回了,其他层的递归由于加锁失败而根本没法执行。
private int expireTime = 1000;
public void fun(int level, String lockKey, String requestId) {
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if (level <= 10) {
this.fun(++level, lockKey, requestId);
} else {
return;
}
}
return;
} finally {
unlock(lockKey, requestId);
}
}
二.改进措施
使用可重入锁,也就是使用Redisson框架内部实现的可重入锁功能。
private int expireTime = 1000;
public void run(String lockKey) {
RLock lock = redisson.getLock(lockKey);
this.fun(lock, 1);
}
public void fun(RLock lock, int level) {
try {
lock.lock(5, TimeUnit.SECONDS);
if (level <= 10) {
this.fun(lock, ++level);
} else {
return;
}
} finally {
lock.unlock();
}
}
如下是Redisson框架的可重入锁lua加锁代码:如果锁名不存在,则使用hset命令+pexpire命令加锁。如果锁名和requestId都存在,则使用hincrby命令给锁名的requestId值加1。这就是重入锁的关键,锁重入一次那么锁名的requestId值就加1。如果锁名存在,但值不是requestId,则返回过期时间。
//KEYS[1]:锁名
//ARGV[1]:过期时间
//ARGV[2]:uuid + ":" + threadId,可认为是requestId
//HINCRBY key field increment,会为哈希表key中的域field的值加上增量increment
String script =
"if (redis.call('exists', KEYS[1]) == 0) then" +
"redis.call('hset', KEYS[1], ARGV[2], 1);" +
"redis.call('pexpire', KEYS[1], ARGV[1]);" +
"return nil;" +
"endif (redis.call('hexists', KEYS[1], ARGV[2]) == 1)" +
"redis.call('hincrby', KEYS[1], ARGV[2], 1);" +
"redis.call('pexpire', KEYS[1], ARGV[1]);" +
"return nil;" +
"end;" +
"return redis.call('pttl', KEYS[1]);";
如下是Redisson框架的可重入锁lua释放锁代码:如果锁名和requestId不存在,则直接返回。如果锁名和requestId存在,则使用hincrby命令给锁名的requestId值减1。如果减1后锁名的requestId值大于0,说明还有引用,则重设过期时间。如果减1后锁名的requestId值还等于0,则可以删除锁名的key。然后发消息通知给等待的线程抢锁。
String script =
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then" +
"return nil;" +
"end" +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);" +
"if (counter > 0) then" +
"redis.call('pexpire', KEYS[1], ARGV[2]);" +
"return 0;" +
"else" +
"redis.call('del', KEYS[1]);" +
"redis.call('publish', KEYS[2], ARGV[1]);" +
"return 1;" +
"end;" +
"return nil;";
(6)锁竞争问题(读写锁+分段锁)
如果有大量写的业务场景,使用上述Redis分布式锁是没有问题的。但如果有些业务场景,写操作比较少,而有大量读操作。这时使用上述的Redis分布式锁,就会有点浪费性能了。
锁的粒度越粗,多个线程对锁的竞争就越激烈,从而造成多个线程锁等待的时间也就越长,性能也就越差。所以提升Redis分布式锁性能的第一步,就是要把锁的粒度变细。
一.读写锁
加锁的目的是为了保证在并发环境中读写数据的安全性,也就是保证不会出现数据错误或者不一致的情况。但在绝大多数实际业务场景中,一般读操作的频率远远大于写操作。
由于并发读操作是并不涉及并发安全问题,所以没必要给读操作加互斥锁。只要保证读写、写写并发操作是互斥即可,这样可以提升系统性能。
将读锁和写锁分开,最大的好处是可以提升读操作性能。因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数的数据操作都是读操作。所以如果提升了读操作的性能,也就提升了整个锁的性能。
Redisson框架内部已经实现了读写锁的功能。
使用Redisson读锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//业务操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}
使用Redisson写锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//业务操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}
二.锁分段
把锁的粒度变细的另一种常见做法就是锁分段。ConcurrentHashMap就是将数据分为16段,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。
在秒杀场景中,假设库存有2000个商品可以供用户秒杀。为了防止出现超卖,通常会对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常的低。
所以为了提升系统性能,可以将库存分段。比如:分为100段,每段有20个商品可以参与秒杀。在秒杀过程中,先获取用户ID的Hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存。以此类推,最后模为100的用户访问第100段库存。这样在多线程环境中,就可以大大减少锁的冲突。
需要注意的是:将锁分段虽可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。
(7)锁超时失效或锁提前过期问题(自动续期)
一.存在的问题
如果线程A加锁成功,但由于某些原因执行耗时过长,超过锁的超时时间,这时Redis会自动释放线程A加的锁。但线程A还没执行完,还在对共享数据进行访问。如果此时线程B尝试加锁,那么也可以加锁成功,并对共享数据进行访问。这样就出现了多个线程对共享数据进行操作的问题。
二.改进措施
如果达到了超时时间,但业务代码还没执行完,则需要给锁自动续期。可以使用TimerTask类来实现自动续期的功能,比如获取锁之后自动开启一个定时任务,每隔10秒自动刷新一次过期时间。
这种机制在Redisson框架中叫Watch Dog,即看门狗。其实自动续期除了可以解决锁超时导致的锁失效问题之外,还可以解决不好预估锁过期时间而导致的锁提前过期问题。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自动续期逻辑
String script =
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then" +
"redis.call('pexpire', KEYS[1], ARGV[1]);" +
"return 1;" +
"end;" +
"return 0;";
...
}
}, 10000, TimeUnit.MILLISECONDS);
需要注意:
在实现自动续期功能时,还要设置一个总过期时间,比如设置成30秒。如果业务代码到了这个总过期时间,还没有执行完,就不再自动续期。
具体就是:
获取锁之后开启一个定时任务,每隔10秒判断锁是否存在。如果存在,则刷新过期时间。如果续期3次(30秒后),业务方法还是没执行完,则不再续期。
(8)主从复制问题(RedLock算法)
一.存在的问题
如果Redis存在多个节点,比如做了主从,或者使用了哨兵模式。如果线程A刚好从Redis的主节点成功加锁,结果主节点还没同步就挂了,这样就会导致新的主节点的锁丢失了。如果有新的线程B过来尝试加锁就会成功,最后导致分布式锁失效。
二.改进措施
为了解决这个问题,Redis官网提供了一个RedLock算法。Redisson框架也提供了一个专门的类RedissonRedLock,实现了该算法。
2.Redis的RedLock算法分析
(1)RedLock算法的两个前提
(2)RedLock算法的具体流程
(3)RedLock算法为什么要这么做
(4)分布式专家Martin对于RedLock的质疑
(6)基于zk的分布式锁是否安全
(7)是否要用RedLock
(8)如何正确使用分布式锁
(1)RedLock算法的两个前提
一.不再需要部署从库和哨兵节点,只部署主库
二.主库要部署多个,官方推荐至少5个节点
也就是说,想要使用Redlock,至少要部署5个Redis节点,而且这5个节点都是主库,它们之间没有任何关系。注意:不是部署Redis Cluster,而是部署5个简单的Redis节点。
(2)RedLock算法的具体流程
步骤一:客户端先获取当前时间戳T1。
步骤二:客户端依次向这5个节点发起加锁请求,且每个请求都会设置超时时间。超时时间是毫秒级的,要远小于锁的有效时间,而且一般是几十毫秒。如果某一个节点加锁失败,包括网络超时、锁被其它线程持有等各种情况,那么就立即向下一个Redis节点申请加锁。
步骤三:如果客户端从3个以上(过半)节点加锁成功,则再次获取当前时间戳T2。如果T2 - T1 < 锁的过期时间,则认为客户端加锁成功,否则加锁失败。
步骤四:如果加锁失败,要向全部节点发起释放锁的请求。如果加锁成功,则去操作共享资源。
RedLock算法的四个要点总结:
一.客户端在多个Redis节点上申请加锁
二.必须保证大多数节点加锁成功
三.大多数节点加锁的总耗时要小于锁的过期时间
四.释放锁时要向全部节点发起释放锁的请求
(3)RedLock算法为什么要这么做
一.为什么要在多个节点上加锁
二.为什么大多数加锁成功才算成功
三.为什么加锁成功后还要计算加锁的累计耗时
四.为什么释放锁要操作所有节点
一.为什么要在多个节点上加锁
本质上是为了容错,部分节点异常宕机,剩余节点加锁成功,整个锁服务依旧可用。
二.为什么大多数加锁成功才算成功
使用的多个Redis节点,其实就组成了一个分布式系统。在分布式系统中,总会出现异常节点。所以分布式系统要考虑,异常节点达到多少个也不影响整个系统的正确性。这是一个分布式系统容错问题,这个问题的结论是:如果存在故障节点,只要大多数节点正常,则整个系统依旧可以提供服务。
三.为什么加锁成功后还要计算加锁的累计耗时
因为操作的是多个节点,所以耗时肯定会比操作单个节点耗时更久,而且网络请求可能会存在延迟、丢包、超时等情况。所以网络请求越多,异常发生的概率就越大。即使大多数节点加锁成功,但如果加锁的累计耗时已超过锁的过期时间,那此时有些节点上的锁可能已经失效了,这个锁就没有意义了。
四.为什么释放锁要操作所有节点
在向某一个Redis节点加锁时,可能因为网络原因导致加锁失败。例如客户端在一个节点上加锁成功,但在读取响应结果时,可能因为网络问题导致读取响应失败,那么这把锁其实已经在Redis上加锁成功。所以释放锁时,不管之前有没有加锁成功,都需要释放所有节点的锁。
RedLock是否解决了Redis节点异常宕机锁失效的问题,保证了锁的安全?
(4)分布式专家Martin对于RedLock的质疑
他的文章主要阐述了4个论点:
一.分布式锁的目的是什么
二.锁在分布式系统中会遇到的问题
三.RedLock假设时钟正确是不合理的
四.提出fecing token的方案保证正确性
一.分布式锁的目的是什么
Martin认为使用分布式锁有两个目的:
目的一:效率
使用分布式锁的互斥能力,是避免做同样的没必要的两次工作。如果锁失效,并不会带来恶性的后果,如发了2次邮件无伤大雅。
目的二:正确性
使用锁用来防止并发进程互相干扰,如果锁失效,会造成多个进程同时操作同一条数据(数据库 + ES + 缓存)。从而导致数据严重错误、永久性不一致、数据丢失等恶性问题。
如果是为了效率,那么使用单机版Redis就可以了。即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而此时使用RedLock去提升效率,则显得太重了,没有这个必要。
如果是为了正确性,那么RedLock又达不到安全性要求,因为RedLock依旧存在锁失效的问题。
二.锁在分布式系统中会遇到的问题
Martin表示一个分布式系统的主要异常是NPC。
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
Martin用一个进程暂停(GC)的例子,指出了RedLock的安全性问题。
时间点一:客户端1请求锁定节点A、B、C、D、E
时间点二:客户端1拿到锁后,进入GC,时间比较久
时间点三:所有Redis节点上的锁都过期了
时间点四:客户端2获取到A、B、C、D、E上的锁
时间点五:客户端1结束GC,认为成功获取锁
时间点六:客户端2也认为获取到锁,于是发生冲突
Martin认为,GC可能发生在程序的任意时刻,而且执行时间是不可控的。即使使用没有GC的编程语言,当发生网络延迟、时钟漂移时,也都有可能导致RedLock出现问题,这里Martin只是拿GC举例。
三.RedLock假设时钟正确是不合理的
又或者当多个Redis节点的时钟发生问题时,也会导致RedLock锁失效。
时间点一:客户端1获取节点到A、B、C上的锁,但由于网络无法访问D和E
时间点二:节点C上的时钟向前跳跃,导致锁到期
时间点三:客户端2获取节点C、D、E上的锁,但由于网络无法访问A和B
时间点四:客户端1和2现在都相信它们持有了锁,产生冲突
Martin觉得,RedLock必须强依赖多个节点的时钟保持同步。一旦有节点时钟发生错误,那这个算法模型就失效了。即使C不是时钟跳跃,而是崩溃后立即重启,也会发生类似的问题。
Martin继续阐述,机器的时钟发生错误,是很有可能发生的。比如,系统管理员手动修改了机器时钟。比如,机器时钟在同步NTP时间时,发生了大的跳跃。
总之,Martin认为RedLock算法是建立在同步模型基础上的。但大量资料研究表明,同步模型的假设,在分布式系统中是有问题的。在分布式系统的中不能假设系统时钟是对的,所以必须非常小心假设。
四.提出fecing token的方案保证正确性
Martin提出了一种被叫作fecing token的方案,保证分布式锁的正确性,这个模型的流程步骤如下:
步骤一:客户端在获取锁时,锁服务可以向客户端提供一个递增的token
步骤二:客户端拿着这个token去操作共享资源
步骤三:共享资源可以根据token拒绝后来者(不是递增token的客户端)的请求
这样无论发生哪种NPC异常情况,都可以保证分布式锁的安全性,因为这个fecing token方案是建立在异步模型上的。而RedLock无法提供类似fecing token的方案,所以它无法保证安全性。
Martin还表示:
一个好的分布式锁,无论NPC怎么发生。可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响锁的性能(或称为活性),而不会影响锁的正确性。
Martin的结论:
结论一:RedLock不伦不类
如果使用RedLock是为了效率,那么RedLock又比较重,没必要这么做。如果使用RedLock是为了正确性,那么RedLock又不够安全。
结论二:时钟假设不合理
RedLock对系统时钟做出了危险的假设,假设节点机器时钟都是一致。如果不满足这些假设,锁就会失效。例如各节点的锁过期时间不一致导致过半节点提早过期,高并发下锁失效。又如时钟大幅跳跃还没执行到自动续期就过期,那么锁就可能失效。
结论三:无法保证正确性
Redlock不能提供类似fencing token的方案,所以解决不了正确性的问题。为了正确性,请使用有共识系统的软件,例如zk。
此外,还有一些场景无法保证锁的正确性(当然RedLock要求都是主库)。
比如客户端A尝试向5个Master实例加锁,但是仅仅在3个Maste中加锁成功。不幸的是此时3个Master中有1个Master突然宕机了,而且锁key还没同步到该宕机Master的Slave上,此时Salve切换为Master。于是在这5个Master中,由于其中有一个是新切换过来的Master,所以只有2个Master是有客户端A加锁的数据,另外3个Master是没有锁的。但继续不幸的是,此时客户端B来加锁。那么客户端B就很有可能成功在没有锁数据的3个Master上加到锁,从而满足了过半数加锁的要求,最后也完成了加锁,依然发生重复加锁。
(5)Redis作者Antirez的反驳
在Redis作者的反驳文章中,重点有3个:
一.解释时钟问题
二.解释网络延迟、GC问题
三.质疑fencing token机制
四.总结Redis作者的结论
一.解释时钟问题
首先Redis作者一眼就看穿了Martin提出的最为核心的问题:时钟问题。Redis作者认为:RedLock并不需要完全一致的时钟。RedLock只需要大体一致的时钟即可,允许有误差。例如要计时5s,但实际可能记了4.5s,之后又记了5.5s,有一定误差,但只要不超过误差范围锁失效时间即可。这对于时钟的精度要求并不高,而且这也符合现实环境。
对于时钟修改问题,Redis作者进行如下反驳:
反驳一:手动修改时钟问题。不要这么做就好了,否则直接修改Raft日志,那Raft也会无法工作。
反驳二:时钟跳跃问题。通过恰当的运维,保证机器时钟不会大幅度跳跃。比如每次通过微小的调整来完成,实际上这是可以做到的。
为什么Redis作者优先解释时钟问题?因为在后面的反驳过程中,需要依赖这个基础做进一步解释。
二.解释网络延迟、GC问题
Redis作者对网络延迟、进程GC可能导致RedLock失效问题也做了反驳,Martin的GC假设如下:
时间点一:客户端1请求锁定节点A、B、C、D、E
时间点二:客户端1拿到锁后,进入GC,时间比较久
时间点三:所有Redis节点上的锁都过期了
时间点四:客户端2获取到A、B、C、D、E上的锁
时间点五:客户端1结束GC,认为成功获取锁
时间点六:客户端2也认为获取到锁,于是发生冲突
Redis作者反驳这个假设其实是有问题的,RedLock是可以保证锁安全的。
下面回顾RedLock的具体步骤流程:
步骤一:客户端先获取当前时间戳T1。
步骤二:客户端依次向节点发起加锁请求,且每个请求都会设置超时时间。超时时间是毫秒级的,要远小于锁的有效时间,一般是几十毫秒。如果某一个节点加锁失败,包括网络超时、锁被其它线程持有等情况,那么就立即向下一个Redis节点申请加锁。
步骤三:如果客户端从过半节点加锁成功,则再次获取当前时间戳T2。如果T2 - T1 < 锁的过期时间,则认为客户端加锁成功,否则加锁失败。
步骤四:如果加锁失败,要向全部节点发起释放锁的请求。如果加锁成功,则去操作共享资源。
注意:重点在步骤三
加锁成功后为什么要重新获取当前时间戳T2?而且还用T2 - T1的时间,与锁的过期时间做比较?
Redis作者强调:
强调一:如果在步骤一到三发生了网络延迟、进程GC等耗时长的异常情况,那么在第三步T2 - T1时是可以检测出来的。如果超出锁设置的过期时间,那这时就认为加锁失败,之后释放所有节点的锁即可。
强调二:如果在步骤三之后发生网络延迟、进程GC等耗时长的异常情况,即客户端确认拿到了锁,去操作共享资源时发生了异常,导致锁失效。那么这不仅仅是RedLock的问题,任何其它锁服务比如zk也都会有类似的问题。
例如下面是RedLock在步骤三之后发生NPC的例子:客户端通过RedLock成功获取到锁(通过过半节点加锁成功 + 加锁耗时检查),客户端开始操作共享资源,此时发生网络延迟、进程GC等耗时很长的情况。此时锁过期自动释放,客户端开始操作MySQL(此时的锁可能会被其他线程拿到,出现锁失效的情况)。
Redis作者的结论:
结论一:客户端在拿到锁之前,无论经历什么NPC耗时长问题,RedLock都能在第三步检测出来。
结论二:客户端在拿到锁之后,如果发生任何NPC情况,那么RedLock、zk其实也都无能为力。
所以Redis作者认为:RedLock在大体一致的时钟基础上,是可以保证正确性的。
三.质疑fecing token机制
Redis作者对于Martin提出的fecing token机制,也提出了质疑。
质疑一:这个方案必须要求要操作的共享资源服务器有拒绝旧token的能力。例如要操作MySQL,从锁服务拿到一个递增数字的token。然后客户端要带着这个token去改MySQL的某一行,这就需要利用MySQL的事务隔离性来做。
//两个客户端必须利用事务和隔离性达到目的
//注意token的判断条件
UPDATE table T SET val = $new_val WHERE id = $id AND current_token < $token
但如果操作的不是MySQL呢?例如向磁盘上写一个文件或发起一个HTTP请求,那这个方案就无能为力。这对要操作的资源服务器,提出了更高的要求。也就是说,大部分要操作的资源服务器都是没有这种互斥能力的。再者,既然资源服务器都有了互斥能力,那还要分布式锁干什么?所以,Redis作者认为这个方案是站不住脚的。
质疑二:即使RedLock没有提供fecing token的能力,但已提供了唯一值。利用这个唯一值,也可以达到与fecing token同样的效果。
例如类似CAS的思路:
步骤一:客户端使用RedLock拿到锁
步骤二:在操作共享资源前,先把锁的value标记在要操作的共享资源上
步骤三:客户端处理业务逻辑
步骤四:在修改共享资源时,先判断标记是否与之前一样,一样才修改
还是以MySQL为例:
步骤一:客户端使用RedLock拿到锁
步骤二:客户端要修改某一行数据前,先把锁的value更新到这一行中
步骤三:客户端处理业务逻辑
步骤四:客户端修改MySQL这一行数据,把value当做where条件去修改
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value;
可见,这种方案依赖MySQL的事务机制,也达到fecing token的效果。
但这里还有个小问题:
两个客户端通过这种方案,先"标记"再"检查+修改"共享资源,那这两个客户端的操作顺序就无法保证。而用Martin提到的fecing token,因为这个token是单调递增的数字,资源服务器可以拒绝小的token请求,保证了操作的顺序性。
Redis作者对这问题做了不同的解释:
分布式锁的本质是为了互斥,只要能保证两个客户端在并发时,一个成功,一个失败就好了。分布式锁不需要关心顺序性,而前面Martin的质疑中,一直很关心这个顺序性问题。
四.总结Redis作者的结论
结论一:同意Martin提出时钟跳跃对RedLock的影响,但Redis作者认为时钟跳跃是可以避免的,取决于基础设施和运维。
结论二:RedLock在设计时,充分考虑了NPC问题。在RedLock步骤三获取锁之前发生NPC,可以保证锁的正确性。但在步骤三获取锁之后发生NPC,不止是RedLock有问题。其它分布式锁服务同样也有问题,所以不在讨论范畴内。
(6)基于zk的分布式锁是否安全
基于zk实现的分布式锁是这样的:
步骤一:客户端1和2都尝试创建临时节点
步骤二:假设客户端1先到达完成创建,那么客户端1加锁成功,客户端2加锁失败
步骤三:客户端1操作共享资源
步骤四:客户端1删除临时节点,释放锁
可见zk不像Redis那样,需要考虑锁的过期时间问题。因为zk采用临时节点,客户端拿到锁后只要连接不断,就可以一直持有锁。而且如果客户端异常崩溃,那么临时节点会自动删除,保证锁会被释放。
zk没有锁过期的烦恼,还能在异常时自动释放锁,但还是不完美。客户端创建临时节点后,zk是如何保证让这个客户端一直持有锁呢?客户端会与zk维护一个Session,这个Session依赖心跳检测来维持连接。如果zk长时间收不到客户端心跳,就认为Session过期,会删除临时节点。
GC问题对zk分布式锁的影响场景如下:
时间点一:客户端1创建临时节点成功,拿到了锁
时间点二:客户端1发生长时间GC
时间点三:客户端1无法给zk发送心跳,于是zk把临时节点进行删除
时间点四:客户端2创建临时节点成功,拿到了锁
时间点五:客户端1结束GC,仍然认为自己持有锁,于是发生了冲突
可见,即使使用zk也无法保证进程GC、网络延迟异常场景下的安全性。这就是Redis作者在反驳中提到的:如果客户端已经拿到了锁,但客户端与锁服务器发生失联(例如GC),那不仅RedLock有问题,其它锁服务都有类似的问题,zk也是一样。
所以可以得出结论:
一个分布式锁,在极端情况下,不一定是安全的。如果业务数据非常敏感,在使用分布式锁时,一定要注意这个问题,我们不能假设分布式锁100%安全。
(7)是否要用RedLock
RedLock只有建立在时钟正确的前提下,才能正常工作。如果可以保证时钟正确这个前提,那么可以使用RedLock。
但保证时钟正确,并不是那么简单就能做到的。
第一:从硬件角度来说,时钟发生偏移是时有发生,无法避免。例如CPU温度、机器负载、芯片材料都是有可能导致时钟发生偏移的。
第二:从工作经历来说,就遇到过时钟错误、运维暴力修改时钟的情况。进而影响了系统的正确性,所以人为错误也是很难完全避免。
所以对RedLock的个人看法是:尽量不用RedLock,而且它的性能不如单机版Redis,部署成本也高,会优先考虑使用主从 + 哨兵的模式实现分布式锁。
(8)如何正确使用分布式锁
Martin提到了fecing token方案,虽然这种方案有很大局限性,但对保证正确性的场景,提供了一个非常好的解决思路。
所以可以把这两者结合起来使用:
一.使用分布式锁在服务上层完成互斥目的
虽然极端情况下锁会失效,但可以最大程度把并发请求阻挡在服务最上层,从而减轻操作资源层的压力。
二.但是对于要求数据绝对正确的业务,在资源层一定要做好兜底设计
设计思路可借鉴fecing token方案,两种思路结合,那么对于大多数业务场景,基本可以满足要求。