写作不易,点赞收藏关注一键三连,以便下次再看,感谢支持~
前两篇文章咱们聊到了如何采用SQL数据库及Zookeeper实现相应的分布式锁。
初识分布式锁(一)
初识分布式锁(二):ZooKeeper分布式锁原理浅析及实战案例
今天咱们再来聊聊如何采用redis实现相应的分布式锁,以及这种实现与前两种方式实现的差异性。
Redis常见命令
在介绍分布式锁之前,我们先来了解一下redis的常用命令:
1、SET key value [EX seconds] [PX milliseconds] [NX|XX],将字符串值 value
关联到 key
。如果 key
已经持有其他值, SET就覆写旧值,无视类型。从 Redis 2.6.12 版本开始, SET命令的行为可以通过一系列参数来修改:
-
EX second
:设置键的过期时间为second
秒。SET key value EX second
效果等同于SETEX key second value
。 -
PX millisecond
:设置键的过期时间为millisecond
毫秒。SET key value PX millisecond
效果等同于PSETEX key millisecond value
。 -
NX
:只在键不存在时,才对键进行设置操作。SET key value NX
效果等同于SETNX key value
。 -
XX
:只在键已经存在时,才对键进行设置操作。
2、EXPIRE key seconds,为给定 key
设置生存时间,当 key
过期时(生存时间为 0
),它会被自动删除。
3、SETEX key seconds value,将值 value
关联到 key
,并将 key
的生存时间设为 seconds
(以秒为单位)。
这个命令类似于以下两个命令:
SET key value
EXPIRE key seconds # 设置生存时间
SETEX命令与SET + EXPIRE命令的区别主要在于,SETEX命令可以保持原子性,而SET+EXPIRE属于两条命令,难以保持其原子性。
4、DEL key [key ...],删除给定的一个或多个 key
。
5、SETNX key value,将 key
的值设为 value
,当且仅当 key
不存在。若给定的 key
已经存在,则 SETNX 不做任何动作。
分布式锁最关键的主要几个命令我都罗列在上面了~如果还有不清楚或者没有提及的命令,可以点开这个文章进行查找。
Lua脚本
紧接着还需要介绍一个redis里面比较不常见的内容,lua脚本。
一般我们需要操作redis的时候,都是需要进入到redis客户端,通过一个一个的命令进行编辑输入,从而完成相应的redis操作。
这样的方式操作起来相对方便,而且都是及时反馈,在命令数量较少、操作简单的时候十分友好。
但是如果当需要执行的命令很多、而且命令可能有前后依赖的时候,那么采用这样一个个命令输入的方式就显得十分不友好了。
为此,redis特意引入了lua脚本,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。
而且另外一个特点是,Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。这个也是lua脚本相比较于单条命令不断执行的优势之一。
分布式锁原理浅析
redis实现分布式锁,主要有两种方式:1、基于redis命令实现;2、基于lua脚本实现。
基于redis命令实现
实现的逻辑主要梳理如下:
- 当线程进入程序时候,采用SETNX命令往缓存中设置key值,如果设置成功,证明此时加锁成功。
- 当线程退出程序的时候,采用DEL命令将key值删除,从而实现解锁。
SETNX key value # 加锁
# 实现相应的业务代码逻辑
DEL key value # 解锁
但是这样明显存在一个问题,如果一个线程在加锁期间,因为某些特殊原因挂掉了,没有进行解锁,此时就会产生【死锁】,从而严重影响整个系统的性能。
因此在加锁后我们还需要采用EXPIRE命令,为相应的KEY值添加上过期时间从而避免死锁的产生。
SETNX key value # 加锁
EXPIRE key seconds # 设置过期时间
# 实现相应的业务代码逻辑
DEL key value # 解锁
问题是不是到此就解决了呢?显然并没有!
之前我们说过由于加锁及设置过期时间的代码是两个命令,而redis在执行两个命令的时候并不能保证原子性,因此又可能出现在执行SETNX命令的时候,出现宕机,这样还是出现了死锁!
因此,在redis,对set命令进行了拓展,我们可以将上述的代码替换成下述的代码。
SET key value EX seconds NX # 设置锁的超时时间,且当key存在时直接返回。
# 实现相应的业务代码逻辑
DEL key value # 解锁
尽管如此,锁重入仍是个难题,因为我们采用了NX参数,因此难以实现锁的重入;
基于lua脚本实现
相反,得益于lua脚本的执行时的原子性,lua脚本能较好的解决上述的种种问题。
用lua脚本实现的加锁代码大致流程如下所示:
lua脚本实现解锁的主要流程如下所示:
更详细的代码解析,在Redisson源码浅析中我们会分析到。
但需要注意的一点是,锁的过期时间设定是一门难题,设置时间长了,锁久久不释放影响性能;设置短了,业务代码还没执行完锁就释放了,没法限制其他线程的代码执行。比较巧妙的是,现有的框架里面已经有使用守护线程的方式(看门狗)来自动延长过期时间,从而简化使用的门槛。
代码实战
这次代码实战,我们采用Redission实现分布式锁,其实redission框架对分布式锁的封装相对完善,只需要很少的代码就可以实现对应分布式加锁及解锁。
首先,我们写一个配置类,用于加载我们对应的容器到spring中,这里需要注意的一点是,@Bean注解会默认使用方法名作为容器名字,要确保咱们的方法名与要加载的容器名字一致,当然也可以使用@Bean(value = "redissionClient")来显式的指定容器的名字。
@Configuration
public class RedisConfig {
//这里在application.yml中填写你对应的redis的ip:port
@Value("${redis.address}")
private String redisAddress;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(redisAddress);
return Redisson.create(config);
}
}
在将对应的容器注入到Spring的框架后,我们调用redission的关键方法getLock获取对应的锁。紧接着可以对这个锁调用相应的tryLock方法进行上锁,这里的上锁是个多态方法,主要区别如下所示:
// 不填写参数,即时获取锁,如果锁不可用则直接返回false。
boolean tryLock();
// 在给定时间内获取对应的锁(如果线程没有被中断)
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
这里我们采用简单的方法实现,直接采用tryLock()改造对应的代码内容,改造后的代码如下:
@Resource
RedissionClient redissionClient;
public Boolean deductProduct(ProductPO productPO){
//首先获取分布式的锁
RLock lock = redissonClient.getLock("deductProduct");
try{
LOGGER.info("分布式锁加锁!");
//尝试对redis的分布式锁进行加锁
boolean success = lock.tryLock(30, TimeUnit.SECONDS);
if (!success){
//加锁失败,直接返回
return false;
}
LOGGER.info("查找商品的数据为 :"+ JSON.toJSONString(productPO));
Example example = new Example(ProductPO.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("skuId", productPO.getSkuId());
List productPOS = productMapper.selectByExample(example);
if (CollectionUtils.isEmpty(productPOS)){
throw new RuntimeException("当前商品不存在");
}
for (ProductPO selectProductPO: productPOS){
//对对应的sku进行数量扣减
Integer number = selectProductPO.getNumber();
LOGGER.info("当前商品的数量为:"+number);
if (number<=0){
//小于等于0时,不进行扣减
continue;
}
selectProductPO.setNumber(number-productPO.getNumber());
productMapper.updateByPrimaryKey(selectProductPO);
}
}finally {
//最后一定记得释放锁资源
LOGGER.info("分布式锁释放!");
lock.unlock();
}
return true;
}
随后运行咱们的代码就可以得到相应的结果啦:
源码浅析
加锁源码
对tryLock(),即加锁的代码进行分析。
boolean success = lock.tryLock();
深入到关键的源码层面,其主要代码如下:
private RFuture tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture acquiredFuture;
if (leaseTime != -1) {
/*关键代码*/
acquiredFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
} else {
/*关键代码*/
acquiredFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
CompletionStage f = acquiredFuture.thenApply(acquired -> {
//如果成功获取锁
if (acquired) {
if (leaseTime != -1) {
// 明确指定了租约时间,则更新类相应的租约时间即可
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 否则将当前的ThreadId保存到一个相应的ConcurrentMap中,
// 开启守护线程,定期刷新对应线程ID持有锁的过期时间。避免出现锁过期被释放的问题
scheduleExpirationRenewal(threadId);
}
}
return acquired;
});
return new CompletableFutureWrapper<>(f);
}
获取锁的命令中,可以看到比较关键的代码是tryLockInnerAsync。
RFuture tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {
//这个命令的逻辑相对清晰,首先判断当前的key值是否存在
//==0则代表哈希的key不存在,则此时新增哈希的key及field对象
//==1则代表哈希key及对应的field对象存在,刷新其过期时间,同时会返回其剩余的超时时间。
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));
}
我们追入相应的代码中可以看到,Redission底层的源码是采用lua脚本的方式执行的。其中有一些关键的参数及命令列举如下:
KEYS[1],是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;
ARGV[1],是internalLockLeaseTime,即锁的租约时间,默认30s;
ARGV[2],是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId
"pexpire",为设置键的超时时间,对一个已经存在的键重复使用会刷新过期时间。
"hincrby",则是对哈希对象中某个field对象进行原子增加或减少。
"pttl",则是返回当前键的过期时间。
"exists",判断当前的key值是否存在。
值得关注的还有scheduleExpirationRenewal里的源码:
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
//往key对应的哈希结构中添加新的ThreadId
oldEntry.addThreadId(threadId);
} else {
//同上
entry.addThreadId(threadId);
try {
renewExpiration();//开启守护线程,关键代码
} finally {
if (Thread.currentThread().isInterrupted()) {
cancelExpirationRenewal(threadId);
}
}
}
}
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(); // 获取对应key的第一个线程Id
if (threadId == null) {
return;
}
RFuture future = renewExpirationAsync(threadId); // 在此处采用脚本的方式更新对应的过期时间
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
//如果更新成功,那么此时重复进入此方法,再次更新。
renewExpiration();
} else {
//否则取消更新
cancelExpirationRenewal(null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
由此分析下来,整个加锁的流程就相对清晰了。流程主要为:
1、首先判断是否存在这个键
- 返回0则代表哈希的key不存在,则此时新增哈希的key及field对象;
- 返回1则代表哈希key及对应的field对象存在,刷新其过期时间,同时会返回其剩余的超时时间。
2、如果加锁成功了,根据租约时间会有不同的策略。
- 如果指定了过期时间,那么不会开启守护线程,而是任由锁超时后自动释放
- 如果没有指定过期时间,那么此时会开启一个守护线程,持续去更新对应线程ID的redis锁时间。
解锁源码
解锁的关键代码主要如下:
@Override
public RFuture unlockAsync(long threadId) {
/*解锁关键代码*/
RFuture future = unlockInnerAsync(threadId);
CompletionStage f = future.handle((opStatus, e) -> {
//解锁成功后,需要解锁对应的watchDog机制,即关闭掉对应的自动延时机制。
cancelExpirationRenewal(threadId);
if (e != null) {
throw new CompletionException(e);
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
throw new CompletionException(cause);
}
return null;
});
return new CompletableFutureWrapper<>(f);
}
追入unlockInnerAsync中进行查看:
protected RFuture unlockInnerAsync(long threadId) {
//如果当前不存在对应的分布式锁,直接返回
//否则将对应的key/field对象的计数-1(针对重入锁)
//如果此时计数>0,就再次刷新相应锁过期时间
//否则直接删除锁,并向对应的频道通知。
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));
}
主要的相关参数罗列如下:
- KEYS[1],是getRawName(),表示分布式锁的key;
- KEYS[2],是getChannelName(),这里会将分布式的key与固定的前缀进行组合,用于将解锁的消息发送到特定的频道。
- ARGV[1],是LockPubSub.UNLOCK_MESSAGE,即发送的消息类型,此处为【解锁】;
- ARGV[2],是internalLockLeaseTime,即锁的租约时间,默认30s;
- ARGV[3],是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId。
- Publish,命令用于将信息发送到指定的频道。(Ps:关于redis发布订阅的介绍,可以看这里)
看到这里我产生了一些疑惑,为啥我们前置都没有进行消息的监听,这里却做了解锁消息的广播呢?为此我又查阅了一遍源码,发现原来tryLock()不会去监听相应的频道消息,但是tryLock(long waitTime, long leaseTime, TimeUnit unit)方法,却会监听对应的消息。
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
...
//监听相应的消息
CompletableFuture subscribeFuture = subscribe(threadId);
...
}
在进行了订阅之后,线程会进入自旋。只有当其余线程释放了占用的锁并会广播了解锁消息后,监听器接收到解锁消息,并释放信号量,才会会唤醒阻塞在这里的其余线程。(不禁大喊一声:“作者牛逼。”)
由此梳理下来,整个解锁流程也相对清晰了。主要为:
1、首先判断是否存在这个键(lua脚本)
- 不存在,直接返回。
- 存在,锁线程计数-1。
2、如果计数扣减成功,根据计数会有不同的策略。
- 锁线程计数大于0,意味着此时锁处于重入状态,刷新过期时间并退出。
- 如果计数小于等于0,删除对应的锁,同时发送广播消息提醒其余的锁进行争抢。
3、最后,解锁成功后,还需要暂停相应的【看门狗机制】,关闭相应的自动延时任务。
优劣性分析
优势
1、基于缓存实现,性能较好,
2、lua脚本方式实现,拓展性好,可支持锁重入、订阅/发布等多个功能。
3、现有框架已实现,开箱即用。
4、支持watchDog自动延时功能。
缺点
1、不保持高一致性,不能保证分布式下每个redis中保存的内容每时每刻完全一致。因此读写时,需要读取同一个redis实例。
2、而且在主从情况下,往主redis实例写入后,主实例还没来得及同步到从实例就挂掉了,导致了从实例可以再次进行加锁,出现了多个服务器同时加锁的情况,有兴趣的可以进一步了解REDLOCK算法。
参考文献
使用Redis分布式锁的一系列问题以及解决方案
Redisson实现Redis分布式锁的N种姿势
Redis应用详解(一)分布式锁
Redis 命令参考
Redis分布式锁面临的问题和解决方案