背景
最近入职了一家新公司,有一道题需要使用redis-lock解决分布式并发问题,所以这篇文章主要研究一下redis-lock是如何实现的。
首先,题目以及解析如下:
// 分布式的Lock,写一个多线程,创建一个Map给Map添加一个key-value,三个线程同时对这个key的value进行递增,保证线程安全。
@Test
public void testRedisson() throws InterruptedException {
RLock lock = Redisson.getLock("lock1");
RMapCache<Object, Object> map = Redisson.getCachedMap("count2");
map.put("count2", 0);
for (int i = 0; i < 3; i++) {
// 此处用的自定义线程池
threadPool.execute(() -> {
for (int j = 0; j < 10; j++) {
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
int value = (Integer)map.get("count2");
value++;
map.put("count2", value);
System.out.println("线程" + Thread.currentThread().getName() + " : " + value);
} else {
j--;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if(lock.isHeldByCurrentThread() && lock.isLocked()) {
lock.unlock();
}
}
}
});
}
sleep(5000);
}
分布锁特点
可以类比 JDK 中的锁
互斥:不仅要保证同个服务中不同线程的互斥,还需要保证不同服务间、不同线程的互斥,如何处理互斥,是自旋、还是阻塞 ,还是其他 ?
超时: 锁超时设置,防止程序异常奔溃而导致锁一直存在,后续同把锁一直加不上
续期: 程序具体执行的时长无法确定,所以过期时间只能是个估值,那么就不能保证程序在过期时间内百分百能运行完成,所以需要进行锁续期,保证业务能够正常执行完。
可重入: 可重入锁又名递归锁,是指同一个线程在外层方法已经获得锁,再进入该线程的中层或内层方法会自动获取锁,简单点来说,就是同个线程可以反复获取同一把锁。
专一释放: 通俗点来讲:谁加的锁就只有它能释放这把锁,为什么会出现这种错乱释放的问题了,举个例子就理解了,线程 T1 对资源 lock_zhangsan 加了锁,由于某些原因,加锁业务还未执行完,锁过期自动释放了,此时线程 T2 对资源 lock_zhangsan 加锁成功。T2 执行业务的时候,T1 业务执行完后释放资源 lock_zhangsan 的锁,结果把 T2 加的锁给释放了
公平与非公平: 公平锁:多个线程按照申请锁的顺序去获得锁,所有线程都在队列里排队,这样就保证了队列中的第一个先得到锁。非公平锁:多个线程不按照申请锁的顺序去获得锁,而是同时直接去尝试获取锁。JDK 中的 ReentrantLock 就有公平和非公平两种实现,有兴趣的可以去看看它的源码,多数情况下用的是非公平锁,但有些特殊情况下需要用公平锁
源码解读
tryLock源码
trtLock的主流程
Redission是怎么去tryLock的?
RedissonLock中的 tryLock + 时间是怎么实现时间内获取锁,超时则放弃
- tryAcquire尝试获取锁,如果获取到返回true
- 获取不到锁说明锁被占用了,订阅解锁消息通知
- 收到解锁消息通知,再次尝试获取锁,如果获取不到重复步骤三,直到超过waitTime获取锁失败
- 不论是否获取锁成功,取消解锁消息订阅。
// 在waitTime时间范围内尝试获取锁,如果获取到锁,则设置锁过期时间leaseTime
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 第一步:尝试获取锁
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
// ttl为空说明获取到了锁
if (ttl == null) {
return true;
} else {
// 判断尝试获取锁是否超过waitTime
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
// 第二步:订阅解锁消息通知
current = System.currentTimeMillis();
// 订阅锁释放
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
try {
// 订阅锁释放消息,等待时间超过waitTime,获取锁失败
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException("Unable to acquire subscription lock after " + time + "ms. Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
// 如果订阅解锁Future在执行中,等任务执行完后取消订阅锁释放
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
// 取消订阅解锁通知
this.unsubscribe(res, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException var22) {
this.acquireFailed(waitTime, unit, threadId);
return false;
}
boolean var16;
try {
// 判断尝试获取锁以及订阅解锁消息的时间是否超过waitTime
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
boolean var24 = false;
return var24;
}
// 第三步:自旋尝试获取锁
do {
long currentTime = System.currentTimeMillis();
// 1、尝试获取锁
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
var16 = true;
return var16;
}
// 判断尝试获取锁是否超过waitTime
time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
// 等待锁释放
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
// 尝试获取信号量((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 判断尝试获取锁是否超过waitTime
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
} finally {
// 第四步:取消解锁订阅this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
return var16;
}
}
}
抢锁代码
下面是加锁的代码实现,包括抢锁以及看门狗的实现
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 这里需要注意的是leaseTime==-1,会触发redisson看门狗机制,此处的方法使用的是lua脚本
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// 获取锁成功
if (ttlRemaining == null) {
if (leaseTime != -1) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 锁自动续时(看门狗机制)触发条件leaseTime == -1;此处底层也是lua脚本,下面会详述
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
抢锁的lua脚本
在下列代码中的lua脚本操作是原子的,其实lua脚本本身不是原子的,但是redis在执行lua脚本的操作是原子的。
// 下列代码中变量含义
// KEYS[1] = "锁key"
// ARGV[1] = "锁过期时间"
// ARGV[2] = "当前连接的UUID + : + 线程id“ 对应后面的getLockName方法返回
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
// 如果key一开始就不存在,则直接创建一个key
"if (redis.call('exists', KEYS[1]) == 0) then " +
// 使用 `HINCRB。Y` 将哈希表 `KEYS[1]` 中的字段 `ARGV[2]`(表示当前线程的锁计数器)加 1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
// 设置该锁的过期时间为 `ARGV[1]`(即 `leaseTime` 转换为毫秒)。
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 这里是重入锁的实现,同一个线程多次获取锁只需要在value加1即可,value相当于一个加锁计数器
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
// `HEXISTS` 用于检查当前线程(由 `ARGV[2]` 标识)是否已经持有锁。
// 如果线程已经持有锁(即锁为重入),通过 `HINCRBY` 将计数器加 1,并重置锁的过期时间。
"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));
}
看门狗的实现
续时间的方法解读
protected void scheduleExpirationRenewal(long threadId) {
// 保存当前加锁key有那些线程自动续时,取消自动续时后会清除此对象内部数据
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
try {
// 更新锁过期时间
renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
cancelExpirationRenewal(threadId);
}
}
}
}
此处是更新时间操作:首先判断锁是否占用,如果占用,则使用定时任务对当前锁进行续时。
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 定时任务(可以搜io.netty.util.HashedWheelTimer)
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;
}
// 更新锁过期时间(lua脚本)
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
// 更新锁过期时间成功
if (res) {
// 递归调用 如果10秒后依然没有解锁,继续更新锁过期时间
renewExpiration();
} else {
cancelExpirationRenewal(null);
}
});
}
// internalLockLeaseTime在不设置lockWatchdogTimeout情况下默认30s,这里会延迟10s触发此任务
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
看门狗对应的更新过期时间的lua脚本
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), 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(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
unLock源码解读
public void unlock() {
try {
// 解锁
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
}
unlockAsync()方法内部会调用lua解锁脚本
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), 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(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
加解锁的订阅发布类
在redissonLock中会有一个LockPubSub的实体类,这个就是消息发布订阅对应的实体,
可以看到此实体类,在发现消息时,会比较是否是释放锁的消息,如果是则将队列中的任务进行释放,通过回调的runnable来通知等待的线程
public class LockPubSub extends PublishSubscribe<RedissonLockEntry> {
public static final Long UNLOCK_MESSAGE = 0L;
public static final Long READ_UNLOCK_MESSAGE = 1L;
public LockPubSub(PublishSubscribeService service) {
super(service);
}
protected RedissonLockEntry createEntry(CompletableFuture<RedissonLockEntry> newPromise) {
return new RedissonLockEntry(newPromise);
}
protected void onMessage(RedissonLockEntry value, Long message) {
Runnable runnableToExecute;
if (message.equals(UNLOCK_MESSAGE)) {
runnableToExecute = (Runnable)value.getListeners().poll();
if (runnableToExecute != null) {
runnableToExecute.run();
}
value.getLatch().release();
} else if (message.equals(READ_UNLOCK_MESSAGE)) {
while(true) {
runnableToExecute = (Runnable)value.getListeners().poll();
if (runnableToExecute == null) {
value.getLatch().release(value.getLatch().getQueueLength());
break;
}
runnableToExecute.run();
}
}
}
}
总结
加锁操作:
- tryAcquire尝试获取锁,会通过lua脚本判断是否有锁占用,如果已经有锁占用则判断是否是当前客户端,如果是当前客户端,则说明是可重入的锁,当前客户端可以继续获得这把锁,如果获取到返回true。如果没抢到当前锁,则使用看门狗定时任务,每隔10s对持有锁加上过期时间。
- 获取不到锁说明锁被占用了,订阅解锁消息通知
- 收到解锁消息通知,再次尝试获取锁,如果获取不到重复步骤三,直到超过waitTime获取锁失败
- 不论是否获取锁成功,取消解锁消息订阅。
解锁操作:
- 发布解锁消息通知。
- 监听器判断为解锁操作时,执行等待队列中的任务,放开对应的锁。
加解锁消息通知
- 使用的是定义的LockPubSub进行通讯。
- 加锁时订阅此通道,
- 解锁时在此通道发布消息。