摘要:
本文介绍了基于Redis实现分布式锁的多种方案及其优化策略。主要内容包括:1) 使用Redis的SETNX命令实现基础分布式锁,通过线程ID标识解决误删问题,并采用Lua脚本保证原子性;2) Redisson重入锁的实现原理,利用Hash结构记录重入次数;
3) Redisson的锁重试机制,通过订阅/发布模式优化性能;4) 自动续期机制(看门狗)保障长任务执行;5) Redis集群环境下的分布式锁实现,重点说明多节点锁获取与时间同步策略。文章详细阐述了各种方案的实现细节和源码分析,为分布式系统开发提供了可靠的锁解决方案。
一,基于redis实现分布式锁
1,常见的分布式锁方案
(1)mysql数据库锁
(2)redis锁(setnx)
(3)zookeeper组件
2,redis实现分布式锁原理
set lock thread1 nx ex 20(过期时间) #获取锁
del lock #释放锁
3,redis分布式锁初级实现
(1)定义ILock接口及其实现类SimpleRedisLock,实现tryLock(),unLock()方法。
(2)以线程id拼接前缀作为value,锁前缀lock:+业务名作为key(指定锁范围)
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().threadId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
//如果直接返回success(万一为null),因为自动拆箱,可能会出现空指针异常
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
4,redis分布式锁优化,解决误删问题
因为业务功能复杂,线程1阻塞时间较长,超过了过期时间,锁被自动释放而被其他线程拿到,等到第一个线程完成删除其他线程的锁的情况。
解决方案:在删除锁前增加判断,通过线程标识来对比确认当前锁是否属于当前线程,如果是才会执行删除操作
private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
@Override
public void unLock() {
String currentThreadId = ID_PREFIX + Thread.currentThread().threadId();
//判断是否是当前线程,避免误删
String lockThreadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//如果当前线程持有锁,则释放锁
if (currentThreadId.equals(lockThreadId)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
5,lua脚本保证释放锁的原子性
线程在释放锁时通过了线程标识的判断,但是在最后一步删除操作前发生阻塞,阻塞时间超过设置的过期时间,锁被自动释放导致其他线程拿到锁,原来线程苏醒删除锁的情况。
解决方案:问题出现在线程判断和删除这一系列操作无法保证原子性,所以采用lua脚本来原子地执行这个比较和删除逻辑。
(1)java的stringRedisTemplate调用lua文件
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
//可以通过构造函数传入文件字符串
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//采用spring的读取文件资源的ClassPathResource
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
//基于Lua脚本实现原子操作
@Override
public void unLock() {
//调用lua脚本,本质就是redis的Eval命令
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().threadId());
}
(2)lua文件编写比较删除逻辑(KEYS,ARGV都是不定参数)
if(redis.call('get', KEYS[1]) == ARGV[1]) then
---释放锁
return redis.call('del', KEYS[1])
end
return 0
二,redisson配置
1,配置依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.15.6</version>
</dependency>
2,定义配置类注册RedissonClient
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redisClient(){
//配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.xxx.xxx:6379")
.setPassword("xxxxx").setDatabase(1);
//创建RedissonClient对象
return Redisson.create(config);
}
}
3,注入RedissonClient,调用getLock()方法拿到锁
RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId);
boolean isLock = lock.tryLock();
............
lock.unlock();
三,redisson重入锁原理(使用redis的Hash类型实现)(源码)
key--锁的名字,field--线程标识,value--线程重入次数,通过lua脚本保证redis操作原子性
1,tryLock() 获取锁
tryLock底层KEYS[1]--key ARGV[1]--过期时间 ARGV[2]--field(线程id)
(1)判断锁是否存在,如果不存在,创建一个Hash并且使value增加1然后设置有效期,返回null
(2)如果锁已经存在,通过线程标识看锁是否是当前线程的,如果是,同样使value增加1,更新有效期,最后返回空
(3)如果不是当前线程,说明锁已经被占用,返回锁的剩余有效期
"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]);"
2,unLock()释放锁
unlock底层KEYS[1]--key ARGV[1]--消息 ARGV[2]--过期时间 ARGV[3]--线程id
(1)判断锁是否属于当前线程,不属于返回空
(2)如果属于就把锁的value即重入次数减1,并且记录value的值
(3)如果值还大于0,说明线程还持有锁,重置有效期
(4)否则说明线程已释放锁,那么删除key并且发布一个消息
"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;",
四,redisson重试机制(源码)
getLock()可传入三个参数,分别是最大尝试时间(重试的最大时间)、过期时间和时间单位
(1)返回锁的剩余过期时间,如果为空说明成功拿到锁,如果在拿锁返回时最大尝试时间已经到期,返回false
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
(2)如果没有指定过期时间leaseTime等于-1,leaseTime默认看门狗超时--30s
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
(3) 如果时间还有剩余,准备重试,但是不是立即重试,因为刚刚获取锁失败,大概率也获取不到,反而增加cpu负担,所以调用订阅方法subscribe(threadId),订阅其他线程释放锁的信号(最终释放锁的lua脚本里有一个publish命令就是表示发布一个信息通知)
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
(4)如果这个future(subscribeFuture)在指定时间完成返回true,否则false,time--剩余等待时间,未完成取消订阅返回失败
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;
}
(5)如果等待成功,先计算剩余尝试时间,如果时间充足,准备加入while循环进行不断重试,先再次获取锁。
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
(6)如果获取失败并且时间还有剩余,再次重试,依旧采用类似订阅的方法,获取信号后执行,等待时间取ttl或者time(谁更小取谁)如果剩余重试时间不足退出循环返回失败,否则继续循环
// 再次重试
currentTime = System.currentTimeMillis();
//过期时间小于剩余时间,说明时间还没到就已经释放
if (ttl >= 0 && ttl < time) {
// 使用future尝试获取锁,这里类似订阅,获取信号后执行,最大等待时间取ttl
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
// 剩余时间更长,最大等待时间取剩余时间
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
// 如果剩余时间不足,返回失败
cquireFailed(waitTime, unit, threadId);
return false;
}
// 如果还有时间,再次循环,直到时间不足返回失败
总结:使用订阅机制避免无效重试,对时间的判断非常严谨,保证最大重试时间。
五,超时释放(源码)
(1)当future完成以后,就会执行{}内容(ttlRemaining剩余有效期,e异常)获取锁成功,开启自动更新续期方法;如果自己指定了过期时间就不会开启按指定的时间执行
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
if (ttlRemaining == null) {
if (leaseTime != -1) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
scheduleExpirationRenewal(threadId);
}
}
});
(2)创建一个ExpirationEntry,将新的Entry放入静态全局常量EXPIRATION_RENEWAL_MAP中,所有RedissonLock实例都共享这个map,每个锁都会有唯一的EntryName和ExpirationEntry,putIfAbsent只有不存在才会添加,所以重试(同一个线程)时不会向里面添加键值对
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
// getEntryName()=连接id+“:”+锁名字
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
........
}
(3)如果一个新的线程拿到锁,旧的entry肯定为空,entry中加入线程id并且调用开启刷新有效期任务的方法renewExpiration(),所以entry里封装了线程id和这个重复刷新有效期的定时任务
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
renewExpiration();
}
}
(4)开启定时任务,三个参数:任务本身,任务延时(这个时间到了以后才会执行任务),时间单位,任务延时为看门狗时间/3即10s,每隔10s执行这个定时任务刷新有效期,将任务加入到entry中
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;
}
..........
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
(5)异步刷新有效期renewExpirationAsync方法(就是lua脚本),因为参数返回时间不确定所以用Future接收,拿到参数时执行onComplete{....},没有异常再次调用方法本身
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();
}
});
(6)使用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));
}
(7)这个定时任务不断刷新,什么时候结束呢?答案就在释放锁时取消定时刷新有效期任务,移除线程id,取消任务并且在map中移除entry
//释放锁时结束无限的定时任务的方法
protected void cancelExpirationRenewal(Long threadId) {
ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
// task == null说明肯定没有这个锁的任务
if (task == null) {
return;
}
//移除id
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
//取出定时任务
Timeout timeout = task.getTimeout();
if (timeout != null) {
//取消任务
timeout.cancel();
}
//移除entry
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
}
}
总结:利用watchDog,每隔一段时间重置超时时间;entry中封装id和定时任务,并将entry放入静态全局常量map中,实现超时续约。
六,redis集群(多个redis节点)(源码)
将数据自动分布到多个redis节点,在每个redis节点获取到锁才算是真正获取到锁,同时支持水平扩展,增加节点来处理更多数据。
1,集群的好处
- 性能提升:通过并行处理和分布式计算,提升处理能力和响应速度。
- 容错能力:节点间互相监控和支持,保障系统稳定运行。
- 负载均衡:将工作负载分布到多个节点,避免单点过载。
- 高可用性:通过冗余和故障转移,确保系统在节点故障时依然可用。
- 扩展性:支持通过添加更多节点来处理更大的数据量和更高的吞吐量。
2,集群部署的原理(源码)
(1)如果自己指定锁释放时间,没有指定最大重试时间--新的释放时间就等于指定的释放时间:如果指定了最大重试时间,那么新的释放时间等于最大重试时间X2,因为集群部署,要重试获取多把锁花费时间较长,所以要求设置的释放时间不能太短,避免锁重试未到时间锁已经释放。
long newLeaseTime = -1;
if (leaseTime != -1) {
if (waitTime == -1) {
newLeaseTime = unit.toMillis(leaseTime);
} else {
newLeaseTime = unit.toMillis(waitTime)*2;
}
}
(2)这一段主要指定:剩余时间remainTime、lockWaitTime、failedLocksLimit和acquiredLocks
long time = System.currentTimeMillis();
//剩余时间
long remainTime = -1;
if (waitTime != -1) {
//如果指定了重试时间,那么剩余时间就是最大重试时间
remainTime = unit.toMillis(waitTime);
}
//不需要重试,锁等待时间为-1,需要重试则为指定的重试时间
long lockWaitTime = calcLockWaitTime(remainTime);
//失败的锁的上限为0
int failedLocksLimit = failedLocksLimit();
//获取到的锁的集合,初始为空
List<RLock> acquiredLocks = new ArrayList<>(locks.size());
(3)进入循环,执行获取锁的代码和重试机制;如果不需要重试且没有指定锁释放时间--执行普通的无参的获取锁的方法,如果需要重试或者指定了锁释放时间,有等待时间就选等待时间,没有取默认-1不重试,执行有参获取锁的方法。
(4)未获取到锁,只有当总的锁个数-获取到锁的个数=失败的上限0,才跳出循环;释放获取到的锁的集合里的锁,如果不需要重试就直接返回false;需要重试,清空获取到锁的集合,将迭代器移动到集合的起始位置,循环前移使整个循环重新开始
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
//拿到locks中的锁
RLock lock = iterator.next();
boolean lockAcquired;
try {
//如果不需要重试且没有指定锁释放时间
if (waitTime == -1 && leaseTime == -1) {
//执行普通的无参的获取锁的方法
lockAcquired = lock.tryLock();
} else {
//如果需要重试或者指定了锁释放时间
//如果指定重试时间,那么就等于重试时间,否则就是默认的-1就不重试
long awaitTime = Math.min(lockWaitTime, remainTime);
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (RedisResponseTimeoutException e) {
//如果获取锁出现redis超时异常,释放锁集合
unlockInner(Arrays.asList(lock));
//将获取到锁的标志置为false
lockAcquired = false;
} catch (Exception e) {
lockAcquired = false;
}
//是否获取到锁
if (lockAcquired) {
//拿到锁就将锁添加到获取到锁的集合中
acquiredLocks.add(lock);
} else {
//未获取到锁,当总的锁个数-获取到锁的个数=失败的上限0,就跳出循环
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
//获取失败的锁的上限为0
if (failedLocksLimit == 0) {
//释放获取到的锁的集合
unlockInner(acquiredLocks);
//不需要重试,直接返回false
if (waitTime == -1) {
return false;
}
//需要重试
failedLocksLimit = failedLocksLimit();
//清空获取到锁的集合
acquiredLocks.clear();
//将迭代器移动到集合的起始位置,循环前移使整个循环重新开始
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
(5)这一段主要是检查获取到每一个节点的锁后剩余的等待时间,如果小于0直接返回false,最后再次循环获取下一个节点的锁。
//如果指定了重试时间,表示要重试
if (remainTime != -1) {
//看看现在的剩余时间
remainTime -= System.currentTimeMillis() - time;
time = System.currentTimeMillis();
if (remainTime <= 0) {
//如果剩余时间小于等于0,表示重试时间已经到了,释放获取到锁的集合
unlockInner(acquiredLocks);
return false;
}
}
//第一个锁获取完成,循环进行其余锁获取
}
(6)如果指定了锁释放时间,为每个lock重置有效期,因为在获取到第一个锁时,它的有效期倒计时已经开启,到获取到最后一个锁时,导致几个锁之间有效期不同步,所以这里统一进行同步
if (leaseTime != -1) {
//使用forEach操作来同步执行每个expireAsync方法的结果--重置有效期
//syncUninterruptibly()方法用于确保异步操作能够被同步执行,即使线程被中断。
acquiredLocks.stream()
.map(l -> (RedissonLock) l)
.map(l -> l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS))
.forEach(f -> f.syncUninterruptibly());
}
总结:使用迭代器循环获取每一个节点的锁,使用forEach操作来同步执行每个expireAsync方法的结果--同时重置各个锁的有效期。
1571

被折叠的 条评论
为什么被折叠?



