通过锁可以避免竞争带来的数据不一致的问题。Java中的锁,Synchronized、Lock锁只能解决同一个JVM进程中的线程竞争带来的此类问题。对于集群部署,和分布式部署的便无能为力了。
解决此类问题的思路和解决分布式全局id生成器的思路类似。都需要依靠于数据库生成唯一的标识供给多个服务进程使用。
原理
下面介绍一下分布式锁在实现过程中会遇到哪些问题,Redis又是如何解决这些问题的。
加锁
加锁要解决两个问题,锁不能被失效,通过设置锁的过期时间防止死锁问题。
先介绍一下Redis两个命令。
SETNX key value
SETNX 是 SET if Not Exists, 效果是如果key不存在,则set成功返回1,否则返回0.
这个命令解决了锁失效的问题。
SETEX key seconds value
SETEX是 SET Expire的意思。将key设置一个过期时间,并将key的生存时间设置为seconds(以秒为单位)
这个命令解决死锁的问题。
另外,还需要保证设置锁和设置锁过期时间是一个原子操作,幸好,Redis的LUA脚本,执行时带有原子性,解决了这个问题。
锁续期
解决完加锁的问题,锁还有可能出现,线程A获取锁,但是还未执行完毕,锁就过期了。因而线程B也成功的获取到了锁。这样就造成了A和B同时拥有了锁。所以需要引入锁续期。
具体的实现方式是在线程获取锁的时候,开启一个守护线程,给线程进行锁续期。当执行业务逻辑的线程执行完毕后关掉守护线程。
解锁
解锁的逻辑就比较简单了,就是需要确保,谁加锁,谁解锁,或者被加锁的对象挂了,由过期时间自动解锁。
Redission的实现
上面的锁只是最基础的锁逻辑讨论,其他比较复杂的锁,例如可重入锁(Reentrant Lock)等,实现起来比较复杂。下面来介绍一下Redission是如何实现可重入锁的。
下面先来,应用一下Redission,后面从日志文件到源码一步步分析它是如何实现可重入的分布式锁。
案例
Redission支持redis单例,主从,哨兵,集群模式。不同的模式配置有些许区别。
单机模式
private static Config getSignalRLock() {
Config config = new Config();
// 默认的看门狗时间为30s,为了便于观察设置为6s
config.setLockWatchdogTimeout(6000L);
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(13);
return config;
}
其他模式的配置,可以参考Redission文档
测试代码
public static final String REDIS_LOCK_FLAG = "redis_lock_flag";
public static final Integer NUM = 2;
public static final Logger logger = LoggerFactory.getLogger(Demo.class);
public static void main(String[] args) {
// 获取锁对象
Config signalRLock = getSignalRLock();
RedissonClient redissonClient = Redisson.create(signalRLock);
final RLock lock = redissonClient.getLock(REDIS_LOCK_FLAG);
// 创立线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(NUM, NUM, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
// 防止子线程还未执行完毕, 主线程就退出了, 导致看不到日志
final CountDownLatch countDownLatch = new CountDownLatch(NUM);
for (int i = 0; i < NUM; i ++) {
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
// 加锁
logger.info(Thread.currentThread().getName() + " is ready to get lock");
lock.lock();
logger.info(Thread.currentThread().getName() + " get lock");
try {
Thread.sleep(3000L);
}catch (Exception e){
e.printStackTrace();
}
// 解释
lock.unlock();
logger.info(Thread.currentThread().getName() + " release lock");
countDownLatch.countDown();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
代码的执行日志如下(简化信息):
// 线程2 和 线程1 出现了竞争锁的关系
15:55:53.518 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 is ready to get lock
15:55:53.518 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 is ready to get lock
// 线程1 尝试获取锁
15:55:53.540 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0)...
// 线程2 也尝试获取锁
15:55:53.540 [pool-2-thread-2] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0)...
// 线程2 获取到了锁
15:55:53.560 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 get lock
15:55:53.603 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command
// 看门狗线程 执行锁续期 [注意该日志时间 与 线程2 获取锁的时间 相差了2s.]
15:55:55.634 [pool-1-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)...
// 线程2任务执行完毕 释放锁
15:55:56.561 [pool-2-thread-2] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)...
15:55:56.577 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 release lock
// 线程1 再次尝试获取锁
15:55:56.584 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0) ...
15:55:56.614 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 get lock
// 看门狗线程 执行锁续期
15:55:58.635 [pool-1-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) ...
// 线程1任务执行完毕 释放锁
15:55:59.619 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)...
15:55:59.624 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 release lock
可以看到Redission锁发挥了作用。
加锁
先来看看,lock方法的执行源码
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
// 尝试获取锁
// ttl 为Time to live 存活时间的意思
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(leaseTime, unit, threadId);
// 获取成功返,则返回
if (ttl == null) {
return;
}
// 获取失败,则订阅到对应这个锁的channel
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
// 再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
// 获取成功 返回
if (ttl == null) {
break;
}
// ttl大于0 则等待ttl时间后继续尝试获取
if (ttl >= 0) {
try {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
}
// ttl小于0 说明 锁已经过期了,尝试获取锁
else {
if (interruptibly) {
getEntry(threadId).getLatch().acquire();
} else {
getEntry(threadId).getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(future, threadId);
}
}
接下来,看看tryAcquire方法,到底干了啥。逐步步深入到tryAcquireAsync方法
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
// 有参方法调用
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 无参方法调用
// 默认的过期时间为30s
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
// 锁续期 相关代码
...
}
接下来,看看tryLockInnerAsync的源码
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
// 转换过期时间
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 如果锁不存在,则通过hset命令设置线程id作为它的值,并且设置过期时间
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
// 如果锁已存在,并且为持有锁的为当前线程,则通过hincrby 将当前值+1(可重入)
"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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
下面,画个流程图总结一下整个加锁的过程
需要注意的是,传入leaseTime的方法,不会开启锁续期的线程,所以可能会出现锁被多个线程同时持有的情况。
锁续期
上文中提到了锁续期的情况,现在来看看锁续期是如何实现的。目光再次回到加锁的方法tryAcquireAsync.
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
// 加锁相关方法
...
// 锁续期相关代码
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
// e表示异常
if (e != null) {
return;
}
// ttlRemaining 为null 表示加锁成功
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
可以看到scheduleExpirationRenewal方法是精华所在,继续往下走,看看这个方法做了啥。
private void scheduleExpirationRenewal(long threadId) {
// 此处是EXPIRATION_RENEWAL_MAP 结构为 ConcurrentMap<String, ExpirationEntry>
// String 为当前RedissionLock 的EnteryName
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
// 如果oldEntery 不为空, 说明当前锁已被当前线程占有(锁重入了)。不需要额外开启守护线程
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
}
// 如果oldEntery不存在,需要开启守护线程
else {
entry.addThreadId(threadId);
renewExpiration();
}
}
可以看到该方法主要是为了区分,当前占有锁的线程是否为可重入占有,如果是重入占有,则不需要额外开启锁续约线程。因为之前已经存在了锁续约线程。接下来看看renewExpiration方法具体做了啥。
private void renewExpiration() {
// 为null直接返回
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// Timeout newTimeout(TimerTask task, long delay, TimeUnit unit);
// 任务延时执行,延时时间 为 设定的 锁过期时间的 1/3。
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);
// res -- 续约结果 , e续约过程中的异常
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
// 如果为true重复调用自身
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
// 续期LUA 方法
// 如果当前线程占有锁,并且还未过期,给锁重新设置过期时间 并且返回true
// 其他情况,返回false
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return commandExecutor.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.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
到这里,总算明白了,锁续期是如何生效的了。renewExpiration会开启一个延时 1/3 过期时间的续期任务。续期任务续期成功,则重复调用自身。相当于又开启了一个延时的续期任务。续期失败,续约线程则方法执行完毕,自动失效。
另外。这个延时 1/3 过期时间的续期任务,解释了为什么案例中线程每2s进行一次锁续约。
再画一个锁续期的流程图。
解锁
解锁部分调用的方法为unlock,具体的实现方法为unlockAsync,看看它是怎么实现解锁的
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<Void>();
// 调用LUA进行解锁,待会深入了解一下
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.onComplete((opStatus, e) -> {
// 如果解锁异常
if (e != null) {
cancelExpirationRenewal(threadId);
result.tryFailure(e);
return;
}
// 如果解锁状态返回为null
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;
}
// 取消续约线程方法
// 具体的做法是 获取 threadId对应的Entry 的值减1
// 如果减1后的值为0, 则删除该Entry
cancelExpirationRenewal(threadId);
result.trySuccess(null);
});
return result;
}
可以,看到解锁的主要方法为unlockInnerAsync, 余下的部分为对其返回参数进行处理的代码。看看unlockInnerAsync到底做了什么。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 获取锁值减1后的值,释放一次锁
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 如果值大于0, 说明释放后,仍然由当前线程持有锁,返回false
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
// 值等于0,说明锁释放成功。发布锁释放的消息,并且返回true
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
// 其他情况返回null
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
可以看到,锁的释放,主要进行的操作就是调用unlockInnerAsync进行原子操作进行解锁。另外加上取消续约线程方法对threadId对应的ExpirationEntry进行减1或者删除操作。
再次画个流程图
总结
整个源码流程看下来,学习到了Redission实现了通过与AQS类似的思想,实现了可重入锁。另外值得关注的就是实现锁续期的代码,主要就是循环调用自身的延时任务,当锁被释放时或者ExpirationEntry不存在时,便不再续约。
另外调用有参的tryLock方法时,不会起开续约任务,所以可能会导致多个线程同时占用锁的问题。
而且使用多个Redis节点时,还可能存在在一个节点上加了锁,还没有同步到其他节点,该节点就宕机了,又有另外一个线程拿着同一把锁进来也可以加锁成功的情况。这也是 Redis 作为分布式锁的一个痛点。Redis 集群之间的同步是异步的,是 AP 模型,并不能保证完全的数据一致性。但是 Redis 的作者使用 Red Lock 来解决这个问题。
引用
本文参考自
个人博主基于Redis的分布式锁之ReentrantLock的文章
掘金用户分布式锁之Redis实现的文章