我们目前项目中使用的 redis 锁并没有续期的功能,所以在执行长时间任务时会触发 attempt to unlock '{}', not locked by current thread. 异常,解决这个问题比较正确的姿势是采用 redisson 这个客户端工具.具体介绍可以搜索最大的同性交友网站 github.

我们看官方的解释:

Redisson 为避免存储这个分布式锁的Redisson节点宕机出现锁死的情况,在内部提供了一个监控锁的看门狗,其作用是在Redisson实例被关闭前,不断延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒,可以通过修改 Config.lockWatchdogTimeout 来指定。另外Redisson还通过加锁的方法提供了 leaseTime 的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

细心的同学可能会发现上面解释的歧义:

  1. 看门狗每隔 30 秒检查一次锁的超时时间
  2. 看门狗会去检查锁的超时时间,锁的时间时间默认是30秒

所以看门狗具体是怎么检查锁的,我们从源码中找答案。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Demo.java
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");

RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("anyLock");

lock.lock();
}

// Redisson.java
// 先看getLock()方法
public RLock getLock(String name) {
return new RedissonLock(connectionManager.getCommandExecutor(), name);
}

// RedissonLock.java
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
this.commandExecutor = commandExecutor;
this.id = commandExecutor.getConnectionManager().getId();
// 从这里我们知道,internalLockLeaseTime 和 lockWatchdogTimeout这两个参数是相等的.
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
this.entryName = this.id + ":" + name;
this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();
}

// Config.java
private long lockWatchdogTimeout = 30 * 1000;

上面代码我们看到锁的释放时间就是看门狗的超时时间,默认为30秒,但还有一个问题,看门狗是多久来延长一次有效期呢?我们接着往下看:

继续看 lock() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
//RedissonLock.java
public void lock() {
try {
lock(-1, null, false);
} catch (InterruptedException e) {
throw new IllegalStateException();
}
}
//RedissonLock.java
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return;
}
...
}
//RedissonLock.java
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(leaseTime, unit, threadId));
}
//RedissonLock.java
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
...
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}

// lock acquired
if (ttlRemaining == null) {
// 主要是这个方法 时间表到期续订
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
// RedissonLock.java
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
// 具体续期方法
renewExpiration();
}
}
// RedissonLock.java 具体续期方法
private void renewExpiration() {
...
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;
}

RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}

if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

}

从上面分析我们就知道了,获取锁成功就会开启一个定时任务,也就是 watchdog,定时任务会定期检查去续期 scheduleExpirationRenewal(threadId).
这里定时用的是netty-common包中的 HashedWheelTimer,默认情况下,该定时调度每次调用的时间差是 internalLockLeaseTime / 3.也就10秒.

总结:
通过源码分析我们知道,默认情况下,加锁的时间是30秒.如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒.那业务的机器万一宕机了呢?宕机了定时任务跑不了,就续不了期,那自然30秒之后锁就解开了.