赵志浩
Published on 2023-06-11 / 34 Visits
1
0

分布式锁

  • 分布式锁🔒的使用场景

    • 解决方案:

  • 分布式锁🔒

    • Redis🔒

      • 解决死锁问题

      • 解决超时问题

      • 解锁问题

分布式锁🔒的使用场景

1、select num from A where id = 1;

2、newNum = num + 3;

3、update set num = newNum where id =1;

以上代码在非并发场景执行时,逻辑如下:

Thread1 线程执行上述代码

1、查询数据库,得到 num 值等于 0

2、计算 newNum = 3

3、更新 newNum 的值到数据库,此时 id=1 的数据所对应的 num 值是 3.

Thread2 线程执行上述代码

1、查询数据库得到 num 值等于 3

2、计算 newNum = 6

3、更新 newNum 的值到数据库中,此时 Id=1 的数据 num 值是 6.

在两个线程并发执行的场景时,逻辑如下:

Thread1 和 Thread2 线程同时执行 1 步骤

1、两个线程同时查询数据库,得到 num 值等于 0

2、两个线程同时计算 newNum 值等于 3

3、两个线程同时进行 update 操作,最终 Id=1 的数据值是 3.

本身两个线程运行两次应该是更新为 6 的结果,最终因为并发执行的问题,更新到库里一个错误的值 3.

解决方案:

要解决上述的问题,我们有两种方式:

1、将 1,2,3 这三个执行步骤,变成只有单线程才能执行。也就是加一把锁,只有单个线程才能进来执行,在该线程执行完毕后,其他线程才能继续进来执行。此时就不存在对应的并发问题了。

2、上述三个代码块,直接变更为:update set num = num +3 where id =1; 也就是通过改变执行的 sql,借助于数据库自身的原子执行的特性,来解决该并发问题。

对于上述的问题,很显然直接使用 第二个 方式,是最佳的解决方案。

但是对于 “这类” 并发问题,不是所有的都可以借助于数据库上述的原子特性来解决,所以加锁的方案也是非常常用的一个解决方案。

分布式锁🔒

要想使用分布式锁,有很多种方案,比如:

1、借助于数据库(Mysql/Oracle)等的 for update 排他锁的方案

2、借助于 redis key 实现分布式锁的方案

3、借助于 zookeeper 创建节点的方式实现分布式锁的方案。

无论如何,我们可以看到,要想实现分布式锁,必须要借助于第三方工具(Mysql/Redis/ZK)等等

为什么一定要借助于第三方工具来实现:

简单来说,当你的多个应用节点都需要对某个共享资源进行访问时,此时想对该共享资源加锁时,则该锁必然是维护在外部。由外部的工具能力来控制该资源的加锁,释放,清理等动作。

而 Mysql/Redis/ZK/ETCD 等工具都提供了一致性,原子性,高可用等特性,所以在分布式的环境中依赖于这些第三方工具来实现锁的操作会容易很多。

Redis🔒

想要借助于 Redis 实现分布式锁,其实很简单,比如:

Redis 有一个 setnx 指令,该指令的作用是:设置指定的 key 不存在时,为 key 设置指定的值。设置成功,则返回 1 。 设置失败,返回 0 。

redis Setnx 命令基本语法如下:

redis 127.0.0.1:6379> SETNX KEY_NAME VALUE

那么借助于这个指令,我们就可以实现如下的分布式锁的代码:

1、    void business() {
        //设置redis 的 key,设置成功则返回 1,此时则开始执行我们的业务逻辑
2、        if (setnx(key, 1) == 1) {
            try {
3、                //TODO 业务逻辑
            } finally {
                //业务逻辑执行成功,则删除该 redis key
4、                del(key)
            }
        } else {
            //设置该 redis key,设置失败,则表示抢锁失败,此时则继续重试抢锁
            Thread.sleep(500)
5、            business();
        }
    }

上述的代码在正常情况下执行是没有任何问题的,但问题就在于真正的运行环境是存在多种可能的。

假设代码在执行第三步业务逻辑的时候,当前的应用进程被直接 kill -9 给 shutdown 了,那么第四步 del 的逻辑,就永远不会被执行了。

此时第 5 步的代码在执行 setnx 的时候,由于该 key 已经存在,则设置该 key 永远都是失败,则该锁永远都抢不成功,成为了一个死锁。

🤔 那如何解决该问题呢?🤔

我们在使用 setnx 指令设置该 key 成功以后,再次使用 redis 的 expire 指令,给该 key 设置一个超时时间,这样岂不是可以保证,应用程序中不主动删除该 key 时,也可以保证该 key 被自动清理?

方案似乎没什么问题,噼里啪啦代码如下:

1、    void business() {
        //设置redis 的 key,设置成功则返回 1,此时则开始执行我们的业务逻辑
2、        if (setnx(key, 1) == 1) {
            
            //获取到该锁后,则设置该 key 的过期时间为 1000 毫秒
3、          expire(key,1000);
            
            try {
4、                //TODO 业务逻辑
            } finally {
                //业务逻辑执行成功,则删除该 redis key
5、                del(key)
            }
        } else {
            //设置该 redis key,设置失败,则表示抢锁失败,此时则继续重试抢锁
            Thread.sleep(500)
6、            business();
        }
    }

解决死锁问题

那么此时我们再想一下,如果代码执行到第二步获取锁成功,准备执行第三步设置该 key 的过期时间时,此时该程序突然崩溃,或者被 kill -9 了,那么此时该 key 不还是永远都不会被删除,还是成为了死锁吗?

所以,如果我们要解决该问题,如何解决呢?

除非:setnx 设置 key 的这个命令和 expire 设置 key 有效期的命令,是一个原子操作。

即:要么 setnx 和 expire 同时执行成功,要么同时执行失败。而不允许 setnx 执行成功,而 expire 执行失败的情况发生。

很碰巧,Redis 刚好是支持Lua 解释器来执行 Lua 脚本。且Redis 还提供了对应的 Eval 命令,该命令支持传递一个 lua 脚本,由Redis来保证该脚本的原子性,脚本要么执行成功,要么执行失败,

Eval 命令语法如下:

redis 127.0.0.1:6379> EVAL script numkeys key [key ...] arg [arg ...] 

script: 参数是一段 Lua 5.1 脚本程序。脚本不必(也不应该)定义为一个 Lua 函数。

numkeys: 用于指定键名参数的个数。

key [key ...]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( 

KEYS[1] , KEYS[2] ,以此类推)。

arg [arg ...]: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。

所以我们此处只需要将 Redis 的setnx命令和 expire 命令,包装为对应的Lua 脚本,然后由Eval 来执行该脚本,即可实现,最终命令如下所示:

String nx_expire_lua = "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;";

1、    void business() {
            //设置redis 的 key,设置成功则返回 1,此时则开始执行我们的业务逻辑; 传递 lua script 脚本进行执行
2、        if (eval(nx_expire_lua,key,1000) == 1) {
            
            try {
3、                //TODO 业务逻辑
            } finally {
                //业务逻辑执行成功,则删除该 redis key
4、                del(key)
            }
        } else {
            //设置该 redis key,设置失败,则表示抢锁失败,此时则继续重试抢锁
            Thread.sleep(500)
5、            business();
        }
    }

解决超时问题

我们上述设置了这个 key 的过期时间是 1000 毫秒,但如果获取到锁后,该业务代码的执行时间超过了 1000 毫秒,那么该 key 已经超时并删除了,此时业务代码还没有执行完,此时则还是会有多个线程进来执行的并发问题。

如何解决该问题:

1、设置一个足够大的 key 过期时间,反正该业务代码执行完以后会执行 delete key 的逻辑。那么只要我们 key 的自动过期时间设置的过大,就能保证你的业务代码肯定能执行完以后自动清理该 key。

2、设置一个守护线程,检测该执行线程是否执行完成,如果没有执行完成,则给该 redis key 进行自动续期。

原创声明:作者:赵志浩、个人博客地址:https://zhaozhihao.com

原创声明:笔名:陈咬金、 博客园地址:https://www.cnblogs.com/zh94/

解锁问题

del(key)

del(key) 这个操作应该是由对应的加锁方进行解锁,而不应该是任何一个请求都可以触发del(key)的操作,所以我们需要在加锁的时候,传递一个 请求 ID 存放到对应key的value当中,然后删除该 key 的时候也需要校验下,该 key 所对应的 value 是否和传递进来的 value 一致,一致则删除,不一致则不删除该 key。

所以最终我们可以得到如下一个工具类:其中clientId直接使用 UUID 创建即可。

下面代码中可以看到,tryLock()方法中并没有使用 eval() 指令,而是在解锁的时候releaseLock()使用了 eval()指令。

难道说,tryLock()的时候不需要使用 lua 脚本保证一致性吗?不是的。

而是因为在 Jedis 的高版本当中,本身就提供了 set()命令,该命令可以支持setnx 和 expire 的原子执行,所以我们直接依赖于 jedis 的 set 指令即可。

而在解锁的时候,由于需要判断该 redis key 的 value 是否和 clientId 一致,所以还是需要用到 eval() 指令,传递一个 lua 脚本。

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>


public class RedisLock {
    private static final Long RELEASE_SUCCESS = 1L;
    private static final String LOCK_SUCCESS = "OK";
    //NX:NX-key不存在时进行保存,XX:XX-key存在时才进行保存
    private static final String SET_IF_NOT_EXIST = "NX";
    //过期时间单位 (EX,PX),EX-秒,PX-毫秒
    private static final String SET_WITH_EXPIRE_TIME = "EX";
    private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
 
    private static final StringRedisTemplate REDIS_TEMPLATE;
 
    static {
        REDIS_TEMPLATE = ContextUtils.getBean(StringRedisTemplate.class);
    }
 
    /**
     * 该加锁方法仅针对单实例 Redis 可实现分布式加锁
     * 对于 Redis 集群则无法使用
     *
     * 支持重复,线程安全
     *
     * @param lockKey   加锁键
     * @param clientId  加锁客户端唯一标识(采用UUID)
     * @param seconds   锁过期时间
     * @return
     */
    public static Boolean tryLock(String lockKey, String clientId, long seconds) {
        return REDIS_TEMPLATE.execute((RedisCallback<Boolean>) redisConnection -> {
            Jedis jedis = (Jedis) redisConnection.getNativeConnection();
            String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
            if (LOCK_SUCCESS.equals(result)) {
                return Boolean.TRUE;
            }
            return Boolean.FALSE;
        });
    }
 
    /**
     * 与 tryLock 相对应,用作释放锁
     *
     * @param lockKey
     * @param clientId
     * @return
     */
    public static Boolean releaseLock(String lockKey, String clientId) {
        return REDIS_TEMPLATE.execute((RedisCallback<Boolean>) redisConnection -> {
            Jedis jedis = (Jedis) redisConnection.getNativeConnection();
            Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
                    Collections.singletonList(clientId));
            if (RELEASE_SUCCESS.equals(result)) {
                return Boolean.TRUE;
            }
            return Boolean.FALSE;
        });
    }
 
}

小米技术团队-分布式锁的实现之 redis 篇

Redis分布式锁的正确实现方式

什么是分布式锁

搞懂分布式锁,看这篇就够了

StringRedisTemplate实现分布式锁

原创声明:作者:赵志浩、个人博客地址:https://zhaozhihao.com

原创声明:笔名:陈咬金、 博客园地址:https://www.cnblogs.com/zh94/


Comment