✅Redisson里面的锁是怎么来防止误删的?

✅Redisson里面的锁是怎么来防止误删的?

典型回答

先看问题,Redisson怎么误删,其实所谓的误删其实就是删除了别的线程加的锁,为什么会发生这种情况呢?假如两个线程的执行顺序是如下的:

线程1 线程2
加锁成功(设置了超时时间)
执行业务逻辑
锁到期,被自动删除
加锁成功
执行业务逻辑
业务逻辑执行完,解锁

那么,如果没有在解锁的时候做一些额外的判断,是可能会线程1把线程2的锁给解了的。那么,Redisson作为一个成熟的框架,他是如何解决这个问题的呢?

Redisson一旦设置了超时时间,watchdog就不会续期了,就可能出现上面的情况。https://www.yuque.com/hollis666/sza8tg/fg0f0wh41g8eu5ik

这种题,就是讲源码最有说服力了。

先来看Redisson加锁的核心lua相关的代码,在RedissonLock#tryLockInnerAsync中。

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return evalWriteSyncedNoRetryAsync(getRawName(), LongCodec.INSTANCE, command,
            "if ((redis.call('exists', KEYS[1]) == 0) " +
                        "or (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 nil; " +
                "end; " +
                "return redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

这段lua脚本执行之后,最终会在Redis中存一个Hash结构,key就是我们指定的加锁的key的名字,比如Hollis666,然后存储的hash的key是一个真正的lockName,存储的值是这个锁被重入的次数。

啥叫真正的lockName呢,其实就是上面的getLockName方法的实现:

protected String getLockName(long threadId) {
    return id + ":" + threadId;
}

其实就是用当前RedissonClient实例的UUID拼上当前线程的ID,如b983c153-8e53-4c04-beb8-0c34d6e0237d:132

而为了避免误删,也就是把别人的锁给解了,这就需要解锁的时候也需要用到这个lockName了。看一下解锁的代码,在在RedissonLock#unlockInnerAsync中。


protected RFuture<Boolean> unlockInnerAsync(long threadId, String requestId, int timeout) {
      return evalWriteSyncedNoRetryAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
          "local val = redis.call('get', KEYS[3]); " +
                "if val ~= false then " +
                    "return tonumber(val);" +
                "end; " +

                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "redis.call('set', KEYS[3], 0, 'px', ARGV[5]); " +
                    "return 0; " +
                "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call(ARGV[4], KEYS[2], ARGV[1]); " +
                    "redis.call('set', KEYS[3], 1, 'px', ARGV[5]); " +
                    "return 1; " +
                "end; ",
            Arrays.asList(getRawName(), getChannelName(), getUnlockLatchName(requestId)),
            LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime,
            getLockName(threadId), getSubscribeService().getPublishCommand(), timeout);
  }

可以看到,这里面也把getLockName(threadId)的结果传到lua脚本中了,作为ARGV[3]

可以看到,上面的代码中,Lua 脚本首先会检查 Redis 中锁 Key 对应的 Hash 结构中,是否存在一个 Field 等于 ARGV[3](即当前客户端的 UUID:threadId)。

  • 如果不存在:说明在当前客户端准备解锁时,这把锁已经不属于它了。此时,脚本直接返回 nil,而不是执行删除操作。这直接防止了误删他人锁的行为。不存在可能的原因有:
    • 锁早已过期被自动删除(设置了超时时间,就不自动续期了),然后被其他客户端获取。
    • 客户端试图释放一个它从未持有的锁。
  • 如果存在:
    • 说明是锁的所有者, then 将重入次数减 1。
    • 如果减 1 后重入次数大于 0,说明当前线程多次锁(可重入),还没有完全释放。此时只刷新过期时间,不删除 Key。
    • 只有重入次数减到 0 时,才执行 del KEYS[1] 删除整个锁 Key。

所以,总结一下,就是在加锁的时候,Redisson并没有单纯用一个string类型来存储一个固定值,比如和key相同,或者存个固定数字,而是存储了一个hash结构,然后针对这个hash结构中的元素的的key设置为lockName,lockName其中包含了当前的线程id,并且在解锁的时候也会通过同样的这个lockName来做解锁判断,如果不一致,则不执行解锁的动作。(为什么要用hash结构?主要是为了实现可重入:https://www.yuque.com/hollis666/sza8tg/ucarmoyv1belggn0