自顶向下Redis分布式锁-前篇

理论层面

什么情况下需要分布式锁?此处的分布式指的是什么?

简单介绍:此处的分布式锁,可以简单理解为服务部署在 >= 2台机器上,我们想要同一个时间,只有一个机器可以加锁成功,保证业务的成功。

分布式锁的要求是啥?

要保证多台机器获取到同一个锁,则锁名是一定的。暂定叫他:myLock

自顶向下Redis分布式锁-前篇_第1张图片

锁名确定了之后,机器A-服务A机器B-服务A机器C-服务C,只有一个可以获取到锁。大功告成。

考虑,上面三个服务在执行过程中,有可能正常结束,有可能抛出异常,所以我们需要保证 能在程序正常和异常结束情况下,都要释放锁,保证其他服务可以使用

能在程序正常和异常结束情况下,都要释放锁,保证其他服务可以使用,怎么做?

代码层面

使用语言特性,保证代码执行结束会自动释放锁

public void execute() {
    // 加锁
    redis.lock()
    try {
        doSomething();
        // ...省略其他代码
    } finally {
        // 释放锁
        redis.unLock();
    }
}

异常情况

服务卡住、机器宕机

假设机器A-服务A获取到了锁,但是在执行到下图位置时,服务或者机器挂掉了,此时,锁就无法被释放了。其他服务无法获取该锁。

自顶向下Redis分布式锁-前篇_第2张图片

此时,可以为锁设置超时时间,在一定时间过后,锁自动删除,保证在 机器宕机情况下,锁也会在到期后自动释放,其他服务就有机会获取锁,继续执行服务了

服务执行时间超过设置的锁超时时间

假设我们引入了超时时间机制,超时时间设置多少,就显得比较重要。来看下图:

自顶向下Redis分布式锁-前篇_第3张图片

聪明的你一定看出了一点问题吧。

A执行时间超过了我们预期的10分钟,导致了一系列连锁反应。

  • 释放了B的锁
  • 导致C加锁成功

至于A为何释放锁,还要归功于我们上面的代码:

自顶向下Redis分布式锁-前篇_第4张图片

要解决上述问题,问题在于超时时间。

超时时间不太好定,这次定为12分钟,下次执行了15分钟,20分钟…之后。你也觉得每次手动修改超时时间很奇怪对吧。

  • 那我设置超时时间1天可以吗?

    • 可以,但是那样的话,在发生异常的情况下,其他服务就要等待一天,锁才会过期,其他服务才能继续执行
    • 自顶向下Redis分布式锁-前篇_第5张图片
  • 正常情况下服务执行完毕后,会自动释放锁,异常情况下,有可能释放不了锁,那我能不能监测到服务故障了,直接释放锁呢?

    • 可以其他机器监测服务故障后释放锁
    • 也可以在本机器启动额外线程来不断给锁延续超时时间,这样服务挂了,就不会继续延续超时时间了,redisson采用的是这种设计
锁名称保证唯一,不会随便释放其他服务的锁

锁被其他人释放有两种场景:

  • 同一机器上的两个线程

  • 不同机器上的两个服务

解决方式:

添加额外信息,说这个锁属于谁,就不会比别人释放了。如下图

额外信息:属于哪个机器,哪个线程。例如:机器A-线程1号

自顶向下Redis分布式锁-前篇_第6张图片

不断续期解决执行时间超过锁超时时间问题

我们采用等待服务执行完毕后再释放的策略【ps,并非唯一解决方式,此方式只是解决方法中的一种可能】

在本机器启动额外线程来不断给锁延续超时时间,这样服务挂了,就不会继续延续超时时间了,如下图

自顶向下Redis分布式锁-前篇_第7张图片

可重入锁

可重入锁,指的是,对一把锁多次加锁,计数加1,同样,释放对应次数的锁,才能最终把这个锁释放掉。

众所周知,Java的很多锁都是提供可重入功能的。其实可重入锁,就是指,可以重复获得一把锁。

这个设计其实让我很不理解,为什么要重复获取同一把锁呢?这不是很奇怪吗?

可能是有代码层面的考量,复用其他代码的考量。

但是根据 StackOverflow论坛Go语言开发团队 的一个大佬,观点是可重入锁是会导致BUG的一大来源。

Go语言开发团队大佬原文

Stack overflow论坛说明

不过我还是在这里提一下,什么情况下使用,如何实现。

根据 知乎该链接 可以知道,在如下的情况下,可能会使用到可重入锁,调用子类的 doSomething 方法,会用到本对象来当锁来加锁,子类的doSomething方法又调用了 父类的doSomething方法,还是把本对象当锁来加锁,这样,就会重复加锁,如果不使用可重入锁,就会导致子类一直不能获取到父类的锁,卡主,导致死锁。

public class Person{
    public synchronized void doSomething(){
        ........
    }
}

public class Student extends Person{
    public synchronized void doSomething(){
        super.doSomething();
    }
}

所以,我们可以在实现分布式锁时,再加一个 使用当前锁的次数来记录加锁了多少次,来保证可以重复对一把锁加锁。

实战层面

理论的结论

要实现一个完整的,功能众多的分布式锁,满足如下条件

  • 锁名是同一个
  • 锁设置超时时间
  • 假设超时时间为10s,防止当前服务A执行15秒后,自动释放其他服务加的锁
  • 启动额外线程来不断给锁延续超时时间
  • 可重入

代码

下面为了简化,我们先用伪代码描述流程,随后给出对应的实际代码

伪代码

锁名一定

public class RedisHelper { 
    public void lock(String lockName) {
        // 直接加锁
        redis.lock(lockName)
    }
    
    public void unlock(String lockName) {
        redis.unlock(lockName)
    }
}

锁设置超时时间

public class RedisHelper { 
    public void lock(String lockName, long expiredTime) {
        // 直接加锁
        redis.lock(lockName)
            // 设置过期时间为x秒
            .expireTime(expiredTime, "Seconds")
    }
    
    public void unlock(String lockName) {
        redis.unlock(lockName)
    }
}

防止释放其他服务的锁

public class RedisHelper {
    // 为当前机器生成一个随机不重复的id
    String ID = UUID.randomId();
    
    public void lock(String lockName, long expiredTime) {
        // 直接加锁
        redis.lock(lockName)
            // 设置过期时间为x秒
            .expireTime(expiredTime, "Seconds")
            // 属于当前线程
            .belongs(ID + currentThread.getId())
    }
    
    public void unlock(String lockName) {
        redis.unlock(lockName)
    }
}

由于篇幅已经够长了,所以,可重入锁和看门狗的实现,会放到下次博客。

Redis简要说明

redis命令默认是串行单线程执行的。

当我们判断锁是否是某个服务的锁时,会涉及多条命令,如下:

if 锁存在,且锁是A的锁:
	删除该锁

如果读者了解 多线程 的观测失效,或者 竞态条件,可以知道,在多线程环境下,是有可能存在判断失效的情况的,如下:

也就是上述代码的第一句话判断完了之后,在执行第二句话之前,图中红色部分被执行了。

那么,就删除错锁了。

自顶向下Redis分布式锁-前篇_第8张图片

如果想要一次性执行多条命令。可以将其放到一个 lua 脚本中,redis 会将其中的全部命令当做一次命令来执行。

实际Java代码示例

借助 SpringredisTemplate 实现。

其中"""三引号之中的内容属于 Java17 语法,可以使用 Java8 的拼接字符串等来替换。

@Component
public class RedisHelper {

    private final RedisTemplate<String, String> redisTemplate;

    public static final String UNLOCK_OK = "OK";

    public static final String UUID = java.util.UUID.randomUUID().toString();

    public RedisHelper(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
    * 加锁,且设置锁过期时间,将锁的归属信息当做value写入
    */
    public boolean tryLock(String key, Long timeValue, TimeUnit timeUnit) {
        
        return Boolean.TRUE.equals(redisTemplate.opsForValue()
                .setIfAbsent(key, lockValue(), timeValue, timeUnit));
    }

    /**
    * 解锁,解锁成功返回OK,失败返回该锁剩余的生存时间
    */
    public Res unLock(String key) {
        // 使用lua脚本
        RedisScript<String> script = RedisScript.of("""
                if (redis.call('get', KEYS[1]) == ARGV[1]) then
                    redis.call('del', KEYS[1]);
                    return 'OK';
                end;
                return tostring(redis.call('pttl', KEYS[1]));
                """, String.class);
        String result = redisTemplate.execute(script, List.of(key), lockValue());

        return new Res(result);
    }

    /**
    * 用于包装结果对象,告诉调用者是否解锁成功
    */
    @Data
    public static class Res {
        private final String value;

        public Res(String value) {
            this.value = value;
        }

        public boolean isOk() {
            return UNLOCK_OK.equals(value);
        }

        public boolean isFailed() {
            return !isOk();
        }
    }
    
    // 锁的归属信息                                                
    String lockValue() {
        return UUID + ":" + Thread.currentThread().getId();
    }
}

你可能感兴趣的:(redis,分布式,缓存)