基于 redis 的分布式锁
springboot 中使用 redisson 的 demo,公众号回复"redisson"获取。
本文主要从以下几个方面描述分布式锁:
- 什么是分布式锁及分布式锁的使用场景(需求介绍)
- 实现分布式锁要解决哪些问题(面试要点)
- 如何实现一个分布式锁(redisson 源码分析)
- 如何优化分布式锁性能(性能优化)
需求介绍
为了保证多个线程对某个资源并发访问的正确性,解决思路大概分为以下三种:
- 给资源加锁(悲观锁 synchronized 或者乐观锁 CAS 等)
- 每个线程独占一份资源,互不干扰(ThreadLocal)
- final 修饰资源,只读不改(final)
本次需求是对单个资源的并发访问,模拟一个具体场景:微信群里的抢红包。此时第二种和第三种方案其实是难以满足需求的,不可能把红包给每个线程都放一份,也不可能对红包只看不抢,因此要满足需求,还是要用加锁的机制去实现。
假如现在有一个抢红包的接口,大家抢红包时都会去调用这个接口处理,伪代码如下:
/**
* 抢红包
*
* @param redPackNo 所抢红包编号
* @return 抢到的金额
*/
@GetMapping
public Long get(String redPackNo) {
//抢到的红包金额,如果0表示没抢到(此处简单处理,没抢到应该用状态码表示)
Long amount = 0L;
//针对单个红包单机部署时,只有拿到了这个红包锁的线程才可以抢到红包
synchronized (ReadPackPool.get(redPackNo)) {
//查询红包剩余数量
String redPackNum = redisTemplate.opsForValue().get(redPackNo);
//如果红包剩余数量大于0
if (Integer.valueOf(redPackNum) > 0) {
//红包数量减一
redisTemplate.opsForValue().decrement(redPackNo, 1);
//此处省略红包缓存删除逻辑...
//此处省略计算和返回金额逻辑...
//amount = randomMoney(redPackNo);
}
}
return amount;
}
假如抢红包的并发不高,红包服务和 redis 都单机部署可以支撑,这段代码可以保证红包发放的正确性,但是如果集群部署红包服务或者 redis,这段代码就可能会产生问题了。因为synchronized
的原理是使用内存中的redPackNo
对象头作为标识位(不明白synchronized
工作原理的去补课吧,我之前讲过关于synchronized
的原理),而多台机器上的redPackNo
对象不能共享,因此就会造成抢红包业务错误。问题产生的根源在于synchronized
的实现机制使用的是一个对象的头作为标识位,而这个对象头信息无法在分布式环境中共享的访问,因此解决这个问题的思路就是把内存中的标识位拿到一个可以共享的位置。 可以存储数据的地方理论上都可以。但是为什么选择通常选择:redis,数据库,zk 等类似的组件?因为这类组件常用,而且提供了可以控制并发访问资源的便捷方式,因此使用这三个可以让分布式锁的实现变的简单。今天要聊的就是基于 redis 来实现一个分布式锁。
看到这里,你是否明白了什么是分布式锁?为什么需要分布式锁呢?
面试要点
实现一个分布式锁要解决如下几个问题:
- 获取锁和释放锁的方式
- 保证锁获取释放操作的原子性
- 防止死锁
- 锁的可重入性
- 悲观锁和乐观锁
- 防止锁失效
- redis 分布式部署时,redis 数据的不一致性导致的锁状态错误
基于 redis 解决如上问题的思路解析:
如何获取锁和释放锁?
synchronized
获取锁和释放锁的方式是通过monitorenter
指令和monitorexit
指令隐式实现,方法标记其实也是通过这两个指令实现。
JUC 中的 Lock 获取和释放锁是通过lock()
方法和unlock()
等一系列方法显式获取。
通过 redis 实现的话,只能通过显式的调用 api 去获取。以 redisson 为例,也是通过lock()
方法和unlock()
等一系列方法显式获取。其中lock()
方法内部实现是将某个 key 原子的写到 redis,如果成功返回 true,如果失败,返回 false。而unlock()
方法内部实现是将刚才 key 从 redis 删除(如果是可重入的,需要先递减计数器,当计数器到达初始值时需要删除 key)。
注意:通过 redis 实现的分布式锁释放和获取时跟使用 JUC 的 Lock 基本遵守原则相同,获取锁不能写到 try 中,释放锁必须写到 finnaly 块中。网上很多博客都是将获取锁写到 try 中,简直误人子弟,这样一旦获取锁的逻辑抛出异常,会执行 finnaly,但是获取锁根本没成功,何谈释放?
如何保证获取锁的原子性?
方式一:原子命令 setnx
//针对单个红包单机部署时,只有拿到了这个红包锁的线程才可以抢到红包
Boolean lock = redisTemplate.opsForValue().setIfAbsent(redPackNo, "lock");
if (lock){...}
第二种:lua 脚本(在 classpath 下创建 distributionLock.lua)
DefaultRedisScript script = new DefaultRedisScript();
script.setResultType(Long.class);
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("distributionLock.lua")));
这两种方式都可以保证原子性的获取锁,基本上 90%的博客说的也都是基于 setnx 的实现,那么基于 redis 实现分布式锁的最佳实践到底是哪个数据结构呢?大家先思考一下,可重入性部分会为大家揭晓答案。
如何防止死锁?
synchronized
是通过在方法结束的地方和异常处增加monitorexit
指令来保证锁一定会被释放。
增加锁超时机制,因为 finnaly 块一定会执行是通过 JVM 保证的,如果某个集群节点在获取锁后未执行到 finnaly 前宕机,那么分布式锁标识就无法释放了,这样其他集群节点一直都拿不到锁资源,因此需要加入锁超时机制,保证锁在超过一定时间后会被释放,防止死锁。
redis 超时机制可以通过 expire 指令实现,或者在写数据时通过 redis 提供的原子指令直接写入 key 超时时间。
Boolean lock = redisTemplate.opsForValue().setIfAbsent(redPackNo, "lock", 30L, TimeUnit.SECONDS);
//或者
Boolean lock = redisTemplate.opsForValue().setIfAbsent(redPackNo, "lock");
redisTemplate.expire( redPackNo,30L, TimeUnit.SECONDS);
如何做到锁的可重入性?
如果使用 strings 结构作为分布式锁,那么可重入实际上要通过和当前锁的 key 对应的另外一个 k-v 去实现计数。通过翻看 redisson 源代码,内部其实使用的是 hashes 结构实现。
如何实现悲观锁和乐观锁?
悲观锁:期望在获取不到锁时,阻塞等待。
乐观锁:期望在获取不到锁时,及时返回。
乐观锁的实现其实很简单,如果加锁失败,直接返回即可。悲观锁稍微困难一点,如果获取锁失败,需要让线程等待,并且持有锁的线程释放锁后通知等待的所有线程,进入新一轮夺锁之争。
悲观锁的实现方式有多种,例如我以前讲到过的 wait/notify 就可以实现。翻阅 redisson 源代码,其实现是通过自旋加Semaphore
实现的。Semaphore
底层其实依赖于AQS
实现的,因此获取锁失败的线程都会保存到AQS
的队列中。
如何防止锁失效?
在如何防止死锁中,聊到了要给锁加超时时间,但是如果业务时间真的超过了超时时间,那么锁岂不是在业务执行到一半的时候就失效了?
解决这个问题的思路就是加一个看门狗,具体做法是:在获取到锁后,开一个守护线程,定期给锁续期,如果当前 JVM 没有挂掉,并且锁未释放,那么守护线程会持续的续期(喂狗),这样锁就不会过期失效。如果当前 JVM 挂了,那么当前锁就不会被续期,过了过期时间,锁自然就解除了,其他获取锁的线程又可以获取到锁了。
redis 分布式部署时,redis 数据的不一致性导致的锁状态错误怎么办?
答案是红锁,红锁 redis 官方提出的解决异步数据丢失的问题。解决思路就是在半数以上的节点上获取到锁才证明获取锁成功。
看到这里,你是否明白了上面提到的七个问题每个问题的解决思路呢?
源码分析
可重入性源码:通过使用 hashes 结构,保存重入次数,不要再说分布式锁是 setnx 了,说 hashes 结构让别人对你刮目相看。
RFuture tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {
internalLockLeaseTime = unit.toMillis(leaseTime);return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,"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; " +"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(getName()), internalLockLeaseTime, getLockName(threadId));
}
悲观锁源码:通过 while(true) + Semaphore 实现异步通知。
RFuture future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquiredif (ttl == null) {break;
}
// waiting for messageif (ttl >= 0) {
try {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {if (interruptibly) {
throw e;
}
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {if (interruptibly) {
getEntry(threadId).getLatch().acquire();
} else {
getEntry(threadId).getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(future, threadId);
}
防止锁失效源码:通过异步线程看门狗机制。
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;
}
RFuture future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);return;
}if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
里面有个核心注释:reschedule itself
性能优化
性能优化的思路:资源分段加锁。例如把数据分成 N 段,那么其实就把一个锁变成了 N 个锁,类似于 JVM 分配内存时的 TLAB 机制,使锁的粒度变小以追求更高的性能。
这种机制有个必须要解决的问题:即分成 N 段后,如果其中的某个段资源被消费完了,还有其他段数据没有被消费,此时一个请求打到被消费完的段时,如何处理这个请求?答案是释放当前锁,将请求打到下一个段。
因为和业务相关,这个优化过程需要自己去实现,很麻烦,不过实现好了可以让你的业务并发成倍的增加。
看完三件事
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
点赞,转发,你们的 『点赞和转发』,才是我创造的动力。
关注公众号 『逆行的碎石机』,扫码 ↓ 不定期分享原创知识和学习资料。
同时可以期待后续文章ing?