由于真实碰到的场景涉及具体的业务,这里我就举个不恰当的例子来大概类比一下我碰到的问题。
比如“我每天回家-打开家里所有智能设备”这件事,可以分为两个场景,一个是我下班从公司回家,然后打开热水器、窗帘等所有智能设备;一个是我外出办事,从别的地方回家然后需要打开所有设备。这两个场景的触发动作都是回家,但是对于回家的实现形式是不一样的,在我们的老代码中,这两种场景就被写成了两个接口。导致的问题就是,如果我家里新增一台智能设备 空调,我就要同时在两个接口中都加上“打开空调”这段代码,业务场景逐渐增多、新同学对代码不熟悉,这段代码忘了加,长年累月就导致了本来都是回家要做的动作,两个接口形成了差异,可能我从公司回家就开了空调,从外面回家就没有开空调orz。
于是我最近做了一个优化,把这两个接口收敛起来,因为上游调用系统很多,接口的调用姿势不能改变,那就只能把内部的action统一起来,于是我把“从外面回家”接口中的内部实现直接改为调用“从公司回家”的实现。这看起来很简单,只需要评估一下入参的校验逻辑与兼容、业务场景覆盖等问题(虽然这些做起来也不止这么简单)就可以发到测试环境了。我也是这么想的。
这两个接口的实现逻辑都开了数据库事务,只要配置好传播特性,这不是问题。问题是这两个接口为了避免并发,也都使用了redis分布式锁,比如“开智能设备”这件事,为了防止我爸妈和我同时操作,也需要使用锁,对于同一台设备,例如客厅里的空调,只需要锁住它对于我家来说的唯一标识(全球设备唯一69码 or “客厅空调”)就能告诉我爸妈我正在使用这个资源。
问题1: 两个方法都对69码加锁,方法1调入方法2后,发现同一69码被锁住,代码没法往下走。
由于锁了同一个资源且锁不是重入的。题外话,因为我们代码Spring和redis版本较低,不能实现同时设定值以及过期时间,所以redis锁都是通过StringRedisTemplate + lua脚本的方式实现非可重入式分布式锁。抛异常和记录日志的操作这里都省略了。
//业务入口,具体业务实现需要实现Action的execute()
public static T doAction(Action action, StringRedisTemplate redisTemplate, String key,String value, int timeout) {
RedisLock redisLock = new RedisLock(redisTemplate, key, value, timeout, TimeUnit.SECONDS);
try {
if (redisLock.tryLockWithLua()) {
return action.execute();
} else {
//抛异常,此69码已被上锁 ;
}
} finally {
if (redisLock.isLock()) {
redisLock.unLockWithLua();
}
}
}
//具体加锁实现
public boolean tryLockWithLua() {
RedisCallback callback = new RedisCallback() {
@Override
public String doInRedis(RedisConnection connection) throws DataAccessException {
Object nativeConnection = connection.getNativeConnection();
return ((Jedis) nativeConnection).eval(加锁lua脚本, 1, key, value, expireTimeStr).toString();
}
};
String success = redisTemplate.execute(callback);
if (SUCCESS.equals(success)) {
isLock = true;
return true;
}
return false;
}
//具体解锁实现
public void unLockWithLua() {
if (isLock) {
RedisCallback callback = new RedisCallback() {
@Override
public String doInRedis(RedisConnection connection) throws DataAccessException {
Object nativeConnection = connection.getNativeConnection();
return ((Jedis) nativeConnection).eval(解锁lua脚本, 1, key, value).toString();
}
};
String success = redisTemplate.execute(callback);
if (SUCCESS.equals(success)) {
isLock = false;
}
}
}
//lua脚本
加锁lua脚本 = "if redis.call('setNx',KEYS[1],ARGV[1])==1 then\n" +
"return redis.call('expire',KEYS[1],ARGV[2])\n" +
"else\n" +
"return 0\n" +
"end\n";
解锁lua脚本 = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
然后根据已有的加锁代码,写可重入的分布式锁。我先看了这位同学的文章https://blog.csdn.net/u014634309/article/details/106755403,hash的方法肯定不能用,因为我们要控制又有当前线程可以重入这个锁,那就只有ThreadLocal可以选择了,这位同学的文章中写的也很清楚,ThreadLocal实现可能存在的问题就是过期时间的问题,无法判断你当前内存中存储的数据在redis中是不是已经过期了,所以ThreadLocal中只放key和count肯定不够。那么我们就扩展一个content,由于value中可以携带时间戳信息,那么就把value也放进去,加锁之前我判断内存中value和redis中的相同key取到的value一样,就可以判断它没有过期了,所以我这么写了。版本1:
private static final ThreadLocal threadLocal = new ThreadLocal<>();
@Data
@Builder
private static class ThreadLocalReentrantLockContent{
private String key;
private String value;
private Integer count;
}
public static T doActionUseReentrantLock(Action action, StringRedisTemplate redisTemplate, String key, String value, int timeout) {
RedisLock redisLock = new RedisLock(redisTemplate, key, value, timeout, TimeUnit.SECONDS);
//获取当前线程所持有的锁
ThreadLocalReentrantLockContent content = threadLocal.get();
try {
if (content == null) {
content = ThreadLocalReentrantLockContent.builder().key("").value("").count(0).build();
threadLocal.set(content);
}
//重入
//1. content中需要保存key并且判断传入的key和内存中相同,兼容一个线程先锁69码,处理完毕后再锁“客厅空调”的情况
//2. 内存中value和redis中相同 保证当前线程持有的锁没有超时(value中携带时间信息)
//3. 附加判断一下超时时间,可以不要
if (StringUtils.isNotBlank(content.getKey())
&& content.getKey().equals(key)
&& StringUtils.isNotBlank(content.getValue())
&& content.getValue().equals(redisTemplate.opsForValue().get(key))
&& redisTemplate.getExpire(key) > 0) {
content.count++;
} else {
//加锁
if (redisLock.tryLockWithLua()) {
content.setKey(key);
content.setValue(value);
content.setCount(1);
} else {
//抛异常,此key已被锁定
}
}
//执行
return action.execute();
} finally {
content = threadLocal.get();
if (null != content) {
//释放锁
content.count--;
if (redisLock.isLock() && 0 == content.getCount()) {
threadLocal.remove();
redisLock.unLockWithLua();
}
}
}
}
问题2:那么问题来了,这个姿势只能实现先锁69码,处理完毕后解锁这个key后再锁“客厅空调”的情况,如果以后代码写的很畸形,存在需要先锁69码,下一个操作直接锁“客厅空调”的情况,那么这代码就不兼容了。跟小伙伴讨论了一下,使用嵌套的方式,在content里面套一个content,用来保存前一次加锁的上下文,是不是就能实现了,于是我们这样做了。版本2:
@Data
@Builder
private static class ThreadLocalReentrantLockContent{
private String key;
private String value;
private Integer count;
/**
* 用于前后加锁key不一样的情况,保留前一次的content
*/
private ThreadLocalReentrantLockContent lastContent;
}
public static T doActionUseReentrantLock(Action action, StringRedisTemplate redisTemplate, String key, String value, int timeout) {
RedisLock redisLock = new RedisLock(redisTemplate, key, value, timeout, TimeUnit.SECONDS);
//获取当前线程所持有的锁
ThreadLocalReentrantLockContent content = threadLocal.get();
try {
//重入
if (content != null
&& content.getKey().equals(key)
&& content.getValue().equals(redisTemplate.opsForValue().get(key))
&& redisTemplate.getExpire(key) > 0) {
content.count++;
} else {
//加锁
if (redisLock.tryLockWithLua()) {
ThreadLocalReentrantLockContent newContent = ThreadLocalReentrantLockContent.builder().key(key).value(value).count(1).build();
newContent.setLastContent(content);
threadLocal.set(newContent);
} else {
//抛异常,已被锁定
}
}
//执行
return action.execute();
} finally {
content = threadLocal.get();
if (null != content) {
//释放锁
content.count--;
if (redisLock.isLock() && 0 == content.getCount()) {
content = content.lastContent;
if (content == null) {
// 当前这个是最外层的加锁逻辑
threadLocal.remove();
} else {
threadLocal.set(content);
}
redisLock.unLockWithLua();
}
}
}
}
看起来好像挺完美的,但是在debug的时候我发现了一个问题,我在重入次数++和重入次数–的时候,取到的都是ThreadLocal中的最新content,如果是先锁69码,再锁“客厅空调”,再锁69码 这种情况怎么办呢?现在的代码在第三个阶段就会报错,他没有找到69的码锁并且把count++,而是试图再给69码加锁,这个时候肯定会异常。
那么,这种复杂而畸形的情况必然是需要递归了。虽然这种场景应该在我的职业生涯不会发生,但是都写到这了,就把它写完吧。于是我又努力了一下。版本3:
/**
* 可重入锁
* 1. 可实现key不同的加锁逻辑 例如先锁69码再锁“客厅空调”
* 2. 可实现key相同的加锁重入 例如先锁69码再锁69码
* 3. 可实现多次循环加锁 例如先锁69码,再锁“客厅空调”,再锁69码
*
* @param Action
* @param redisTemplate
* @param key
* @param value
* @param timeout
* @param
* @return
*/
public static T doActionUseReentrantLock(Action action, StringRedisTemplate redisTemplate, String key, String value, int timeout) {
RedisLock redisLock = new RedisLock(redisTemplate, key, value, timeout, TimeUnit.SECONDS);
//threadLocal get到的为最新的content,要暂存起来,因为要把层级中count的更新放入内存
ThreadLocalReentrantLockContent latestContent = threadLocal.get();
try {
//重入,curContent为当前key使用的content
ThreadLocalReentrantLockContent curContent = checkAndGetReentrant(latestContent, key, redisTemplate.opsForValue().get(key));
if (null != curContent) {
curContent.count++;
//重置threadLocal中的count
threadLocal.set(latestContent);
} else {
//加锁
if (redisLock.tryLockWithLua()) {
ThreadLocalReentrantLockContent newContent = ThreadLocalReentrantLockContent.builder().key(key).value(value).count(1).build();
newContent.setLastContent(latestContent);
threadLocal.set(newContent);
} else {
//抛异常,已被锁定
}
}
//执行
return action.execute();
} finally {
//如果存在锁69码-“客厅空调”-69码 的情况,下面两个content不相同
latestContent = threadLocal.get();
ThreadLocalReentrantLockContent curContent = checkAndGetReentrant(latestContent, key, redisTemplate.opsForValue().get(key));
if (null != curContent) {
//释放锁
curContent.count--;
threadLocal.set(latestContent);
if (redisLock.isLock() && 0 == curContent.getCount()) {
if (curContent.lastContent == null) {
// 当前这个是最外层的加锁逻辑
threadLocal.remove();
} else {
threadLocal.set(curContent.lastContent);
}
redisLock.unLockWithLua();
}
}
}
}
/**
* 递归检查可重入性,兼容先锁69码 再锁“客厅空调” 再锁69码…的逻辑
*
* @param content
* @param curKey
* @param redisValue
* @return 当前轮次所使用的content
*/
private static ThreadLocalReentrantLockContent checkAndGetReentrant(ThreadLocalReentrantLockContent content, String curKey, String redisValue) {
//首次加锁
if (null == content) {
return null;
}
//与上次key相同的重入
if (content.getKey().equals(curKey)
&& content.getValue().equals(redisValue)) {
return content;
} else {
//递归的重入
if (null != content.getLastContent()) {
content = content.getLastContent();
return checkAndGetReentrant(content, curKey, redisValue);
} else {
//非重入加锁
return null;
}
}
}
完成!这套代码就可以实现各种姿势,不同唯一id变着法的加锁的情况了。如果实际情况没有这么复杂,那么版本1就可以完全实现你的需求了!
后记:
一开始想着保存上一次的content,后来发现要递归才能达到目的,经过小伙伴的指引,发现这个递归的方法着实有点复杂了,一个map不就可以实现了吗。。。
private static class ThreadLocalReentrantLockContent{
private String key;
private String value;
private Integer count;
}
public static T doActionUseReentrantLock(LockAction lockAction, StringRedisTemplate redisTemplate, String key, String value, int timeout) {
RedisLock redisLock = new RedisLock(redisTemplate, key, value, timeout, TimeUnit.SECONDS);
Map threadLocalMap = threadLocal.get();
try {
if (threadLocalMap != null && null != threadLocalMap.get(key)
&& threadLocalMap.get(key).getValue().equals(redisTemplate.opsForValue().get(key))) {
threadLocalMap.get(key).count++;
} else {
//加锁
if (redisLock.tryLockWithLua()) {
ThreadLocalReentrantLockContent newContent = ThreadLocalReentrantLockContent.builder().key(key).value(value).count(1).build();
if (threadLocalMap == null) {
threadLocalMap = new HashMap<>();
}
threadLocalMap.put(key, newContent);
threadLocal.set(threadLocalMap);
} else {
//加锁失败
}
}
//执行
return lockAction.execute();
} finally {
threadLocalMap = threadLocal.get();
ThreadLocalReentrantLockContent curContent = threadLocalMap.get(key);
if (null != curContent) {
//释放锁
curContent.count--;
if (redisLock.isLock() && 0 == curContent.getCount()) {
redisLock.unLockWithLua();
}
}
}
}
这块只是为了展现这个思想,threadLocal里面的map没有remove掉,这块就可以随意发挥了,影响不大~