Redis 分布式锁实现

为什么需要分布式锁

在聊为什么需要分布式锁的之前先来看看这么一个场景:
数据库创建设备记录时,设备编号不允许重复。开发工程师 A 每次创设备前先获取数据库记录是否存在设备编号,不存在则创建,存在则返回错误信息。有一天测试工程师 B 告诉 A ,他调用接口快速创建设备,出现了设备编号相同的记录。 为什么会出现这种情况,开发工程师 A 需要分析分析。

单体中多线程

线程 p1 从根据设备编号 DEVICENUMBER1 从数据库查找记录,发现不存在,继续执行创建设备的代码(这个逻辑比较长),在这期间,线程 p2 抢占到了 CPU 执行,也是使用设备编号 DEVICENUMBER1 从数据库查找记录,发现不存在,执行创建设备的代码进库。释放 CPU 之后,线程 p1 继续执行也进库。便出现了两条设备编号重复的数据。

分布式多进程

分布式服务 S1 从根据设备编号 DEVICENUMBER2 从数据库查找记录,发现不存在,继续执行创建设备的代码,由于这台服务资源相对较低,执行较慢,,在这期间,服务 S2 接收到创建请求,也是使用设备编号 DEVICENUMBER2 创建设备 从数据库查找记录,发现不存在,执行创建设备的代码。这样便出现了两条设备编号重复的数据。

解决方案

单体服务中,一个进程多线程,程序开发通常使用锁互斥以保证共享变量访问操作的正确性。但对于分布式来说是多个进程之间的资源共享,如何互斥呢?目前市面上的业务应用通常采用微服务架构,这也意味着一个应用会部署多个进程。此时必须借助一个外部系统,所有进程都去这个系统上申请加锁。而这个外部系统,必须要实现互斥能力,即两个请求同时进来,只会给一个进程加锁成功,另一个失败。

可以使用 Redis 提供的 Setnx 命令可以达到一定的效果。Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。

Setnx K V 加锁
redis 127.0.0.1:6379> SETNX KEY_NAME VALUE
redis> EXISTS job                # job 不存在
(integer) 0

redis> SETNX job "programmer"    # job 设置成功
(integer) 1

redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败
(integer) 0

redis> GET job                   # 没有被覆盖
"programmer"

因此在执行业务之前,可以提前调用 Setnx 指令,成功(Setnx key vale 返回 1)则代表获取到锁。其他获取不到锁(Setnx key vale 返回 0)的进程或者线程者进行直接返回。

public static void lock() {
    //1.获取客户端
    Jedis redis = getJedis();
    //2.获取锁
    Long lockResult = redis.setnx(key, value);
    if (1 == lockResult) {
        // 3.执行业务
        business();
        // 4.释放锁
        redis.del(key);
    } else {
        // 获取锁失败
        System.out.println("Can not get lock");
    }
}

不考虑异常情况下,这就是一个解决方案了。但是假如在客户端执行 redis.del 失败了(可能网络断开),那么这个锁就永远保存在 redis 服务 中了,其他进程或者线程将永远无法执行业务操作。针对这种情况,可以再提供一种补偿机制,在锁删除失败的情况下依然可以让锁消失。

SET K V EX TIME NX 设置缓存时间加锁
redis 127.0.0.1:6379> SETNX key value EX 100 NX

如果 key 不存在,则设置 key 的值为 VALUE,且缓存时间为 100 秒,100 秒过后消失。

127.0.0.1:6379>  SET key value EX 100 NX
OK
127.0.0.1:6379> GET key
"value"
127.0.0.1:6379>  SET key value1 EX 100 NX
(nil)
127.0.0.1:6379> ttl key
(integer) 76

那么以上的代码再修改一版如下:

public static void lock() {
    Jedis redis = getJedis();
    //修改为此指令
    String lockResult = redis.set(key, value, "NX", "EX", EXPIRE_SECS);
    if ("OK".equalsIgnoreCase(lockResult)) {
        business();
        redis.del(key);
    } else {
        System.out.println("Can not get lock");
    }
}

如此一来,对无法释放锁这种情况也有了相应的解决方案了。但是,在某一瞬间服务器资源紧缺,客户端 A 执行 business 过慢,超过缓存时间,Redis 删除了 key, 客户端 B 拿到了锁,恰好此时 客户端 A 执行完 business, 删除 key,那么此时删除的 key 显然是客户端 B 设置的 key。因此其他客户也可以拿到锁执行业务,显然这违背了互斥性。有两种解决方法:

1、设置超时时间远大于业务执行时间;
2、删除锁的时候增加判断锁是不是自己的,如果是再删除。

第1种方案存在较大缺陷,设置过期的时间过大,删除键失败则会出现其他进程或者线程长时间无法获取锁执行业务,显然不可接受。

那么尝试使用第2种,把上面的代码修改一版:

private static void lock(String lockKey, String lockValue) {
    Jedis redis = getJedis();
    String lockResult = redis.set(lockKey, lockValue, "NX", "EX", EXPIRE_SECS);
    if ("OK".equalsIgnoreCase(lockResult)) {
        executeBusiness();
        String presentValue = redis.get(lockKey);
        //判断是否是自己的,是自己的再删除
        if (lockValue.equalsIgnoreCase(presentValue)) {
            redis.del(lockKey);
            System.out.println("lock deleted");
        }
    } else {
        System.out.println("Can not get lock");
    }
}

从上面可以看到,获取当前客户端设置的值、判断是不是自己的锁、删除设置的值这不是原子性,如果在某一步发生阻塞,且在这期间客户端设置的值刚好过期,其他客户端便可以操作业务,也违背了互斥性。

那么如何保证这几步是原子性的呢,Redis提供了Lua脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令的原子性。

local keyName = redis.call('get',KEYS[1])

-- 比较线程标示与锁中的是否一直
if (ARGV[1] == keyName) then
   -- 删除锁
    redis.call('del',KEYS[1])
    return 1
else
    return 0
end

将此代码封装到代码中,再修改一版:

private static void lock(String lockKey, String lockValue) {
    Jedis redis = getJedis();
    String lockResult = redis.set(lockKey, lockValue, "NX", "EX", EXPIRE_SECS);
    if ("OK".equalsIgnoreCase(lockResult)) {
        executeBusiness();
        delLock(lockKey,lockValue);
    } else {
        System.out.println("Can not get lock");
    }
}

private static void delLock(String lockKey, String lockValue) { 
    String luaScript = "local keyName = redis.call('get',KEYS[1])\n" +
            "\n" +
            "if (keyName == ARGV[1]) then\n" +
            "    redis.call('del',KEYS[1])\n" +
            "    return 1\n" +
            "else\n" +
            "    return 0\n" +
            "end";

        //加载脚本
        String script = redis.scriptLoad(luaScript);
        Object delResult = redis.evalsha(script, Arrays.asList(lockKey), Arrays.asList(lockValue));
}

至此基本完成一把可用的分布式锁,看来实现分布式锁并不是简单的事情。为了确保分布式锁可用,需要确保锁的实现同时满足以下五个条件:

  • 互斥性:在任意时刻,只有一个客户端能持有锁;
  • 高可用:一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证其他客户端能加锁;
  • 安全性:加锁和解锁必须是同一个客户;
  • 高性能:加锁和解锁需要开销尽可能低;
  • 容错性:集群只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
当前分布式锁的缺陷

以上基于 setnx 可以满足大多数要求不高的系统。但是依然存在一些问题:

  • 不可重入:同一个线程无法多次获取同一把锁;
  • 不可重试:获取不到锁的线程直接返回了,没有重试机制;
  • 无法保证一致性:在集群环境下,master宕机且来不急主从同步,出现锁失效。

显然实现尽善尽美的分布式锁不简单,好在 Redisson 实现了以上对于分布式锁的要求。但这不妨碍此篇对分布式锁实现的思考,下篇就来分析 Redisson 对分布式锁的实现,看看 Redisson 如何保证这几大条件和解决以上的缺陷。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值