写点什么

分布式锁—原理算法和使用建议

  • 2025-03-03
    福建
  • 本文字数:19875 字

    阅读完需:约 65 分钟

1.Redis 分布式锁的 8 大问题


(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的值加上增量incrementString 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 算法的两个前提


一.不再需要部署从库和哨兵节点,只部署主库

二.主库要部署多个,官方推荐至少 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 个论点:


一.分布式锁的目的是什么


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 个:


一.解释时钟问题


首先 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 方案,两种思路结合,那么对于大多数业务场景,基本可以满足要求。


3.Redis 和 zk 的分布式锁实现原理对比


问题汇总:


一般实现分布式锁都有哪些方式?使用 Redis 如何设计分布式锁,使用 zk 来设计分布式锁可以吗?这两种分布式锁的实现方式哪种效率比较高?

 

(1)Redis 分布式锁的简单实现


Redis 分布式锁使用 RedLock 算法,是 Redis 官方支持的分布式锁算法,这个分布式锁有 3 个考量点:

考量点一:互斥,只能有一个客户端获取锁

考量点二:不能产生死锁

考量点三:容错,允许其中的节点出现故障而不影响分布式锁功能

 

一.获取锁


最普通的实现方式就是在 Redis 里创建一个 key。如果创建成功,那么就认为获取锁成功。如果创建失败,即发现已经有该 key 了,则说明获取锁失败。

 

通常使用如下这样的命令进行创建:


/** 说明一:"my:lock"就是锁的名称,对应于Redis的key *//** 说明二:Redis中这个名为"my:lock"的key对应的值必须是一个随机值 *//** 说明三:NX的意思是只有key不存在的时候才会设置成功 *//** 说明四:"PX 30000"的意思是30秒后锁自动释放 */SET my:lock 随机值 NX PX 30000
复制代码


二.释放锁


释放锁就是删除 key,但是一般用 lua 脚本进行删除,并且是判断随机 value 一样才进行删除。


//删除key的lua脚本String script ="if redis.call('get', KEYS[1]) == ARGV[1] then" +    "return redis.call('del', KEYS[1]);" +"else" +    "return 0;" +"end;";
复制代码


为什么必须要用随机值:


因为如果某个客户端获取到了锁,但阻塞了很长时间才执行完。此时可能由于过期时间已到而自动释放了锁,而别的客户端又刚好获取到锁,这个时候如果该客户端直接删除 key 那么就会有问题。所以才必须先对 key 设置随机值,然后再按 key + 随机值来删除 key 释放锁。


三.这套 Redis 获取锁 + 释放锁的方案存在的问题


问题一:单点故障。如果 Redis 服务是普通的单实例,那么就会有单点故障的风险。


问题二:破坏互斥。如果 Redis 服务是普通主从架构,Redis 进行的是主从异步复制。那么当 Redis 主节点挂了,key 还没同步到从节点时,一旦从节点切换为主节点,其他系统的线程就会拿到锁。

 

(2)Redis 官方的 RedLock 算法


RedLock 算法主要是通过过半机制来避免单节点故障导致的锁没同步问题,RedLock 算法在获取锁 + 释放锁的方案上基本和前面一致。但实际上这种 RedLock 算法也有很多问题,不是很完美。

 

RedLock 算法获取一把锁的步骤:

步骤一:客户端先获取当前时间戳 T1。

步骤二:客户端依次向这 5 个节点发起加锁请求,且每个请求都会设置超时时间,也是使用"SET my:lock 随机值 NX PX 50"创建锁。超时时间是毫秒级的,要远小于锁的有效时间,而且一般是几十毫秒。如果某一个节点加锁失败,包括网络超时、锁被其它线程持有等各种情况,那么就立即向下一个 Redis 节点申请加锁。

步骤三:如果客户端从 3 个以上(过半)节点加锁成功,则再次获取当前时间戳 T2。如果 T2 - T1 < 锁的过期时间,则认为客户端加锁成功,否则加锁失败。

步骤四:如果加锁失败,要向全部节点发起释放锁的请求。如果加锁成功,则去操作共享资源,也是使用 lua 按照 key + 随机值进行删除。

 

(3)zk 分布式锁之排他锁的实现原理


一.获取锁


使用临时顺序节点来表示获取锁的请求,让创建出后缀数字最小的节点的客户端成功拿到锁。

 

步骤一:客户端调用 create()方法在"/exclusive_lock"节点下创建临时顺序节点。

步骤二:然后调用 getChildren()方法返回"/exclusive_lock"下的所有子节点,接着对这些子节点进行排序。

步骤三:排序后,看看是否有后缀比自己小的节点。如果没有,则当前客户端便成功获取到排他锁。如果有,则调用 exist()方法对排在自己前面的那个节点注册 Watcher 监听。

步骤四:当客户端收到 Watcher 通知前面的节点不存在,则重复步骤二。

 

二.释放锁


如果获取锁的客户端宕机,那么客户端在 zk 上对应的临时节点就会被移除。如果获取锁的客户端执行完,会主动将自己创建的临时节点删除。

 

(4)zk 分布式锁之读写锁的实现原理


一.获取锁


步骤一:客户端调用 create()方法在"/shared_lock"节点下创建临时顺序节点。如果是读请求,那么就创建"/shared_lock/read001"的临时顺序节点。如果是写请求,那么就创建"/shared_lock/write002"的临时顺序节点。

 

步骤二:然后调用 getChildren()方法返回"/shared_lock"下的所有子节点,接着对这些子节点进行排序。

 

步骤三:对于读请求:如果排序后发现有比自己序号小的写请求子节点,则需要等待,且需要向比自己序号小的最后一个写请求子节点注册 Watcher 监听。

 

对于写请求:如果排序后发现自己不是序号最小的请求子节点,则需要等待,并且需要向比自己序号小的最后一个请求子节点注册 Watcher 监听。

 

注意:这里注册 Watcher 监听也是调用 exist()方法。此外,不满足上述条件则表示成功获取共享锁。

 

步骤四:如果客户端在等待过程中接收到 Watcher 通知,则重复步骤二。

 

二.释放锁


如果获取锁的客户端宕机,那么 zk 上的对应的临时顺序节点就会被移除。如果获取锁的客户端执行完,会主动将自己创建的临时顺序节点删除。

 

(5)zk 分布式锁的一个简单实现


一.分布式锁的实现步骤


步骤一:每个线程都通过"临时顺序节点 + zk.create()方法 + 添加回调"去创建节点。

 

步骤二:线程执行完创建临时顺序节点后,先通过 CountDownLatch.await()方法进行阻塞。然后在创建成功的回调中,通过 zk.getChildren()方法获取根目录并继续回调。

 

步骤三:某线程在获取根目录成功后的回调中,会对目录排序。排序后如果发现其创建的节点排第一,那么就执行 countDown()方法表示获取锁成功。排序后如果发现其创建的节点不是第一,那么就通过 zk.exists()方法监听前一节点。

 

步骤四:获取到锁的线程会通过 zk.delete()方法来删除其对应的节点实现释放锁,在等候获取锁的线程掉线时其对应的节点也会被删除。而一旦节点被删除,那些监听根目录的线程就会重新执行 zk.getChildren()方法,获取成功后其回调又会进行排序以及通过 zk.exists()方法监听前一节点。

 

二.WatchCallBack 对分布式锁的具体实现


public class WatchCallBack implements Watcher, AsyncCallback.StringCallback, AsyncCallback.Children2Callback, AsyncCallback.StatCallback {    ZooKeeper zk ;    String threadName;    CountDownLatch countDownLatch = new CountDownLatch(1);    String pathName;
public String getPathName() { return pathName; }
public void setPathName(String pathName) { this.pathName = pathName; }
public String getThreadName() { return threadName; }
public void setThreadName(String threadName) { this.threadName = threadName; }
public ZooKeeper getZk() { return zk; }
public void setZk(ZooKeeper zk) { this.zk = zk; }
public void tryLock() { try { System.out.println(threadName + " create...."); //创建一个临时的有序的节点 zk.create("/lock", threadName.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL, this, "abc"); countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } }
//当前线程释放锁, 删除节点 public void unLock() { try { zk.delete(pathName, -1); System.out.println(threadName + " over work...."); } catch (InterruptedException e) { e.printStackTrace(); } catch (KeeperException e) { e.printStackTrace(); } }
//上面zk.create()方法的回调 //创建临时顺序节点后的回调, 10个线程都能同时创建节点 //创建完后获取根目录下的子节点, 也就是这10个线程创建的节点列表, 这个不用watch了, 但获取成功后要执行回调 //这个回调就是每个线程用来执行节点排序, 看谁是第一就认为谁获得了锁 @Override public void processResult(int rc, String path, Object ctx, String name) { if (name != null ) { System.out.println(threadName + " create node : " + name ); setPathName(name); //一定能看到自己前边的, 所以这里的watch要是false zk.getChildren("/", false, this ,"sdf"); } }
//核心方法: 各个线程获取根目录下的节点时, 上面zk.getChildren("/", false, this ,"sdf")的回调 @Override public void processResult(int rc, String path, Object ctx, List<String> children, Stat stat) { //一定能看到自己前边的节点 System.out.println(threadName + "look locks..."); for (String child : children) { System.out.println(child); } //根目录下的节点排序 Collections.sort(children); //获取当前线程创建的节点在根目录中排第几 int i = children.indexOf(pathName.substring(1)); //是不是第一个, 如果是则说明抢锁成功; 如果不是, 则watch当前线程创建节点的前一个节点是否被删除(删除); if (i == 0) { System.out.println(threadName + " i am first..."); try { //这里的作用就是不让第一个线程获得锁释放锁跑得太快, 导致后面的线程还没建立完监听第一个节点就被删了 zk.setData("/", threadName.getBytes(), -1); countDownLatch.countDown(); } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } else { //9个没有获取到锁的线程都去调用zk.exists, 去监控各自自己前面的节点, 而没有去监听父节点 //如果各自前面的节点发生删除事件的时候才回调自己, 并关注被删除的事件(所以会执行process回调) zk.exists("/" + children.get(i-1), this, this, "sdf"); } }
//上面zk.exists()的监听 //监听的节点发生变化的Watcher事件监听 @Override public void process(WatchedEvent event) { //如果第一个获得锁的线程释放锁了, 那么其实只有第二个线程会收到回调事件 //如果不是第一个哥们某一个挂了, 也能造成他后边的收到这个通知, 从而让他后边那个去watch挂掉这个哥们前边的, 保持顺序 switch (event.getType()) { case None: break; case NodeCreated: break; case NodeDeleted: zk.getChildren("/", false, this ,"sdf"); break; case NodeDataChanged: break; case NodeChildrenChanged: break; } }
@Override public void processResult(int rc, String path, Object ctx, Stat stat) { //TODO }}
复制代码


三.分布式锁的测试类


package com.demo.zookeeper.lock;
import com.demo.zookeeper.config.ZKUtils;import org.apache.zookeeper.ZooKeeper;import org.junit.After;import org.junit.Before;import org.junit.Test;
public class TestLock { ZooKeeper zk;
@Before public void conn() { zk = ZKUtils.getZK(); }
@After public void close() { try { zk.close(); } catch (InterruptedException e) { e.printStackTrace(); } }
@Test public void lock() { //10个线程都去抢锁 for (int i = 0; i < 10; i++) { new Thread() { @Override public void run() { WatchCallBack watchCallBack = new WatchCallBack(); watchCallBack.setZk(zk); String threadName = Thread.currentThread().getName(); watchCallBack.setThreadName(threadName); //每一个线程去抢锁 watchCallBack.tryLock(); //抢到锁之后才能干活 System.out.println(threadName + " working..."); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //干完活释放锁 watchCallBack.unLock(); } }.start(); } while(true) {
} }}
复制代码


(6)Redis 分布式锁和 zk 分布式锁的对比


一.性能开销方面


对于 Redis 分布式锁,客户端需要不断去尝试获取锁,比较消耗性能。对于 zk 分布式锁,当客户端获取不到锁时,只需要注册一个监听器即可。所以 zk 分布式锁不需要不断主动地尝试获取锁,性能开销小。

 

二.异常释放锁方面


对于 Redis 分布式锁,如果获取到锁的客户端挂了,那么只能等过期时间后才能释放锁。对于 zk 分布式锁,获取锁时创建的是临时节点,即便客户端挂了,由于临时节点会被 zk 删除,所以会自动释放锁。

 

三.实现方面


Redis 分布式锁的 RedLock 算法比较麻烦,需要遍历上锁、计算时间等。而 zk 的分布式锁语义清晰实现简单,zk 的分布式锁比 Redis 的分布式锁牢靠、而且模型简单易用。

 

四.性能方面


Redis 和 zk 都是基于内存去通过写数据来创建锁的,但是因为 zk 有过半写机制,所以 Redis 能够承受更高的 QPS。

 

五.支持方面


Redisson 可以对分布式锁提供非常好的支持,zk 的 Curator 则没有这么好的支持。

 

4.Redis 分布式锁的问题以及使用建议


(1)Redis 分布式锁的问题


第一种方案:基于 Redis 单实例 + setnx 随机值 + lua 删除 key


如果出现 Redis 单点故障,会导致系统全盘崩溃,做不到高可用。除非是那种不太核心的小系统,随便用一下分布式锁,那么可以使用 Redis 单实例。

 

第二种方案:基于 Redis 主从架构 + 哨兵 + setnx 随机值 + lua 删除 key


Redis 主从+哨兵保证了高可用,Master 宕机,Slave 会接替。但是存在隐患,即在 Master 宕机的瞬间:如果刚创建的锁还没异步复制到 Slave,那么就会导致重复加锁的问题。虽然主从 + 哨兵保证了高可用,但锁的实现有漏洞,可能会导致系统异常。

 

第三种方案:使用 RedLock 算法


通过 twemproxy、Codis、Redis Cluster 可以实现 Redis 集群分片。面对 Redis 的多 Master 集群,此时使用的是 RedLock 算法,但不推荐,因为实现过程太复杂繁琐、很脆弱。因网络原因,难以实现多节点同时设置分布式锁,锁失效时间都会不一样。不同 Linux 机器的时间不同步 + 各种无法考虑到的问题,可能导致重复加锁。

 

举个例子:客户端 A 给 5 个 Redis Master 都设置了一个 key 上了一把锁,失效时间是 10s。因为网络等各种情况不同,各个 Master 对 key 的过期处理可能不同步。可能会出现:客户端 A 由于某些原因处理耗时特别长所以还没释放锁,然后过了大概 10 秒,其中 3 台机器的 key 都到期失效了。此时客户端 A 还没释放锁,而客户端 B 却发起设置请求,刚好成功加到锁。于是出现两个客户端同时持有锁。

 

RedLock 算法存在两个问题:


问题一:实现过程和步骤太复杂,上锁的过程和机制很重很复杂,导致很脆弱,各种意想不到的情况都可能发生。

问题二:网络原因和各服务器的时钟问题导致对 key 的过期处理并不同步,不够健壮,不一定能完全实现健壮的分布式锁的语义。

 

RedLock 算法的问题总结:


第一是太复杂

第二是不健壮可能重复加锁

 

Redis 分布式锁总结:


Redis 分布式锁实际上没有 100%完美的方案,或多或少有点问题。实际生产系统中,有时候用 zk 分布式锁,有时候也会用 Redis 分布式锁。

 

(2)Redis 分布式锁的优点


Redis 分布式锁有一个优点,就是拥有优秀的 Redis 客户端类库 Redisson。Redisson 封装了大量基于 Redis 的复杂操作,比如数据集合(Map、Set、List)的分布式的存储、各种复杂的分布式锁等。甚至基于 Redis+Redisson,就可以将 Redis 作为一个轻量级的 NoSQL 使用。

 

Redisson 对 Redis 分布式锁的支持非常友好,比如支持可重入锁、读写锁、公平锁、信号量、CountDownLatch 等。Redisson 支持很多种复杂的锁的语义,提供了各种分布式锁的高级支持,Redisson 这个客户端框架本身就有完整的一套 Redis 分布式锁实现。

 

(3)zk 分布式锁


zk 分布式锁的优点是锁模型健壮、稳定、简单、可用性高,zk 分布式锁的缺点是性能不如 Redis,而且部署和运维成本高。

 

Curator 客户端主要还是针对 zk 的一些基础语义进行封装。Curator 之于 zk,就类似于 Jedis 之于 Redis。Curator 也封装了多种不同的锁类型,比如可重入锁、读写锁、公平锁、信号量、CountDownLatch 等锁类型。

 

Redis 的 Jedis 可以和 Redisson 结合起来一起使用,因为 Jedis 封装了 Redis 的一些基础语义和操作。

 

(4)使用建议


目前行业里,基本都会有使用 Redis 或 zk 做分布式锁。Redis 分布式锁没有 100%完美和健壮的锁模型,或多或少会导致一些问题。如果业务场景能容忍这些问题,同时需要 Redission 的复杂的锁类型支持,那么可以使用 Redis 分布式锁。

 

zk 分布式锁有完美健壮的锁模型,但没有太好的开源类库支持复杂锁类型。如果业务场景要求锁的语义健壮稳定,不能出现多客户端同时加到一把锁,且对锁的功能没有特别需求,那么可以使用 zk 分布式锁。

 

(5)一点思考


设计架构为什么偏好用 zk 分布式锁,原因两个:

原因一是 Redis 分布式锁的算法模型有隐患,zk 分布式锁的机制更加健壮稳定。

原因二是 Redis 的本质是分布式缓存,zk 的本质是分布式协调服务。

 

此外,如下是一些关于 Redis 框架的发展看法:

 

Redis 现在越来越往队列、分布式锁、发布订阅等功能发展,有点本末倒置。Redis 框架应该回归它的本质去发展,它的本质是一个 kv 缓存。Redis 框架如果要发展,应该是纵向发展。比如支持磁盘存储、可以支持磁盘 + 内存的大规模海量数据的 kv 存储,而且 Redis 作为开源项目,最好提供一些便于集群管理和运维操作的功能。

 

比如提供便捷的可视化界面实现如下等功能来支持使用方高效管理:一键部署集群、集群上下线节点、集群数据迁移、集群数据备份、不同集群模式的一键转换(单实例模式->主从模式->哨兵模式->集群模式)、一键集群版本滚动升级、子集群模式与业务隔离、热 key 大 value 的自动发现与报警、集群资源的监控与报警、多机房集群部署容灾、集群访问量监控与扩容预警等。这些功能虽然 Cache Clound 有提供,但各大公司都要自己重复造轮子。

 

让人失望的是:Redis Cluster 刚出来时还不支持读写分离,其 Slave 只做高可用自动切换。而且运维极其繁琐,还有 RedLock 分布式锁算法本身也不健壮。此外还支持队列、支持发布订阅等。Redis 天然就不是为了分布式锁这种分布式系统的基础组件来设计的。zk 才是最适合做各种分布式系统的基础设施依赖的,而且业界基本各大开源项目,都依赖 zk 做各种分布式系统的基础设施。


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18744347

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
分布式锁—原理算法和使用建议_分布式_不在线第一只蜗牛_InfoQ写作社区