接口卡顿的罪魁祸首居然是它---重入锁

23 阅读5分钟

记一次线上bug,用户在登录一次后,重复登录会出现登录转圈,持续时间20多秒,这能忍吗,于是话就赶紧去查看响应代码,这不赶紧解决,还不得要我老命啊!!!!!

java中常用的锁很多比如ReentrantLock,Redisson等,由于是分布式项目,我才用了Redisson分布式锁,主要也是用了中间件redis来实现了

事故现场代码:

slodonLock.lock(memberLockKey);
try{
    业务逻辑
}
}finally {
    slodonLock.unlock(memberLockKey);
}

眼瞅着代码没什么问题,怎么会卡顿了,自己在本地测试了也是复现了事故场景,发现第一次登录没事正常响应,再次登录就会出现长时间卡顿,发现缓存中的key没有删除,就怀疑没有执行删除操作,经过debug发现是执行了的,就很无解了,不由得就去查看改锁的源码

Redisson的加锁流程:

public void lock(String lockName,long leaseTime){
    client.getLock(lockName).lock(leaseTime, TimeUnit.SECONDS);
}
redisson的具体实现是RedissonLock实现类

根据代码一步一步找到加锁这里
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    加锁--通过future异步加锁
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                                                            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
     获取枷锁结果                                                       
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
             这里就是传说的看门狗的实现,开启定时任务,每10秒执行一次,给锁续期
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}


<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    最后是用lua脚本实现,保证了原子性 
    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "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 nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
脚本含义:
1.  如果哈希表 `KEYS[1]` 不存在,则将哈希表中对应的值设为 `ARGV[2]`,并将过期时间设为 `ARGV[1]`,然后返回 `nil`。
2.  如果哈希表 `KEYS[1]` 存在且键为 `ARGV[2]` 的元素也存在,则将哈希表中对应的值加一,并将过期时间设为 `ARGV[1]`,然后返回 `nil`。
3.  如果以上两个条件都不满足,则返回哈希表 `KEYS[1]` 的剩余生存时间(以秒为单位)。

从第二点就发现存在key就会值加1,从这里就可以看出是重入锁,为了验证猜想就再去看看释放锁逻辑,难道是释放锁就会减1,一起出看看释放的源码

public void unlock(String lockName) {
    RLock lock = client.getLock(lockName);
    //是否是当前线程 只有当前线程才能释放锁  所有一般来说,我们加锁后必须要释放锁,否者就只能等到过期才会释放
    if (lock.isHeldByCurrentThread()){
        lock.unlock();
    }
}


@Override
public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<>();
    //释放锁
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
         //移除map中维护的线程集合
        cancelExpirationRenewal(threadId);

        if (e != null) {
            result.tryFailure(e);
            return;
        }

        if (opStatus == null) {
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
            result.tryFailure(cause);
            return;
        }

        result.trySuccess(null);
    });

    return result;
}
释放锁也是执行的lua脚本
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "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]); " +
                    "return 0; " +
                    "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return nil;",
            Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
1.  如果哈希表 `KEYS[1]` 中不存在键为 `ARGV[3]` 的元素,则返回 `nil`2.  如果存在该元素,则将哈希表中对应的值减一,并将结果保存在变量 `counter` 中。
3.  如果 `counter` 大于零,说明哈希表中的值大于一,那么设置哈希表 `KEYS[1]` 的过期时间为 `ARGV[2]`,然后返回 `0`4.  如果 `counter` 不大于零,说明哈希表中的值等于一,删除哈希表 `KEYS[1]`,然后发布消息到 `KEYS[2]`,并返回 `1`5.  如果以上条件都不满足,最后返回 `nil`。
从第二点就验证了我们的猜想,果然是重入锁,难道是我们我们锁了两次就导致锁没有删除

回到代码里面,我们直接把断点打到释放锁前面,看看值就知道,果不其然,缓存中的值居然是2,执行完代码就变成1了,这就说明同一个线程加了两个锁,只释放了一次锁 错误代码: slodonLock.lock(memberLockKey); try{

slodonLock.lock(memberLockKey)
业务逻辑

} }finally { slodonLock.unlock(memberLockKey); }

原来是修改老代码,重复加锁了,代码数量太长,导致没有查看到,大家要注意哦

看面讲一讲redisson看门狗的实现逻辑,直接上源码

protected void scheduleExpirationRenewal(long threadId) {
     
    ExpirationEntry entry = new ExpirationEntry();
    不存在返回null,  存在返回响应的值
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
       
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        renewExpiration();
    }
}

private static final ConcurrentMap<String, ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap<>();

EXPIRATION_RENEWAL_MAP 维护这一个map,所有的加锁的线程对象ExpirationEntry

private final Map<Long, Integer> threadIds = new LinkedHashMap<>();

threadIds  所有线程的对应的加锁次数

  存在,就累加一次,这次也是重入锁
public synchronized void addThreadId(long threadId) {
    Integer counter = threadIds.get(threadId);
    if (counter == null) {
        counter = 1;
    } else {
        counter++;
    }
    threadIds.put(threadId, counter);
}

看门狗实现,定时任务
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
             //续期key
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                     续期失败,说明key已经被释放,移除改对象
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                
                if (res) {
                    // reschedule itself
                    //续期成功,递归下次再续期
                    renewExpiration();
                }
            });
        }
        默认是你设置过期时间的/3后执行一次
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getName()),
            internalLockLeaseTime, getLockName(threadId));
}
如果哈希表 `KEYS[1]` 中存在键为 `ARGV[2]` 的字段,则执行以下操作:

1.  设置该字段的过期时间为 `ARGV[1]`。
1.  返回1表示成功添加了新的记录。
1.  如果哈希表 `KEYS[1]` 中不存在键为 `ARGV[2]` 的字段,则返回0表示添加失败。