老大吩咐的可重入分布式锁,终于完美的实现了!!!

重作永远比改造简单

最近在作一个项目,将一个其余公司的实现系统(下文称做旧系统),完整的整合到本身公司的系统(下文称做新系统)中,这其中须要将对方实现的功能完整在本身系统也实现一遍。javascript

旧系统还有一批存量商户,为了避免影响存量商户的体验,新系统提供的对外接口,还必须得跟之前一致。最后系统完整切换以后,功能只运行在新系统中,这就要求旧系统的数据还须要完整的迁移到新系统中。html

固然这些在作这个项目以前就有预期,想过这个过程很难,可是没想到有那么难。本来感受排期大半年,时间仍是挺宽裕,如今感受就是大坑,还不得不在坑里一点点去填。java

哎,说多都是泪,不吐槽了,等到下次作完再给你们复盘下真正心得体会。redis

回到正文,上篇文章Redis 分布式锁,我们基于 Redis 实现一个分布式锁。这个分布式锁基本功能没什么问题,可是缺乏可重入的特性,因此这篇文章小黑哥就带你们来实现一下可重入的分布式锁。spring

本篇文章将会涉及如下内容:安全

  • 可重入
  • 基于 ThreadLocal 实现方案
  • 基于 Redis Hash 实现方案
先赞后看,养成习惯。微信搜索「程序通事」,关注就完事了~

可重入

说到可重入锁,首先咱们来看看一段来自 wiki 上可重入的解释:微信

若一个程序或子程序能够“在任意时刻被中断而后操做系统调度执行另一段代码,这段代码又调用了该子程序不会出错”,则称其为 可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程能够再次进入并执行它,仍然得到符合设计时预期的结果。与多线程并发执行的线程安全不一样,可重入强调对单个线程执行时从新进入同一个子程序仍然是安全的。

当一个线程执行一段代码成功获取锁以后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是须要等待锁释放以后,再次获取锁成功,才能继续往下执行。数据结构

用一段 Java 代码解释可重入:多线程

public synchronized void a() {
    b();
}

public synchronized void b() {
    // pass
}

假设 X 线程在 a 方法获取锁以后,继续执行 b 方法,若是此时不可重入,线程就必须等待锁释放,再次争抢锁。并发

锁明明是被 X 线程拥有,却还须要等待本身释放锁,而后再去抢锁,这看起来就很奇怪,我释放我本身~

我打我本身

可重入性就能够解决这个尴尬的问题,当线程拥有锁以后,日后再遇到加锁方法,直接将加锁次数加 1,而后再执行方法逻辑。退出加锁方法以后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。

能够看到可重入锁最大特性就是计数,计算加锁的次数。因此当可重入锁须要在分布式环境实现时,咱们也就须要统计加锁次数。

分布式可重入锁实现方式有两种:

  • 基于 ThreadLocal 实现方案
  • 基于 Redis Hash 实现方案

首先咱们看下基于 ThreadLocal 实现方案。

基于 ThreadLocal 实现方案

实现方式

Java 中 ThreadLocal可使每一个线程拥有本身的实例副本,咱们能够利用这个特性对线程重入次数进行技术。

下面咱们定义一个ThreadLocal的全局变量 LOCKS,内存存储 Map 实例变量。

private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);

每一个线程均可以经过 ThreadLocal获取本身的 Map实例,Mapkey 存储锁的名称,而 value存储锁的重入次数。

加锁的代码以下:

/**
 * 可重入锁
 *
 * @param lockName  锁名字,表明须要争临界资源
 * @param request   惟一标识,可使用 uuid,根据该值判断是否能够重入
 * @param leaseTime 锁释放时间
 * @param unit      锁释放时间单位
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.containsKey(lockName)) {
        counts.put(lockName, counts.get(lockName) + 1);
        return true;
    } else {
        if (redisLock.tryLock(lockName, request, leaseTime, unit)) {
            counts.put(lockName, 1);
            return true;
        }
    }
    return false;
}
ps: redisLock#tryLock 为上一篇文章实现的分布锁。

因为公号外链没法直接跳转,关注『程序通事』,回复分布式锁获取源代码。

加锁方法首先判断当前线程是否已经已经拥有该锁,若已经拥有,直接对锁的重入次数加 1。

若还没拥有该锁,则尝试去 Redis 加锁,加锁成功以后,再对重入次数加 1 。

释放锁的代码以下:

/**
 * 解锁须要判断不一样线程池
 *
 * @param lockName
 * @param request
 */
public void unlock(String lockName, String request) {
    Map<String, Integer> counts = LOCKS.get();
    if (counts.getOrDefault(lockName, 0) <= 1) {
        counts.remove(lockName);
        Boolean result = redisLock.unlock(lockName, request);
        if (!result) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                    + request);
        }

    } else {
        counts.put(lockName, counts.get(lockName) - 1);
    }
}

释放锁的时首先判断重入次数,若大于 1,则表明该锁是被该线程拥有,因此直接将锁重入次数减 1 便可。

若当前可重入次数小于等于 1,首先移除 Map中锁对应的 key,而后再到 Redis 释放锁。

这里须要注意的是,当锁未被该线程拥有,直接解锁,可重入次数也是小于等于 1 ,此次可能没法直接解锁成功。

ThreadLocal 使用过程要记得及时清理内部存储实例变量,防止发生内存泄漏,上下文数据串用等问题。

下次咱来聊聊最近使用 ThreadLocal 写的 Bug。

相关问题

使用 ThreadLocal 这种本地记录重入次数,虽然真的简单高效,可是也存在一些问题。

过时时间问题

上述加锁的代码能够看到,重入加锁时,仅仅对本地计数加 1 而已。这样可能就会致使一种状况,因为业务执行过长,Redis 已通过期释放锁。

而再次重入加锁时,因为本地还存在数据,认为锁还在被持有,这就不符合实际状况。

若是要在本地增长过时时间,还须要考虑本地与 Redis 过时时间一致性的,代码就会变得很复杂。

不一样线程/进程可重入问题

狭义上可重入性应该只是对于同一线程的可重入,可是实际业务可能须要不一样的应用线程之间能够重入同把锁。

ThreadLocal的方案仅仅只能知足同一线程重入,没法解决不一样线程/进程之间重入问题。

不一样线程/进程重入问题就须要使用下述方案 Redis Hash 方案解决。

基于 Redis Hash 可重入锁

实现方式

ThreadLocal 的方案中咱们使用了 Map 记载锁的可重入次数,而 Redis 也一样提供了 Hash (哈希表)这种能够存储键值对数据结构。因此咱们可使用 Redis Hash 存储的锁的重入次数,而后利用 lua 脚本判断逻辑。

加锁的 lua 脚本以下:

---- 1 表明 true
---- 0 表明 false

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end ;
return 0;
若是 KEYS:[lock],ARGV[1000,uuid]

不熟悉 lua 语言同窗也不要怕,上述逻辑仍是比较简单的。

加锁代码首先使用 Redis exists 命令判断当前 lock 这个锁是否存在。

若是锁不存在的话,直接使用 hincrby建立一个键为 lock hash 表,而且为 Hash 表中键为 uuid 初始化为 0,而后再次加 1,最后再设置过时时间。

若是当前锁存在,则使用 hexists判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,若是存在,再次使用 hincrby 加 1,最后再次设置过时时间。

最后若是上述两个逻辑都不符合,直接返回。

加锁代码以下:

// 初始化代码

String lockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:lock.lua").openStream(), Charsets.UTF_8);
lockScript = new DefaultRedisScript<>(lockLuaScript, Boolean.class);

/**
 * 可重入锁
 *
 * @param lockName  锁名字,表明须要争临界资源
 * @param request   惟一标识,可使用 uuid,根据该值判断是否能够重入
 * @param leaseTime 锁释放时间
 * @param unit      锁释放时间单位
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    return stringRedisTemplate.execute(lockScript, Lists.newArrayList(lockName), String.valueOf(internalLockLeaseTime), request);
}
Spring-Boot 2.2.7.RELEASE

只要搞懂 Lua 脚本加锁逻辑,Java 代码实现仍是挺简单的,直接使用 SpringBoot 提供的 StringRedisTemplate 便可。

解锁的 Lua 脚本以下:

-- 判断 hash set 可重入 key 的值是否等于 0
-- 若是为 0 表明 该可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end ;
-- 计算当前可重入次数
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小于等于 0 表明能够解锁
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end ;
return nil;

首先使用 hexists 判断 Redis Hash 表是否存给定的域。

若是 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil

若存在的状况下,表明当前锁被其持有,首先使用 hincrby使可重入次数减 1 ,而后判断计算以后可重入次数,若小于等于 0,则使用 del 删除这把锁。

解锁的 Java 代码以下:

// 初始化代码:


String unlockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:unlock.lua").openStream(), Charsets.UTF_8);
unlockScript = new DefaultRedisScript<>(unlockLuaScript, Long.class);

/**
 * 解锁
 * 若可重入 key 次数大于 1,将可重入 key 次数减 1 <br>
 * 解锁 lua 脚本返回含义:<br>
 * 1:表明解锁成功 <br>
 * 0:表明锁未释放,可重入次数减 1 <br>
 * nil:表明其余线程尝试解锁 <br>
 * <p>
 * 若是使用 DefaultRedisScript<Boolean>,因为 Spring-data-redis eval 类型转化,<br>
 * 当 Redis 返回  Nil bulk, 默认将会转化为 false,将会影响解锁语义,因此下述使用:<br>
 * DefaultRedisScript<Long>
 * <p>
 * 具体转化代码请查看:<br>
 * JedisScriptReturnConverter<br>
 *
 * @param lockName 锁名称
 * @param request  惟一标识,可使用 uuid
 * @throws IllegalMonitorStateException 解锁以前,请先加锁。若为加锁,解锁将会抛出该错误
 */
public void unlock(String lockName, String request) {
    Long result = stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
    // 若是未返回值,表明其余线程尝试解锁
    if (result == null) {
        throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
                + request);
    }
}

解锁代码执行方式与加锁相似,只不过解锁的执行结果返回类型使用 Long。这里之因此没有跟加锁同样使用 Boolean ,这是由于解锁 lua 脚本中,三个返回值含义以下:

  • 1 表明解锁成功,锁被释放
  • 0 表明可重入次数被减 1
  • null 表明其余线程尝试解锁,解锁失败

若是返回值使用 BooleanSpring-data-redis 进行类型转换时将会把 null 转为 false,这就会影响咱们逻辑判断,因此返回类型只好使用 Long

如下代码来自 JedisScriptReturnConverter

相关问题

spring-data-redis 低版本问题

若是 Spring-Boot 使用 Jedis 做为链接客户端,而且使用Redis Cluster 集群模式,须要使用 2.1.9 以上版本的spring-boot-starter-data-redis,否则执行过程当中将会抛出:

org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.

若是当前应用没法升级 spring-data-redis也不要紧,可使用以下方式,直接使用原生 Jedis 链接执行 lua 脚本。

以加锁代码为例:

public boolean tryLock(String lockName, String reentrantKey, long leaseTime, TimeUnit unit) {
    long internalLockLeaseTime = unit.toMillis(leaseTime);
    Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
        Object innerResult = eval(connection.getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
        return convert(innerResult);
    });
    return result;
}

private Object eval(Object nativeConnection, RedisScript redisScript, final List<String> keys, final List<String> args) {

    Object innerResult = null;
    // 集群模式和单点模式虽然执行脚本的方法同样,可是没有共同的接口,因此只能分开执行
    // 集群
    if (nativeConnection instanceof JedisCluster) {
        innerResult = evalByCluster((JedisCluster) nativeConnection, redisScript, keys, args);
    }
    // 单点
    else if (nativeConnection instanceof Jedis) {
        innerResult = evalBySingle((Jedis) nativeConnection, redisScript, keys, args);
    }
    return innerResult;
}

数据类型转化问题

若是使用 Jedis 原生链接执行 Lua 脚本,那么可能又会碰到数据类型的转换坑。

能够看到 Jedis#eval返回 Object,咱们须要具体根据 Lua 脚本的返回值的,再进行相关转化。这其中就涉及到 Lua 数据类型转化为 Redis 数据类型。

下面主要咱们来说下 Lua 数据转化 Redis 的规则中几条比较容易踩坑:

一、Lua number 与 Redis 数据类型转换

Lua 中 number 类型是一个双精度的浮点数,可是 Redis 只支持整数类型,因此这个转化过程将会丢弃小数位。

二、Lua boolean 与 Redis 类型转换

这个转化比较容易踩坑,Redis 中是不存在 boolean 类型,因此当Lua 中 true 将会转为 Redis 整数 1。而 Lua 中 false 并非转化整数,而是转化 null 返回给客户端。

三、Lua nil 与 Redis 类型转换

Lua nil 能够当作是一个空值,能够等同于 Java 中的 null。在 Lua 中若是 nil 出如今条件表达式,将会当作 false 处理。

因此 Lua nil 也将会 null 返回给客户端。

其余转化规则比较简单,详情参考:

http://doc.redisfans.com/scri...

总结

可重入分布式锁关键在于对于锁重入的计数,这篇文章主要给出两种解决方案,一种基于 ThreadLocal 实现方案,这种方案实现简单,运行也比较高效。可是若要处理锁过时的问题,代码实现就比较复杂。

另一种采用 Redis Hash 数据结构实现方案,解决了 ThreadLocal 的缺陷,可是代码实现难度稍大,须要熟悉 Lua 脚本,以及Redis 一些命令。另外使用 spring-data-redis 等操做 Redis 时不经意间就会遇到各类问题。

帮助

https://www.sofastack.tech/bl...

https://tech.meituan.com/2016...

最后说两句(求关注)

看完文章,哥哥姐姐们点个吧,周更真的超累,不知觉又写了两天,拒绝白嫖,来点正反馈呗~。

最后感谢各位的阅读,才疏学浅,不免存在纰漏,若是你发现错误的地方,能够留言指出。若是看完文章还有其余不懂的地方,欢迎加我,互相学习,一块儿成长~

最后谢谢你们支持~

最最后,重要的事再说一篇~

快来关注我呀~
快来关注我呀~
快来关注我呀~

欢迎关注个人公众号:程序通事,得到平常干货推送。若是您对个人专题内容感兴趣,也能够关注个人博客: studyidea.cn

公号底图

相关文章
相关标签/搜索