简单使用
下面通过 Redisson 框架封装的分布式锁机制,来演示一下分布式锁的实现。
- 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
</dependency>
- 测试
public class RedisLockMain {
private static RedissonClient redissonClient;
static {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
redissonClient = Redisson.create(config);
}
public static void main(String[] args) throws InterruptedException {
RLock rLock = redissonClient.getLock("updateRepo");
for (int i = 0; i < 10; i++) {
if (rLock.tryLock()) { //返回true,表示获得锁成功
System.out.println("获得锁成功");
} else {
System.out.println("获得锁失败");
}
Thread.sleep(2000);
rLock.unlock();
}
}
}
Redisson 分布式锁的实现原理
你们会发现,通过 redisson,非常简单就可以实现我们所需要的功能,当然这只是 redisson 的冰山一角,redisson 最强大的地方就是提供了分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的并发程序的工具包获得了协调分布式多级多线程并发系统的能力,降低了程序员在分布式环境下解决分布式问题的难度,下面分析一下 RedissonLock 的实现原理:
RedissonLock.tryLock
@Override
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();
//通过tryAcquire方法尝试获取锁
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) { //表示成功获取到锁,直接返回
return true;
}
//省略部分代码....
}
tryAcquire
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
//leaseTime就是租约时间,就是redis key的过期时间。
if (leaseTime != -1) { //如果设置过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {//如果没设置了过期时间,则从配置中获取key超时时间,默认是30s过期
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
//当tryLockInnerAsync执行结束后,触发下面回调
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
tryLockInnerAsync
通过 lua 脚本来实现加锁的操作;
- 判断 lock 键是否存在,不存在直接调用 hset 存储当前线程信息并且设置过期时间,返回 nil,告诉客户端直接获取到锁。
- 判断 lock 键是否存在,存在则将重入次数加 1,并重新设置过期时间,返回 nil,告诉客户端直接获取到锁。
- 被其它线程已经锁定,返回锁有效期的剩余时间,告诉客户端需要等待。
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), 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(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
unlock 释放锁流程
释放锁的流程,脚本看起来会稍微复杂一点
- 如果 lock 键不存在,通过
publish
指令发送一个消息表示锁已经可用。 - 如果锁不是被当前线程锁定,则返回 nil
- 由于支持可重入,在解锁时将重入次数需要减 1
- 如果计算后的重入次数 > 0,则重新设置过期时间
- 如果计算后的重入次数 <=0,则发消息说锁已经可用
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 有竞争的情况
有竞争的情况在 redis 端的 lua 脚本是相同的,只是不同的条件执行不同的 redis 命令。当通过 tryAcquire 发现锁被其它线程申请时,需要进入等待竞争逻辑中。
- this.await 返回 false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
- this.await 返回 true,进入循环尝试获取锁。
继续查看tryLock相关源码。
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//省略部分代码
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅监听redis消息,并且创建RedissonLockEntry
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 阻塞等待subscribe的future的结果对象,如果subscribe方法调用超过了time,说明已经超过了客户端设置的最大wait time,则直接返回false,取消订阅,不再继续申请锁了。
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) { //取消订阅
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId); //表示抢占锁失败
return false; //返回false
}
try {
//判断是否超时,如果等待超时,返回获的锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
//通过while循环再次尝试竞争锁
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId); //竞争锁,返回锁超时时间
// lock acquired
if (ttl == null) { //如果超时时间为null,说明获得锁成功
return true;
}
//判断是否超时,如果超时,表示获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// 通过信号量(共享锁)阻塞,等待解锁消息. (减少申请锁调用的频率)
// 如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
// 否则就在wait time 时间范围内等待可以通过信号量
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) { //获取锁失败
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId); //取消订阅
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
锁过期了怎么办?
一般来说,我们去获得分布式锁时,为了避免死锁的情况,我们会对锁设置一个超时时间,但是有一种情况是,如果在指定时间内当前线程没有执行完,由于锁超时导致锁被释放,那么其他线程就会拿到这把锁,从而导致一些故障。
为了避免这种情况,Redisson 引入了一个 Watch Dog 机制,这个机制是针对分布式锁来实现锁的自动续约,简单来说,如果当前获得锁的线程没有执行完,那么 Redisson 会自动给 Redis 中目标 key 延长超时时间。
默认情况下,看门狗的续期时间是 30s,也可以通过修改 Config.lockWatchdogTimeout 来另行指定。
@Override
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return tryLock(waitTime, -1, unit); //leaseTime=-1
}
实际上,当我们通过 tryLock 方法没有传递超时时间时,默认会设置一个 30s 的超时时间,避免出现死锁的问题。
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时,leaseTime=internalLockLeaseTime,默认是30s,表示当前锁的过期时间。
//this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
由于默认设置了一个 30s 的过期时间,为了防止过期之后当前线程还未执行完,所以通过定时任务对过期时间进行续约。
- 首先,会先判断在 expirationRenewalMap 中是否存在了 entryName,这是个 map 结构,主要还是判断在这个服务实例中的加锁客户端的锁 key 是否存在,
- 如果已经存在了,就直接返回;主要是考虑到 RedissonLock 是可重入锁。
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {// 第一次加锁的时候会调用,内部会启动WatchDog
entry.addThreadId(threadId);
renewExpiration();
}
}
定义一个定时任务,该任务中调用 renewExpirationAsync
方法进行续约。
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;
}
// renewExpirationAsync续约租期
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);//每次间隔租期的1/3时间执行
ee.setTimeout(task);
}
执行 Lua 脚本,对指定的 key 进行续约。
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));
}
Redis 中的 Pub/Sub 机制
下面是 Redisson 中释放锁的代码,在代码中我们发现一个 publish 的指令 redis.call('publish', KEYS[2], ARGV[1])
,这个指令是干啥的呢?
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));
}
Redis 提供了一组命令可以让开发者实现 “发布 / 订阅” 模式 (publish/subscribe) . 该模式同样可以实现进程间的消息传递,它的实现原理是:
- 发布 / 订阅模式包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或多个频道,而发布者可以向指定的频道发送消息,所有订阅此频道的订阅者都会收到该消息
- 发布者发布消息的命令是 PUBLISH, 用法是
PUBLISH channel message
比如向 channel.1 发一条消息:hello:
PUBLISH channel.1 “hello”
这样就实现了消息的发送,该命令的返回值表示接收到这条消息的订阅者数量。因为在执行这条命令的时候还没有订阅者订阅该频道,所以返回为 0. 另外值得注意的是消息发送出去不会持久化,如果发送之前没有订阅者,那么后续再有订阅者订阅该频道,之前的消息就收不到了。
订阅者订阅消息的命令是:
SUBSCRIBE channel [channel …]
该命令同时可以订阅多个频道,比如订阅 channel.1 的频道:SUBSCRIBE channel.1,执行 SUBSCRIBE 命令后客户端会进入订阅状态。
一般情况下,我们不会用 pub/sub 来做消息发送机制,毕竟有这么多 MQ 技术在。