La serrure distribuée rentrante commandée par le patron a enfin été parfaitement réalisée ! ! !

Refaire est toujours plus facile que moderniser

Récemment, je travaille sur un projet d'intégration complète du système d'implémentation d'une autre société (ci-après dénommé l'ancien système) dans le système de ma propre société (ci-après dénommé le nouveau système), qui nécessite les fonctions mises en œuvre par le l'autre partie doit être complétée dans son propre système. Recommencez.

L'ancien système a encore un certain nombre de marchands existants. Afin de ne pas affecter l'expérience des marchands existants, l'interface externe fournie par le nouveau système doit être la même qu'avant. Enfin, après le basculement complet du système, les fonctions ne s'exécutent que dans le nouveau système, ce qui nécessite la migration complète des données de l'ancien système vers le nouveau système.

Bien sûr, ceux-ci étaient attendus avant de faire ce projet.Je pensais que ce processus serait difficile, mais je ne m'attendais pas à ce qu'il soit si difficile. Au départ, je sentais que le calendrier était de plus de six mois et qu'il y avait encore beaucoup de temps. Maintenant, cela ressemble à un gros trou et je dois le combler un peu.

Hey, je parle de larmes, donc je ne vais pas me plaindre. Je vous donnerai la vraie expérience quand je l'aurai fini la prochaine fois.

Revenons au texte principal, dans le dernier article Verrou distribué Redis , nous avons implémenté un verrou distribué basé sur Redis. Il n'y a pas de problème avec les fonctions de base de ce verrou distribué, mais il lui manque la fonction réentrante, donc dans cet article, Xiao Hei vous amènera à implémenter un verrou distribué réentrant.

Cet article couvrira les éléments suivants :

  • rentrant
  • Schéma d'implémentation basé sur ThreadLocal
  • Schéma de mise en œuvre basé sur Redis Hash

Aimez-le d'abord, puis regardez-le, faites-en une habitude. Wechat recherche "Program Communication", suivez-le et vous avez terminé ~

rentrant

En parlant de verrous réentrants, examinons d'abord une explication réentrante du wiki :

Un programme ou un sous-programme peut être « réentrant ou réentrant s'il peut être « interrompu à tout moment puis le système d'exploitation programme un autre morceau de code à exécuter sans erreur lorsque ce code appelle le sous-programme » C'est-à-dire que pendant que la sous-routine est en cours d'exécution, le thread d'exécution peut la réintégrer et l'exécuter, tout en obtenant les résultats attendus au moment de la conception. Contrairement à la sécurité des threads de l'exécution simultanée multithread, la réentrance souligne qu'il est toujours sûr de ré-entrer dans le même sous-programme lorsqu'il est exécuté sur un seul thread.

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

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

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

public synchronized void b() {
    // pass
}
复制代码
复制代码

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

锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~

je me suis cogné

可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 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 的规则中几条比较容易踩坑:

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

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

2、Lua boolean 与 Redis 类型转换

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

3、Lua nil 与 Redis 类型转换

Lua nil 可以当做是一个空值,可以等同于 Java 中的 null。在 Lua 中如果 nil 出现在条件表达式,将会当做 false 处理。

所以 Lua nil 也将会 null 返回给客户端。

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

doc.redisfans.com/script/eval…

总结

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

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

絮叨几句。

Frère Dong a écrit de nombreuses séries d'articles illustrant le réseau et le système d'exploitation sur l'ensemble du réseau . Je suis très heureux de recevoir la reconnaissance et le soutien de nombreux amis. Tout récemment, il y a plus de 20 articles sérialisés sur l'illustration du réseau et système d'exploitation, et il peut être considéré comme un système.

Par conséquent, afin de faciliter la lecture des amis de Zhihu, Dongge a organisé son réseau graphique original et son système d'exploitation graphique en PDF Après avoir terminé, je ne m'attendais pas à ce que chaque graphique produise ** 150 000 mots + 500 images, ** La qualité est De nombreux amis m'ont envoyé un message privé et après avoir lu mon schéma, ils ont reçu une offre d'un grand fabricant.

Téléchargement open source PDF du système graphique :

Résumé des bases de l'informatique et système d'exploitation Téléchargement PDF

Téléchargement Web PDF illustré de 300 pages !

Système d'exploitation graphique, réseau, composition informatique Téléchargement PDF !

Enfin, je vous souhaite à tous un avenir radieux et une bonne route sur la route du codage.

Je suppose que tu aimes

Origine juejin.im/post/7079695284099350559
conseillé
Classement