温故知新-分布式锁的实现原理和存在的问题


摘要

本分旨在快速理解分布锁的实现原理,以及不同实现方式存在的问题,阅读此文需要对mysql、zk、redis有一定的了解。

在Java中synchronized关键字和ReentrantLock可重入锁在我们的代码中是经常见的,一般我们用其在多线程环境中控制对资源的并发访问,但是随着分布式的快速发展,本地的加锁往往不能满足我们的需要,在我们的分布式环境中上面加锁的方法就会失去作用。于是人们为了在分布式环境中也能实现本地锁的效果,也是纷纷各出其招,今天让我们来聊一聊一般分布式锁实现的套路。

分布式锁的特点

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

分布式锁的实现方式

  • MySql
  • zk
  • Redis

MySql

Mysql分布式锁的实现原理很简单,也很容实现,创建一个表,当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。这种方式实现问题也非常明显。

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

zookeeper

  • 方式一:zk 分布式锁,其实可以做的比较简单,就是某个节点尝试创建临时 znode,此时创建成功了就获取了这个锁;这个时候别的客户端来创建锁会失败,只能注册个监听器监听这个锁。释放锁就是删除这个 znode,一旦释放掉就会通知客户端,然后有一个等待着的客户端就可以再次重新加锁。

  • 方式二:创建临时顺序节点,如果有一把锁,被多个人给竞争,此时多个人会排队,第一个拿到锁的人会执行,然后释放锁;后面的每个人都会去监听排在自己前面的那个人创建的 node 上,一旦某个人释放了锁,排在自己后面的人就会被 zookeeper 给通知,一旦被通知了之后,就 ok 了,自己就获取到了锁,就可以执行代码了,如图所示

    • image-20200614204639719

存在问题

对比:在高并发场景下,方式一需要通知很多个监听,此时会引起羊群效应;所以一般推荐第二种方式;但是第二种方式也并非完美无缺,如上图所示,如果发生脑裂等网路异常情况,导致clinet1生成的临时节点被删除、此时client2获得了锁,但此时clinet1并未执行完毕,此时就会引发问题。

redis

redis 最普通的分布式锁

第一个最普通的实现方式,就是在 redis 里使用 setnx 命令创建一个 key,这样就算加锁。

SET resource_name random_value NX PX 30000

执行这个命令就 ok。

  • NX:表示只有 key 不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回 nil
  • PX 30000:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了。

释放锁就是删除 key ,但是一般可以用 lua 脚本删除,判断 value 一样才删除:

-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

为啥要用 random_value 随机值呢?因为如果某个客户端获取到了锁,但是阻塞了很长时间才执行完,比如说超过了 30s,此时可能已经自动释放锁了,此时可能别的客户端已经获取到了这个锁,要是你这个时候直接删除 key 的话会有问题,所以得用随机值加上面的 lua 脚本来释放锁。这个随机数一般会存在ThreadLocal里面;

private ThreadLocal lockValue = new ThreadLocal<>();

存在问题

  • 但是这样是肯定不行的。因为如果是普通的 redis 单实例,那就是单点故障。或者是 redis 普通主从,那 redis 主从异步复制,如果主节点挂了(key 就没有了),key 还没同步到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。

RedLock 算法

这个场景是假设有一个 redis cluster,有 5 个 redis master 实例。然后执行如下步骤获取一把锁:

  1. 获取当前时间戳,单位是毫秒;
  2. 跟上面类似,轮流尝试在每个 master 节点上创建锁,过期时间较短,一般就几十毫秒;
  3. 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1
  4. 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
  5. 要是锁建立失败了,那么就依次之前建立过的锁删除;
  6. 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁

Redis 官方给出了以上两种基于 Redis 实现分布式锁的方法,详细说明可以查看://redis.io/topics/distlock

RedLock的算法问题依然存在

  • 如果加锁过程中发生了GC,那么还是存在问题;

实际使用

在spring中,我们一般情况会中将锁封装为注解,DistributedLock,通过APO的@Around的方法做增强,我们可以基于RedisTemplate实现自己锁的逻辑,也可以使用RedissonClient(对分布式相关支持比较好的redis客户端);

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DistributedLock {
    /**
     * 锁的资源前缀,可以写入方法名称
     */
    String prefix() default "";
    /**
     * 锁的资源,redis的key,使用"#"开头,可以取参数值
     */
    String value() default "default";
    /**
     * 锁的有效时间  单位ms (默认6秒)
     */
    int expireTime() default 6000;
    /**
     * 请求锁的超时时间 ms (默认1秒)
     */
    int timeOut() default 1000;
}

总结

分布式锁的实现有很多种,网上也非常齐全,具体代码实现找一下就好了,不管是mysql、zk、redis多多少少都是存在问题的;

  • redis 分布式锁,其实需要自己不断去尝试获取锁,CPU的资源消耗较多。
  • zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。

我们出于redis的高性能考虑,采用了redis实现了分布式锁!

参考

ZooKeeper 的羊群效应

再有人问你分布式锁,这篇文章扔给他

zookeeper 的容错与脑裂问题


你的鼓励也是我创作的动力

打赏地址

Tags: