redis 线程级别的可重入式分布式锁(不同唯一id可以循环加锁)

场景示例

由于真实碰到的场景涉及具体的业务,这里我就举个不恰当的例子来大概类比一下我碰到的问题。
比如“我每天回家-打开家里所有智能设备”这件事,可以分为两个场景,一个是我下班从公司回家,然后打开热水器、窗帘等所有智能设备;一个是我外出办事,从别的地方回家然后需要打开所有设备。这两个场景的触发动作都是回家,但是对于回家的实现形式是不一样的,在我们的老代码中,这两种场景就被写成了两个接口。导致的问题就是,如果我家里新增一台智能设备 空调,我就要同时在两个接口中都加上“打开空调”这段代码,业务场景逐渐增多、新同学对代码不熟悉,这段代码忘了加,长年累月就导致了本来都是回家要做的动作,两个接口形成了差异,可能我从公司回家就开了空调,从外面回家就没有开空调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掉,这块就可以随意发挥了,影响不大~

你可能感兴趣的:(Tips,for,work,redis,分布式,数据库)