文章目录
Redisson
SETNX实现分布式锁
通过Redis来实现分布式锁,最简单的办法就是通过SETNX命令来实现,SETNX这个命令设置成功会返回1,失败则返回0,借助该命令的特性就可以实现分布式锁。
lua
-- 获取锁
SETNX lock 1
-- 释放锁
DEL lock
对应的Java代码实现
获取锁:
- 锁的KEY为业务标识,而value则是UUID拼接当前线程的ID来区分集群下的不同线程
- 获取锁的时候设置超时时间
释放锁:
- 为了避免释放锁的时候误删其它线程的锁,在释放锁之前要进行判断
- 显然判断锁和释放锁要通过Lua脚本来保证原子性
java
public class SimpleRedisLock implements ILock{
private StringRedisTemplate redisTemplate;
private static final String ID_PREFIX = UUID.randomUUID().toString();
private static final String KEY_PREFIX = "lock:";
private static final DefaultRedisScript UNLOCK_SCRIPT;
private String name;
public SimpleRedisLock(String name,StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.name = name;
}
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 返回值类型
UNLOCK_SCRIPT.setResultType(Long.class);
// 读取要执行的脚本
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
}
@Override
public boolean tryLock(long time) {
// 获取当前的线程ID
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 不同的业务
String key = KEY_PREFIX +this.name;
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, threadId, time, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
@Override
public void unLock() {
// 执行Lua脚本 (脚本对象,参数key,参数value)
redisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX +this.name),
ID_PREFIX + Thread.currentThread().getId());
}
}
lua脚本
lua
-- 那当前线程标识和Redis中存储的线程标识做比较
if (redis.call('get',KEYS[1]) == ARGV[1]) then
-- 释放锁
redis.call('del',KEYS[1])
return 1;
end
-- 不是该线程的锁释放失败
return 0
使用SETNX这种方式实现的分布式锁已经是可以正常使用了,但在某些场景还是会出现一些问题,SETNX存在以下问题:
- 重入问题:重入锁的出现主要是为了避免死锁,比如synchronized就实现了可重入锁,而SETNX实现的分布式锁并不是可重入锁,假设它在一个方法内调用内外一个加锁方法不就死锁了吗
- 不可重试问题:SETNX这种方式实现的分布式锁,只能尝试获取一次锁,并不能获取锁失败后重新尝试获取锁
- 超时释放问题:虽然在加锁的时候设置了过期时间,可以一定程度上避免死锁,但是如果该业务的卡顿时间过长,虽然使用了Lua脚本防止误删其它线程的锁,但是如果业务还灭有执行完成的时候锁过期,此时并没有锁,就会有一定的安全隐患
- 主从一致性问题: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题
引入Redisson
Redisson 是一个功能十分强大的 Redis Java 客户端,它提供了丰富的功能和API,支持同步和异步操作,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redisson使用分布式锁
添加Maven依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置Redisson客户端
java
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加单节点Redis地址
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123456");
// 创建客户端
return Redisson.create(config);
}
}
使用
java
// 创建可重入锁,并指定锁名称
RLock lock = redissonClient.getLock("lock:" + userId);
try {
// 尝试获取锁(获取锁的最大等待时间(期间会重试),锁自动释放的时间,时间单位)
boolean success = lock.tryLock(1,10,TimeUnit.SECONDS);
if (!success) {
return Result.fail("一个用户只能购买一张优惠卷");
}
// 执行业务逻辑...
}finally {
// 释放锁前判断当前线程是否持有锁
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson可重入锁原理
当通过Redisson客户端创建一把锁的,并且尝试获取的锁的时候,如果此时没有人获取,就会创建一把锁。并以锁的名称作为key,而value值是一个Map,map中有一对键值对,KEY为线程的标识,而value为锁的重入次数,如果次数为1,则表示第一次获取锁。
java
// 创建可重入锁,并指定锁名称
RLock lock = redissonClient.getLock("lock:" + userId);
// 尝试获取锁
boolean success = lock.tryLock();
来看一下对应获取锁源码,再tryLockInnerAsync
方法内有对于获取锁的lua脚本
java
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
//......
}
lua
-- KEYS[1] : 锁名称 ARGV[1]: 锁失效时间
-- ARGV[2]: id + ":" + threadId; 锁的小key(map对应的Key)
-- 判断该锁是否被创建
if (redis.call('exists', KEYS[1]) == 0) then
-- 如果没有被创建,就直接创建(hincrby不存在会自动创建)
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
-- 如果是则将重入次数+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]);
再来看一下释放锁的源码,再unlockInnerAsync
方法内有释放锁的lua脚本
java
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
//....
}
lua
-- KEYS[1] : 锁名称 ARGV[1]: 锁失效时间
-- ARGV[2]: id + ":" + threadId; 锁的小key(map对应的Key)
-- 判断该线程是否持有锁,没有直接return
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
-- 将当前重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0)
-- 如果重入次数>0直接更新超时时间
then redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
-- 如果重入次数等于0,直接删除锁
else redis.call('del', KEYS[1]);
-- 通知等待锁的线程锁已经释放
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
Redisson可重试原理
可重试
Redisson是支持获取锁失败进行重试的
java
RLock lock = redissonClient.getLock("lock:test:");
// 获取锁并指定等待重试时间为2秒
boolean success = lock.tryLock(2, TimeUnit.SECONDS);
查看对应源码
如果没有设置锁的释放时间,会使用默认的看门狗的超时释放时间
java
/**
waitTime 等待时间
*/
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return this.tryLock(waitTime, -1L, unit);
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// 锁自动释放时间为默认的-1,所以会走else
if (leaseTime != -1L) {
return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 锁的释放时间使用看门狗的默认超时时间 30秒
RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) {
if (ttlRemaining == null) {
this.scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
}
java
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 将等待时间转换为毫秒
long time = unit.toMillis(waitTime);
// 获取当前时间戳
long current = System.currentTimeMillis();
// 获取当前线程的ID
long threadId = Thread.currentThread().getId();
// 尝试获取锁,返回锁的剩余过期时间(对应获取锁的lua脚本的返回值)
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
// 如果锁被成功获取,返回true
if (ttl == null) {
return true;
} else {
// 减去获取锁的消耗的时间
time -= System.currentTimeMillis() - current;
// 如果等待时间已经用完,则获取锁失败,返回false
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
current = System.currentTimeMillis();
// 订阅锁的释放事件(对应释放锁的lua脚本的发送锁释放的消息)
RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
// 等待一段时间,看是否能获取到锁的释放事件
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
// 如果等待超时,则取消订阅并获取锁失败,返回false
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
this.unsubscribe(subscribeFuture, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
try {
// 减去已经消耗的时间
time -= System.currentTimeMillis() - current;
// 如果还有剩余等待时间
if (time > 0L) {
do {
// 获取当前时间戳
long currentTime = System.currentTimeMillis();
// 再次尝试获取锁,返回锁的剩余过期时间
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
// 如果锁成功获取
if (ttl == null) {
return true;
}
// 减去已经消耗的时间
time -= System.currentTimeMillis() - currentTime;
// 如果等待时间已经用完,则获取锁失败,返回false
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
}
currentTime = System.currentTimeMillis();
// 根据剩余时间尝试获取锁,ttl是别的线程tll毫秒后释放锁
if (ttl >= 0L && ttl < time) {
// 通过信号量机制来等待别的线程释放锁,在等待期间就已经释放了锁
// 等待别的线程释放锁的时长tll
// 这里采用信号量机制,等待释放锁的线程释放锁
((RedissonLockEntry)subscribeFuture.getNow()).
getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
// 如果这个ttl时间比当前线程尝试获取锁的时间还长
// 那么就直接等待尝试获取锁的时间
((RedissonLockEntry)subscribeFuture.getNow()).
getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 减去已经消耗的时间
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L); // 如果还有剩余等待时间,则继续尝试获取锁
// 如果等待时间已经用完,则获取锁失败,返回false
this.acquireFailed(waitTime, unit, threadId);
return false;
}
// 如果等待时间已经用完,则获取锁失败,返回false
this.acquireFailed(waitTime, unit, threadId);
return false;
} finally {
// 释放订阅
this.unsubscribe(subscribeFuture, threadId);
}
}
}
}
}
定时更新有效期
那么此时还有一个问题,如果当前持有锁的线程业务阻塞了,TTL到期了别其它线程获取到了锁,那么此时就会有安全问题了
而Redisson是通过看门狗来解决这个问题的
java
/**
* 尝试以异步方式获取锁的剩余过期时间。
* @param waitTime 等待时间
* @param leaseTime 锁的租期时间
* @param unit 时间单位
* @param threadId 当前线程ID
* @return 表示剩余过期时间的Future对象
*/
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// 如果租期时间不为-1,即锁具有过期时间
if (leaseTime != -1L) {
// 调用tryLockInnerAsync方法尝试获取锁
return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 如果租期时间为-1,即锁没有过期时间,则获取锁的过期时间为锁守护超时时间
// 调用tryLockInnerAsync方法尝试获取锁的剩余过期时间
RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(
waitTime,
this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS,
threadId,
RedisCommands.EVAL_LONG
);
// 在获取剩余过期时间的异步结果完成后,执行回调
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) { // 如果没有发生异常
// 如果剩余过期时间为null,表示锁成功获取,但是没有获取到剩余过期时间
// 对应尝试获取锁的Lua脚本返回值
if (ttlRemaining == null) {
// 更新锁的有效期(继续往下看文章)
this.scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture; // 返回表示剩余过期时间的Future对象
}
}
更新锁的过期时间的
java
/**
*更新锁的有效期
* @param threadId 当前线程ID
*/
private void scheduleExpirationRenewal(long threadId) {
// 这个entry里主要存储了两个东西,一个是更新锁释放时间的定时任务,还有一个就是线程ID
ExpirationEntry entry = new ExpirationEntry();
// 将entry添加到ConcurrentHashMap中,如果是第一次添加则会返回null
// 保证每次重入拿到的是同一个extry
ExpirationEntry oldEntry = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
if (oldEntry != null) {
// 将当前线程ID添加到旧的entry中
oldEntry.addThreadId(threadId);
} else {
// 如果旧的条目为null,说明是第一次添加该条目
// 将当前线程ID添加到map中
entry.addThreadId(threadId);
// 第一次来,就需要创建的更新释放时间的定时任务
this.renewExpiration();
}
}
更新过期时间代码
java
/**
* 续约锁的过期时间。
*/
private void renewExpiration() {
// 获取锁的过期续约条目
ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
// 如果续约条目不为null
if (ee != null) {
// 创建一个定时任务,用于定时执行续约操作
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
// 获取锁的过期续约条目
ExpirationEntry ent = (ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
// 如果续约条目不为null
if (ent != null) {
// 获取第一个等待续约的线程ID
Long threadId = ent.getFirstThreadId();
// 如果线程ID不为null
if (threadId != null) {
// 异步执行续约操作
RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
// 在续约操作完成后执行回调
future.onComplete((res, e) -> {
if (e != null) {
// 如果续约操作出现异常,则记录日志
RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
} else {
// 如果更新超时时间成功,继续递归更新超时时间
if (res) {
RedissonLock.this.renewExpiration();
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // 更新间隔为看门狗时间的1/3也就是30秒
// 将定时任务设置到续约条目中
ee.setTimeout(task);
}
}
更新锁释放时间的lua源码在renewExpirationAsync
方法里
java
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
//...
}
lua
-- 判断该锁是否当前线程持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 更新锁释放时间为看门狗的默认时间30
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
再来看一下释放锁的源码
java
/**
* 异步释放锁。
* @param threadId 当前线程ID
* @return 表示释放结果的Future对象
*/
public RFuture<Void> unlockAsync(long threadId) {
// 创建一个Promise对象,用于表示释放结果
RPromise<Void> result = new RedissonPromise();
// 异步执行内部的解锁操作
RFuture<Boolean> future = this.unlockInnerAsync(threadId);
// 在解锁操作完成后执行回调
future.onComplete((opStatus, e) -> {
// 取消锁的自动更新释放时间
this.cancelExpirationRenewal(threadId);
if (e != null) { // 如果解锁操作出现异常
// 设置Promise为失败状态,并将异常作为失败原因
result.tryFailure(e);
} else if (opStatus == null) { // 如果操作状态为null,说明锁未被当前线程持有
// 设置Promise为失败状态,并抛出IllegalMonitorStateException异常
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);
result.tryFailure(cause);
} else { // 否则,解锁成功
// 设置Promise为成功状态
result.trySuccess(null);
}
});
// 返回表示释放结果的Future对象
return result;
}
取消自动更新锁的释放时间方法
EXPIRATION_RENEWAL_MAP对应上面添加更新任务,这个Map里存的是一个个ExpirationEntry,ExpirationEntry里主要包含定时更新锁释放时间的任务和线程ID
java
/**
* 取消锁的过期续约。
* @param threadId 要取消续约的线程ID,如果为null,则表示取消所有线程的续约
*/
void cancelExpirationRenewal(Long threadId) {
// 获取锁的过期续约任务
ExpirationEntry task = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
// 如果过期续约任务不为null
if (task != null) {
// 如果线程ID不为null,则移除指定线程的续约
if (threadId != null) {
task.removeThreadId(threadId);
}
// 如果线程ID为null,或者任务已经没有任何线程在续约
if (threadId == null || task.hasNoThreads()) {
// 获取任务的定时器任务
Timeout timeout = task.getTimeout();
// 如果定时器任务不为null,则取消定时器任务
if (timeout != null) {
timeout.cancel();
}
// 从过期续约映射中移除该任务
EXPIRATION_RENEWAL_MAP.remove(this.getEntryName());
}
}
}
小结
尝试获取锁时可以指定3个参数
java
// 尝试获取锁(获取锁的最大等待时间(期间会重试),锁自动释放的时间,时间单位)
boolean success = lock.tryLock(1,10,TimeUnit.SECONDS);
- 首先线程来获尝试获取锁(调用Lua脚本),判断Lua脚本的返回值TTL是否为NULL(为剩余释放时间说明获取失败)
- ttl为空说明获取成功,判断锁的释放时间是否设置,为-1表示未设置,则开启看门狗(看门狗的释放锁时间为30秒)
- 如果自己设置了锁的释放时间则不会启用开门狗,也就是是不会自动更新释放时间
- 如果获取TTL不为null说明锁被其他线程给占用了,被占用就去判断当前线程的剩余等待时间是否大于0
- 剩余等待时间不大于0则说明等待超时直接返回false表示获取锁失败
- 剩余等待时间大于0则订阅等待锁的释放信号,等待别的线程的剩余释放时间
- 如果别的线程的释放时间大于等于当前获取锁线程的最大等待时间,则当前线程直接等待最大等待时间
- 等待完毕后判断等待时间是否超时,是则返回false获取锁失败
- 没有超时则继续尝试获取锁
释放锁流程
- 线程尝试获取锁,判断是否获取成功
- 如果获取 成功,则发生释放锁消息给其他订阅线程,并取消看门狗
- 获取失败则记录异常
Redisson分布式锁原理
- 可重入:利用hash结构记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
- 超时续约:利用看门狗机制,每隔一段时间 (releaseTime/3),重置超时时间
Redisson分布式锁主从一致性问题
如果采用Redis主从来实现分布式锁,当主节点宕机了,而锁的信息还没有同步过去,就会导致锁的信息丢失。
为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
通过这种联锁的方式就可以解决锁信息丢失的问题。