最近在作一个项目,将一个其余公司的实现系统(下文称做旧系统),完整的整合到本身公司的系统(下文称做新系统)中,这其中须要将对方实现的功能完整在本身系统也实现一遍。javascript
旧系统还有一批存量商户,为了避免影响存量商户的体验,新系统提供的对外接口,还必须得跟之前一致。最后系统完整切换以后,功能只运行在新系统中,这就要求旧系统的数据还须要完整的迁移到新系统中。html
固然这些在作这个项目以前就有预期,想过这个过程很难,可是没想到有那么难。本来感受排期大半年,时间仍是挺宽裕,如今感受就是大坑,还不得不在坑里一点点去填。java
哎,说多都是泪,不吐槽了,等到下次作完再给你们复盘下真正心得体会。redis
回到正文,上篇文章Redis 分布式锁,我们基于 Redis 实现一个分布式锁。这个分布式锁基本功能没什么问题,可是缺乏可重入的特性,因此这篇文章小黑哥就带你们来实现一下可重入的分布式锁。spring
本篇文章将会涉及如下内容:安全
先赞后看,养成习惯。微信搜索「程序通事」,关注就完事了~
说到可重入锁,首先咱们来看看一段来自 wiki 上可重入的解释:微信
若一个程序或子程序能够“在任意时刻被中断而后操做系统调度执行另一段代码,这段代码又调用了该子程序不会出错”,则称其为 可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程能够再次进入并执行它,仍然得到符合设计时预期的结果。与多线程并发执行的线程安全不一样,可重入强调对单个线程执行时从新进入同一个子程序仍然是安全的。
当一个线程执行一段代码成功获取锁以后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是须要等待锁释放以后,再次获取锁成功,才能继续往下执行。数据结构
用一段 Java 代码解释可重入:多线程
public synchronized void a() { b(); } public synchronized void b() { // pass }
假设 X 线程在 a 方法获取锁以后,继续执行 b 方法,若是此时不可重入,线程就必须等待锁释放,再次争抢锁。并发
锁明明是被 X 线程拥有,却还须要等待本身释放锁,而后再去抢锁,这看起来就很奇怪,我释放我本身~
可重入性就能够解决这个尴尬的问题,当线程拥有锁以后,日后再遇到加锁方法,直接将加锁次数加 1,而后再执行方法逻辑。退出加锁方法以后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
能够看到可重入锁最大特性就是计数,计算加锁的次数。因此当可重入锁须要在分布式环境实现时,咱们也就须要统计加锁次数。
分布式可重入锁实现方式有两种:
首先咱们看下基于 ThreadLocal 实现方案。
Java 中 ThreadLocal
可使每一个线程拥有本身的实例副本,咱们能够利用这个特性对线程重入次数进行技术。
下面咱们定义一个ThreadLocal
的全局变量 LOCKS
,内存存储 Map
实例变量。
private static ThreadLocal<Map<String, Integer>> LOCKS = ThreadLocal.withInitial(HashMap::new);
每一个线程均可以经过 ThreadLocal
获取本身的 Map
实例,Map
中 key
存储锁的名称,而 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 方案解决。
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 脚本中,三个返回值含义以下:
null
表明其余线程尝试解锁,解锁失败若是返回值使用 Boolean
,Spring-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