三种分布式锁的简单理解

1、数据库实现(效率低,不推荐)

2、redis实现(使用redission实现,但是需要考虑思索,释放问题。繁琐一些)

3、Zookeeper实现   (使用临时节点,效率高,失效时间可以控制)

 4、Spring Cloud 实现全局锁(内置的)

 

 

数据库的分布式锁

悲观锁

  • STEP1 - 获取锁:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
  • STEP2 - 执行业务逻辑。
  • STEP3 - 释放锁:COMMIT。

乐观锁

加版本号,每次读出版本号,进行操作时对比版本号

 

 

Zookeeper实现分布式锁原理

使用zookeeper创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控节点的变化,从剩下的节点的找到最小的序列节点,获取分布式锁,执行相应处理,依次类推……

 

 

redis分布式锁 

直接用SETNX和DEL实现加解锁。SETNX 是『SET if Not eXists』,如果不存在,才会设置

不过直接使用 SETNX 有一个缺陷,我们没办法对其设置过期时间,如果加锁客户端宕机了,这就导致这把锁获取不了。

不过这个问题在 Redis 2.6.12 版本 就可以被完美解决。这个版本增强了 SET 命令,可以通过带上 NX,EX 命令原子执行加锁操作,解决上述问题。参数含义如下:

  • EX second  :设置键的过期时间,单位为秒

  • NX 当键不存在时,进行设置操作,等同与 SETNX 操作

使用 SET 命令实现分布式锁只需要一行代码:

SET lock_name anystring NX EX lock_time

 

不过这种方式却存在一个缺陷,可能会发生错解锁问题。

假设应用 1 加锁成功,锁超时时间为 30s。由于应用 1 业务逻辑执行时间过长,30 s 之后,锁过期自动释放。

这时应用 2 接着加锁,加锁成功,执行业务逻辑。这个期间,应用 1 终于执行结束,使用 DEL 成功释放锁。

这样就导致了应用 1 错误释放应用 2 的锁,另外锁被释放之后,其他应用可能再次加锁成功,这就可能导致业务重复执行。

 

这时候就可以考虑乐观锁的版本号方法

为了使锁不被错误释放,我们需要在加锁时设置随机字符串,比如 UUID。 

SET lock_name uuid NX EX lock_time

释放锁时,需要提前获取当前锁存储的值,然后与加锁时的 uuid 做比较,伪代码如下:

var value= get lock_name
if value == uuid
 // 释放锁成功
else
 // 释放锁失败

但是以上代码我们不能通过 Java 代码运行,因为无法保证上述代码原子化执行。要用Lua脚本

lua 代码可以运行在 Redis 服务器的上下文中,并且整个操作将会被当成一个整体执行,中间不会被其他命令插入。 

Redis 可以使用 EVAL 执行 LUA 脚本,而我们可以在 LUA 脚本中执行判断求值逻辑。EVAL 执行方式如下: 

 

在 Lua 脚本可以使用下面两个函数执行 Redis 命令:

  • redis.call()

  • redis.pcall()

两个函数作用法与作用完全一致,只不过对于错误的处理方式不一致,感兴趣的小伙伴可以具体点击以下链接,查看错误处理一章。http://doc.redisfans.com/script/eval.html

EVAL 命令每次执行时都需要发送 Lua 脚本,但是 Redis 并不会每次都会重新编译脚本。

当 Redis 第一次收到 Lua 脚本时,首先将会对 Lua 脚本进行  sha1 获取签名值,然后内部将会对其缓存起来。后续执行时,直接通过 sha1 计算过后签名值查找已经编译过的脚本,加快执行速度。

虽然 Redis 内部已经优化执行的速度,但是每次都需要发送脚本,还是有网络传输的成本,如果脚本很大,这其中花在网络传输的时间就会相应的增加。

所以 Redis 又实现了 EVALSHA 命令,原理与 EVAL 一致。只不过 EVALSHA 只需要传入脚本经过 sha1计算过后的签名值即可,这样大大的减少了传输的字节大小,减少了网络耗时。

EVALSHA命令如下:

evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 楼下小黑哥

可以看到,如果之前未执行过 EVAL命令,直接执行 EVALSHA 将会报错。

 

//连接本地的 Redis 服务
Jedis jedis = new Jedis("localhost", 6379);
jedis.auth("1234qwer");

System.out.println("服务正在运行: " + jedis.ping());

String lua_script = "return redis.call('set',KEYS[1],ARGV[1])";
String lua_sha1 = DigestUtils.sha1DigestAsHex(lua_script);

try {
    Object evalsha = jedis.evalsha(lua_sha1, Lists.newArrayList("foo"), Lists.newArrayList("楼下小黑哥"));
} catch (Exception e) {
    Throwable current = e;
    while (current != null) {
        String exMessage = current.getMessage();
        // 包含 NOSCRIPT,代表该 lua 脚本从未被执行,需要先执行 eval 命令
        if (exMessage != null && exMessage.contains("NOSCRIPT")) {
            Object eval = jedis.eval(lua_script, Lists.newArrayList("foo"), Lists.newArrayList("楼下小黑哥"));
            break;
        }

    }
}
String foo = jedis.get("foo");
System.out.println(foo);

上面的代码看起来还是很复杂吧,不过这是使用原生 jedis 的情况下。如果我们使用 Spring Boot 的话,那就没这么麻烦了。Spring 组件执行的 Eval 方法内部就包含上述代码的逻辑。

不过需要注意的是,如果 Spring-Boot 使用 Jedis 作为连接客户端,并且使用Redis  Cluster 集群模式,需要使用  2.1.9 以上版本的spring-boot-starter-data-redis,不然执行过程中将会抛出。

 

优化分布式锁

/**
 * 非阻塞式加锁,若锁存在,直接返回
 *
 * @param lockName  锁名称
 * @param request   唯一标识,防止其他应用/线程解锁,可以使用 UUID 生成
 * @param leaseTime 超时时间
 * @param unit      时间单位
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    // 注意该方法是在 spring-boot-starter-data-redis 2.1 版本新增加的,若是之前版本 可以执行下面的方法
    return stringRedisTemplate.opsForValue().setIfAbsent(lockName, request, leaseTime, unit);
}

由于setIfAbsent方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本无法设置超时时间。

 

解锁需要使用 Lua 脚本:

-- 解锁代码
-- 首先判断传入的唯一标识是否与现有标识一致
-- 如果一致,释放这个锁,否则直接返回
if redis.call('get', KEYS[1]) == ARGV[1] then
   return redis.call('del', KEYS[1])
else
   return 0
end

这段脚本将会判断传入的唯一标识是否与 Redis 存储的标示一致,如果一直,释放该锁,否则立刻返回。

释放锁的方法如下:

/**
 * 解锁
 * 如果传入应用标识与之前加锁一致,解锁成功
 * 否则直接返回
 * @param lockName 锁
 * @param request 唯一标识
 * @return
 */
public Boolean unlock(String lockName, String request) {
    DefaultRedisScript<Boolean> unlockScript = new DefaultRedisScript<>();
    unlockScript.setLocation(new ClassPathResource("simple_unlock.lua"));
    unlockScript.setResultType(Boolean.class);
    return stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
}

 

Redis 分布式锁的缺陷

无法重入

由于上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。

如果想将 Redis 分布式锁改造成可重入的分布式锁,有两种方案:

  • 本地应用使用 ThreadLocal 进行重入次数计数,加锁时加 1,解锁时减 1,当计数变为 0 释放锁

  • 第二种,使用 Redis Hash 表存储可重入次数,使用 Lua 脚本加锁/解锁

Redis 分布式锁集群问题

redisson 已经实现的 RedLock 

 

简单的 Redis 分布式锁的实现方式还是很简单的,我们可以直接用 SETNX/DEL  命令实现加解锁。

不过这种实现方式不够健壮,可能存在应用宕机,锁就无法被释放的问题。

所以我们接着引入以下命令以及 Lua 脚本增强 Redis 分布式锁。

 

摘抄自公众号程序通事,大家可以去关注学习下。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章