分布式锁简单总结

实现方式

  1. 数据库的行级排它锁(如select * from x for update);
  2. 基于zookeeper的瞬间顺序节点;
    • 最小节点获得锁
  3. 基于 Redis 的 SETNX 命令。
    • 使用lua脚本保证原子性 (Redisson 有封装实现 )
    • RedLock
      • 红锁并非是一个工具,而是Redis官方提出的一种分布式锁的算法。
      • RedLock作者指出,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。
      • 红锁算法认为,只要(N/2) + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。
      • 细说Redis分布式锁
      • Redisson 有封装实现

本文要讲的是第3种方式。

实现原理

  1. 使用setnx创建一个key,如果key不存在,则创建成功返回1,否则返回0。根据是否获得锁决定是否执行业务逻辑,执行完后删除key来实现释放锁。

    • SET resource_name my_random_value NX PX 30000
  2. 为了避免客户端挂了导致其他客户端无法获得锁的情况,为lock_key设置一个过期时间lock timeout

    • 一旦业务逻辑执行时间过长,租约到期,就会引发并发问题。
    • lock timeout 设置合适的时间,一般情况10s内
    • 相对而言,ZooKeeper版本的分布式锁没有这个问题
      • 锁的占用时间限制:redis就有占用时间限制,而ZooKeeper则没有,最主要的原因是redis目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而ZooKeeper通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。
      • 使用ZooKeeper可以主动通知客户端释放锁,Redis则不行
  3. 设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。

    • 释放锁lua脚本
      1
      2
      3
      4
      5
      if redis.call("get",KEYS[1]) == ARGV[1] then
      return redis.call("del",KEYS[1])
      else
      return 0
      end
  4. 可重入锁

    • https://juejin.cn/post/6961380552519712798
    • lua 脚本,需要存储 锁名称lockName、获得该锁的线程id和对应线程的进入次数count
    • 加锁 lock.lua
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
         -- 不存在该key时
      if (redis.call('exists', KEYS[1]) == 0) then
      -- 新增该锁并且hash中该线程id对应的count置1
      redis.call('hincrby', KEYS[1], ARGV[2], 1);
      -- 设置过期时间
      redis.call('pexpire', KEYS[1], ARGV[1]);
      return nil;
      end;

      -- 存在该key 并且 hash中线程id的key也存在
      if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
      -- 线程重入次数++
      redis.call('hincrby', KEYS[1], ARGV[2], 1);
      redis.call('pexpire', KEYS[1], ARGV[1]);
      return nil;
      end;
      return redis.call('pttl', KEYS[1]);
    • 解锁 unlock.lua
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
         -- 不存在key
      if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
      return nil;
      end;
      -- 计数器 -1
      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;
  5. 锁续约

    • 延长锁的releaseTime延迟释放锁来直到完成业务期望结果,这种不断延长锁过期时间来保证业务执行完成的操作就是锁续约。
  6. 基于单Redis节点的分布式锁无法解决的安全问题。

    • 假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。
    • Redlock算法
  7. 使用Redisson可以满足以上所有需求。

    • Redisson 实现分布式锁原理分析
    • Redisson没有设置一个随机值,也可以解决解锁误删的问题。因为Redisson在解决可重入时,已经定义了threadId进行重入计数,通过threadId就可以判断是否是自己之前加的锁。
    • 锁续期:leaseTime 必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
    • 锁等待:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
    • 缺点:Redis Cluster 或者说是 Redis Master-Slave 架构的主从异步复制导致的 Redis 分布式锁的最大缺陷(在 Redis Master 实例宕机的时候,可能导致多个客户端同时完成加锁)
    • 用法:使用Redisson实现分布式锁
  • 个人看法: 分布式锁并不是绝对可靠,只能尽量保证大多数时候可靠,业务应该自行保证一旦锁失效时的逻辑正确性。

Zookeeper和Redis分布式锁的比较

  1. 添加和删除,Reids性能较高
  2. Zookeeper有等待锁队列,大大提升抢锁效率;Redis需要考虑超时,原子性,误删等场景,客户端需要自旋等锁。
  3. 使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”。所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。
  4. ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
    如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
    但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。

扩展

  • 可以尝试使用Redisson实现分布式锁

  • Redis的作者antirez给出了一个更好的实现,称为Redlock,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上:
    https://redis.io/topics/distlock

  • 举个场景的例子来详细说明:一提到分布式锁问题,大多数人想到的方案是基于Redis的Master-Slave模式来实现。这个实现方案行不行?分布式锁本质是一个CP需求,基于Redis的实现是一个AP需求,乍一看基于Redis的实现是无法满足的。脱离业务场景来谈架构都是耍流氓。
    从技术战略的需求层面来看,如果分布式锁在极端情况下获取锁的不一致,社交业务场景能够接受,那么基于Redis的实现是完全可行的。如果业务是交易场景,分布式锁在极端情况下获取锁的不一致性无法接受,那么基于Redis的实现方案是不可行的。在锁强一致性的场景下,需要采取基于CP模型的etcd等方案来实现。

  • redis-cli提供了EVAL与EVALSHA命令执行Lua脚本:

    1. EVAL
      • EVAL script numkeys key [key …] arg [arg …]
      • key和arg两类参数用于向脚本传递数据, 他们的值可在脚本中使用KEYS和ARGV两个table访问: KEYS表示要操作的键名, ARGV表示非键名参数(并非强制).
    2. EVALSHA
    - EVALSHA命令允许通过脚本的SHA1来执行(节省带宽), Redis在执行EVAL/SCRIPT LOAD后会计算脚本SHA1缓存, EVALSHA根据SHA1取出缓存脚本执行.
    
  • redis一般都是单机房部署,如果要控制多个机房只有一个锁,考虑使用Consul来实现分布式锁。

  • Redis 分布式锁的 10 个坑

    1. 非原子操作(setnx + expire)
      • 如果刚要执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁了
    2. 被别的客户端请求覆盖( setnx + value为过期时间)
      • Getset 命令用于设置指定 key 的值,并返回 key 的旧值。
    3. 忘记设置过期时间
    4. 业务处理完,忘记释放锁
    5. B的锁被A给释放了
      - 假设在这样的并发场景下:A、B两个线程来尝试给Redis的keylockKey加锁,A线程先拿到锁(假如锁超时时间是3秒后过期)。如果线程A执行的业务逻辑很耗时,超过了3秒还是没有执行完。这时候,Redis会自动释放lockKey锁。刚好这时,线程B过来了,它就能抢到锁了,开始执行它的业务逻辑,恰好这时,线程A执行完逻辑,去释放锁的时候,它就把B的锁给释放掉了。
      - 正确的方式应该是,在用set扩展参数加锁时,放多一个这个线程请求的唯一标记,比如requestId,然后释放锁的时候,判断一下是不是刚刚的请求。
    6. 释放锁时,不是原子性
      • 因为判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用unlock(lockKey)释放锁的时候,锁已经过期,所以这把锁已经可能已经不属于当前客户端,会解除他人加的锁。
      • 判断和删除是两个操作,不是原子的,有一致性问题。释放锁必须保证原子性,可以使用Redis+Lua脚本来完成
    7. 锁过期释放,业务没执行完
      • 是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
      • 当前开源框架Redisson解决了这个问题: 只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程一还持有锁,那么就会不断的延长锁key的生存时间。
    8. Redis分布式锁和@transactional一起使用失效
      • 正确的实现方法,可以在updateDB方法之前就上锁,即还没有开事务之前就加锁,那么就可以保证线程的安全性.
    9. 锁可重入
      • 前面讨论的Redis分布式锁,都是不可重入的。
      • 不可重入的分布式锁的话,是可以满足绝大多数的业务场景。但是有时候一些业务场景,我们还是需要可重入的分布式锁
      • Redis只要解决这两个问题,就能实现重入锁了:
        • 怎么保存当前持有的线程
        • 怎么维护加锁次数(即重入了多少次)
      • 实现一个可重入的分布式锁,我们可以参考JDK的ReentrantLock的设计思想。实际上,可以直接使用Redisson框架,它是支持可重入锁的。
    10. Redis主从复制导致的坑
      • 如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
      • 为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
        • 搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
        • 简化下步骤就是:
          1. 按顺序向5个master节点请求加锁
          2. 根据设置的超时时间来判断,是不是要跳过该master节点。
          3. 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
          4. 如果获取锁失败,解锁!
    11. 个人意见:一般情况下(绝大多数),业务不要强依赖于redis做互斥逻辑

Reference

  1. 分布式锁的实现
  2. 使用Redis作为分布式锁的一些注意点
  3. 如何实现靠谱的分布式锁?
  4. 分布式锁用Redis还是Zookeeper?
  5. 基于Redis的分布式锁到底安全吗(上)?
  6. 80% 人不知道的 Redis 分布式锁的正确实现方式(Java 版)
  7. Redis结合Lua脚本实现高并发原子性操作
  8. 细说Redis分布式锁
  9. 分布式锁用 Redis 好,还是 ZooKeeper 好?
  10. 使用Redis实现分布式锁和ZK实现分布式锁有什么区别,分别有哪些场景?