手撸Redis分布式锁(8个版本的渐进式源码实践解读)_redislock哪个版本才有

前言

分布式锁相对应的是本地锁,像我们熟悉的synchronized和ReentrantLock都是本地锁,本地锁是作用于JVM内部,单个进程内的操作共享资源互斥。而现在主流都是分布式和微服务架构,会部署多个服务(多个JVM),为此分布式锁也就应运而生了。
分布式锁主流实现有3种:基于Redis、Zookeeper或Mysql等数据库。
Redis实现分布式锁使用得非常广泛,也是面试的重要考点之一,很多同学都知道这个知识,也大致知道分布式锁的原理,但是具体到细节的掌握上,往往并不完全正确。所以下面就让我们手写Redis分布式锁,以版本迭代的方式,渐进式的解读遇到的问题和对应的解决方案,帮你彻底理解Reids分布式锁。
在这里插入图片描述


一、v1 初出茅庐

setnx (SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。

两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。

基本语法:setnx key value 例如两个进程同时加锁,只能成功1个:
在这里插入图片描述
在这里插入图片描述
释放锁,直接使用 DEL 命令删除这个 key 即可:
在这里插入图片描述
基于这个思路,使用RedisTemplate我写下了v1版本的实现代码:
在这里插入图片描述

模拟减库存调用如下:

在这里插入图片描述


二、v2 小心死锁

1. 业务逻辑异常导致死锁

解决方案:当lock成功后,对执行的业务逻辑加try finally,保证即使业务逻辑异常,也可以unlock。
在这里插入图片描述

2. 服务宕机导致死锁

在执行业务逻辑时,虽然我们加了try finally,但假设获得锁的服务宕机,还没有来的及解锁,那么这个锁将一直被占有,其它客户端也将永远拿不到这个锁了。
解决方案设置过期时间,服务宕机的话key也会在指定时间内自动过期,不会永远占有锁。

我们修改lock方法,对key加上expire 10s:

public boolean lock() {
    // 等价于原生命令 setnx key 1
    Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(key, "1");
    boolean res = Boolean.TRUE.equals(ok);
    if (res) {
        // 等价于原生命令 expire key 10
        stringRedisTemplate.expire(key, 10, TimeUnit.SECONDS);
    }
    return res;
}


三、v3 彻底搞定死锁

v2的思路是对的,但是这里还有个小问题,如果setnx成功但expire失败呢? 依然会有死锁的可能,这个问题的根源在于setnx和expire是两条指令而不是原子指令,所以解决原子性问题我们可以采用lua脚本,详见我写的 Redis使用Lua脚本:保证原子性【项目案例分享】
另外,在Redis2.8版本中,作者加入了set指令的扩展参数ex nx,使得setnx和expire指令可以一起执行:

基本语法: set key value ex secords nx 例如:
在这里插入图片描述
我们修改lock方法如下:

public boolean lock() {
    // 等价于原生命令 set key 1 ex 10 nx
    Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(ok);
}

这样就彻底解决了死锁问题💪💪💪


四、v4 解铃还需系铃人

上面代码里的unlock方法只是单纯的删除key,这样的代码是不安全的,如果占有锁的线程还在执行业务逻辑,这时被未上锁的线程调用了unlock释放了锁,可想而知,那么这时其它线程就又可以抢锁成功了,结果就不正确了,所以我们要确保解锁还需上锁线程
解决方案:对每个锁对象生成唯一的id,加锁时保存在value中,解锁时需要判断value=id再解锁。

我们修改代码如下:
在这里插入图片描述


五、v5 解锁 - 原子性

其实unlock还是会有一点小问题的,因为unlock时GET + DEL 是两条指令,又会遇到我们前面讲的原子性问题了。假设持有锁的进程在del之前锁过期自动释放了,这时马上被其它线程加锁了,那么此时再删除就会把别人刚加的锁删除了
我们修改成执行lua脚本方式:

public void unlock() {
    String script = "if (redis.call('get', KEYS[1]) == ARGV[1] ) then " +
            "redis.call('del', KEYS[1]); " +
            "return 1; " +
            "end;" +
            "return 0;";
    executeLua(script, Long.class, Collections.singletonList(key), id);
}

private <T> T executeLua(String script, Class<T> resultType, List<String> keys, Object... args) {
    RedisScript<T> redisScript = new DefaultRedisScript<>(script, resultType);
    RedisSerializer<String> argsSerializer = new StringRedisSerializer();
    RedisSerializer<T> resultSerializer = new Jackson2JsonRedisSerializer<>(resultType);
    return (T) stringRedisTemplate.execute(redisScript, argsSerializer, resultSerializer, keys, args);
}

至此,解锁的流程就更严谨 💪💪💪


六、v6 可重入性

接下来,我们做一个完善:可重入。
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如synchronized和ReentrantLock就是可重入锁。
对于可重入性的解决方案加锁时,增加锁的持有计数; 释放锁时,减少锁的持有计数。
对于锁的持有计数保存在哪?一般会保存在本地,或者Redis服务端。

  1. 本地使用ThreadLocal全局静态变量保存锁的持有计数
  2. Redis端将持有计数保存在value中

对于方案2是开源框架Redisson的RedissonLock的解决方案,但是可重入性是在本地就能解决的,所以为了更好的性能我们采用方案1。

增加一个全局静态变量Map

// 可重入map:key= this.key+threadId; value=重入次数
private final static ConcurrentHashMap<String, AtomicInteger> refMap = new ConcurrentHashMap<>();

加锁
加锁前调用isShouldAcquire判断是否应该抢锁,加锁成功将持有计数放入refMap
在这里插入图片描述

isShouldAcquire方法如下:加锁时如果发现该线程已持有锁,refCount++
在这里插入图片描述
解锁:
解锁前调用isShouldUnlock判断是否应该释放锁
在这里插入图片描述
isShouldUnlock方法如下:解锁时如果发现该线程已持有锁,refCount--,减1后如果refCount仍大于0,说明是仍持有锁,不应该释放锁。如果需要释放锁,应从refMap中移除。
在这里插入图片描述

至此,我们手写的分布式锁就支持可重入 💪💪💪


七、v7 锁等待

我们抢锁往往不是一锤子买卖,需要不断重试抢锁,直到成功,所以我们来尝试写一个最简单的锁等待,方案就是使用循环 + Thread.sleep不断重试抢锁
在这里插入图片描述


八、v8 锁等待 - 优化

上面的锁等待,运行结果没问题,可能性能稍微差了一点,所以我们做一下优化。

优化方案:

  1. 使用Redis的订阅发布功能,锁等待的线程订阅channel,持有锁的线程释放锁时发布channel
  2. 使用Semaphore,当锁释放时,通知本进程的锁等待线程。其它进程的锁等待线程依然使用Thread.sleep。

对于方案1是开源框架Redisson的RedissonLock的解决方案,由于引入了订阅发布channel,提高了复杂了度,另外由于Redis发布订阅不能持久化,有可能丢失,所以权衡过后为了更稳定我们采用方案2

这里更改的点 较多,慢慢品味:
先增加一个Semaphore计数器,目的是当没有锁等待时,能将Semaphore释放。

static class SemaphoreCounter {
    private final Semaphore semaphore = new Semaphore(0);
    private final AtomicInteger acquireCount = new AtomicInteger(0);

    public void tryAcquire(long timeout, TimeUnit unit) throws InterruptedException {
        acquireCount.incrementAndGet();
        semaphore.tryAcquire(timeout, unit);
        acquireCount.decrementAndGet();
    }

    public int getAcquiredCount() {
        return acquireCount.get();
    }

    public void release() {
        semaphore.release();
    }
}

最后

无论是哪家公司,都很重视基础,大厂更加重视技术的深度和广度,面试是一个双向选择的过程,不要抱着畏惧的心态去面试,不利于自己的发挥。同时看中的应该不止薪资,还要看你是不是真的喜欢这家公司,是不是能真的得到锻炼。

针对以上面试技术点,我在这里也做一些分享,希望能更好的帮助到大家。

最后

无论是哪家公司,都很重视基础,大厂更加重视技术的深度和广度,面试是一个双向选择的过程,不要抱着畏惧的心态去面试,不利于自己的发挥。同时看中的应该不止薪资,还要看你是不是真的喜欢这家公司,是不是能真的得到锻炼。

针对以上面试技术点,我在这里也做一些分享,希望能更好的帮助到大家。

[外链图片转存中…(img-FmOIV7cV-1714712165506)]

[外链图片转存中…(img-oBSmXwuA-1714712165507)]

[外链图片转存中…(img-THQwcE3Y-1714712165507)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值