今天来说说Redis分布式锁。
在说Redis分布式锁之前你首先得明白什么是分布式。
在我看来服务部署就两种形式,一种是单体应用,一种是分布式架构。
那么什么叫单体应用呢? 举个简单的例子,比如你的网段ip是 192.168.xxx.xxx,你只有一个服务,就部署在这一台ip上,那么我认为这种就是单体应用。
那么什么又叫分布式架构? 你可以这样理解,比如你的应用最开始上市平平无奇,没有什么访问量,那么单体应用看起来并不会什么问题,完全够用嘛。假如说某一天,你的app火了,成千上万甚至几十万的用户访问到你的系统,那么所有的用户请求全部用一台服务器去承载这是会有大问题的。会造成系统拥堵,cpu过高,系统响应慢,频繁fgc,甚至一台机器可能扛不住,直接挂了。那你的整个系统就瘫痪了。带给用户的体验就是一卡一卡的,然后突然就宕机了。
那么这时候完蛋了,一个大好的赚钱机会就被你单体架构部署给弄丢了。
所以这个时候,我们往往会去采取分布式架构部署服务,在所有的应用前通过一层服务(服务你可以理解为网关或者nginx等等一系列可以分发请求的服务)去分发流量,给系统一个良好并且稳定的工作环境,增加系统的可用性以及稳定性。在跟你聊什么是分布式锁之前,我们先来看看这样的一种场景。
假如你有一个商品服务,你把他部署了分布式架构,部署了两台,然后通过nginx来分发流量。
然后商品服务里面有一个接口是获取库存接口,伪代码如下
@RestController
public class MarketController {
@Resource
private MarketService marketService;
public String getMarket(String userId){
//获取库存 默认100个
int num = marketService.getMarketNum();
System.out.println("当前库存:" + num + ",用户userId拿到了第" + num +"号商品");
num--;
//重新修改库存
marketService.setMarketNum(num);
return "购买成功!";
}
}
假如对这段操作我们没有任何的上锁操作,那么就会出现超卖的问题。
什么是超卖?
就比如张三和李四同时在抢商品,当前商品是85,当张三拿到了85号商品,系统还没有进行减库存的操作时,李四这时候也进入了这个接口,因为数据库还没被更改,那么李四也拿到了85号商品,这就出现了超卖现象。同一件商品被卖给了两位顾客,你觉得合适吗? 顾客可能心里美滋滋,但是对于系统而言就是资损了。我们肯定不能允许这种问题存在。好,那么这个时候你已经意识到了要上锁来防止问题的出现了。
那要用什么锁呢? 可能有同学会想到,哎用那个synchronized去锁啊,锁住这一整个方法不就可以了吗?
比如这样
@RestController
public class MarketController {
@Resource
private MarketService marketService;
public String getMarket(String userId){
synchronized (this) {
//获取库存 默认100个
int num = marketService.getMarketNum();
System.out.println("当前库存:" + num + ",用户userId拿到了第" + num + "号商品");
num--;
//重新修改库存
marketService.setMarketNum(num);
}
return "购买成功!";
}
}
如果你是单体架构,那么我认为这堂课我们已经可以下课了
开个玩笑,开个玩笑 ->_ ->
其实还没有结束,你是单体应用的话,确实这样锁住防止了并发,但是其实这把锁是一把悲观锁,效率不高。
而且,如果你是分布式架构,这样就有问题,你只能锁住当前这台虚拟机部署的应用的这个库存方法,当两台服务同时出现用户进行库存操作的时候,其实还是会出现超卖的问题。归根结底,就是多把锁不同步,如果我们能够用一把锁去控制,那这样是不是就不会出现并发的问题了?比如这样
@RestController
public class MarketController {
@Resource
private MarketService marketService;
@Resource
private StringRedisTemplate stringRedisTemplate;
public String getMarket(String userId){
//设置分布式锁
Boolean ifAbsent = stringRedisTemplate.opsForValue()
.setIfAbsent("userId", "product-01", 30, TimeUnit.SECONDS);
if(!ifAbsent){
return "当前系统正在处理,请稍后再试!";
}
//获取库存 默认100个
int num = marketService.getMarketNum();
System.out.println("当前库存:" + num + ",用户userId拿到了第" + num + "号商品");
num--;
//重新修改库存
marketService.setMarketNum(num);
//移除分布式锁
stringRedisTemplate.delete("userId");
return "购买成功!";
}
}
这种样子会不会有问题呢?
其实是有的,假如服务在设置完redis锁之后,到修改库存的那一步,突然程序发生了异常。会发生什么?
程序会回滚,但是redis的操作已经把这把锁给设置上去了。那这把分布式锁就成了一把死锁了,就有大问题了,同学。好,又有一个同学说,那可以try-catch起来,用finally去删除 不就好了吗,不管发生啥异常,我最后都会要删除掉这把分布式锁,就像这样
@RestController
public class MarketController {
@Resource
private MarketService marketService;
@Resource
private StringRedisTemplate stringRedisTemplate;
public String getMarket(String userId){
try {
//设置分布式锁
Boolean ifAbsent = stringRedisTemplate.opsForValue()
.setIfAbsent("userId", "product-01", 30, TimeUnit.SECONDS);
if(!ifAbsent){
return "当前系统正在处理,请稍后再试!";
}
//获取库存 默认100个
int num = marketService.getMarketNum();
System.out.println("当前库存:" + num + ",用户userId拿到了第" + num + "号商品");
num--;
//重新修改库存
marketService.setMarketNum(num);
} catch (Exception e) {
//异常处理
} finally {
//移除分布式锁
stringRedisTemplate.delete("userId");
}
return "购买成功!";
}
}
我们再来看看这样的结构会不会有问题,
try-catch确实可以解决所有异常,甚至是错误,但是当设置完redis锁之后,到修改库存的那一步,你机器突然不争气的宕机了呢?
那么死锁的问题还是出现了。
怎么解决?可能有同学就说了,设置过期时间啊,过期时间搞一个不就完事了吗? 你再怎么宕机异常,我这把锁我总归不会成为死锁,就像这样
@RestController
public class MarketController {
@Resource
private MarketService marketService;
@Resource
private StringRedisTemplate stringRedisTemplate;
public String getMarket(String userId){
try {
//设置分布式锁
Boolean ifAbsent = stringRedisTemplate.opsForValue()
.setIfAbsent("userId", "product-01", 30, TimeUnit.SECONDS);
if(!ifAbsent){
return "当前系统正在处理,请稍后再试!";
}
//获取库存 默认100个
int num = marketService.getMarketNum();
System.out.println("当前库存:" + num + ",用户userId拿到了第" + num + "号商品");
num--;
//重新修改库存
marketService.setMarketNum(num);
} catch (Exception e) {
//异常处理
} finally {
//移除分布式锁
stringRedisTemplate.delete("userId");
}
return "购买成功!";
}
}
现在我们再来仔细看看这个程序,不论你宕机还是异常这把锁都会自动过期,不会死锁,程序正常的时候也能去锁住这个接口,防止并发问题导致的超卖现象。看上去好像是没什么问题了。
你再仔细看看,真的没问题了吗?
再仔细看看
再看看我来举个栗子
如果我们设置的redis超时时间是30秒。
第一个线程在执行修改库存时,这个时候数据库可能卡了,导致线程1整个时间超过了30秒,这个时候还没能够执行完,因为这个时候,分布式锁已经失效了,这个时候第二个线程就可以进来了,那么这个时候,超卖问题是不是又发生了?显然是发生了。
这个时候,可能又有同学会说,时间拉长啊,再拉个几倍呢?
其实不然,不论你时间怎么调其实都会有问题
如果你调小,那有可能就是我上面描述的这种问题,锁过期了但是第一个线程还没能够把程序执行完毕,其他线程就可以进来,这样就会有问题
如果你调大,那有可能发生异常,你重启服务,这个功能要等个好几十秒甚至几分钟才能用,这样也不友好。所以总的来说,不能通过依赖这个过期时间去解决这个问题
虽然这个过期时间其实是可以解决百分之九十九以上的问题的。并且如果你的系统对这个功能点的并发要求不是那么高,其实做到这样子就差不多了。如果你对一致性有很高的要求,也不想有这种因为时间而导致的并发问题,其实还有一种解决方案
想象一下,如果有另外一个线程,可以在主线程执行这段代码的过程中不断的去看这把锁有没有过期,如果锁快要过期了,就给锁延长一下存在的时间,其实说白了也就是锁续命
通过这种续命的方式,在你的主线程执行完之前都保留有这把分布式锁,这样就可以防止过期时间导致的锁过期的问题了。那么如何去做呢? 其实市面上已经有比较成熟的框架了。
比如说 Redisson
那么我们一起来看看Redisson是怎么做的Redisson集群整合代码 我们只需要注入一个Redisson类然后给他加上相应的配置即可
@Configuration
public class RedissonConfig {
//添加redisson的bean
@Bean
public Redisson redisson() {
//redisson版本是3.5,集群的ip前面要加上“redis://”,不然会报错,3.2版本可不加
List<String> clusterNodes = new ArrayList<>();
String[] nodes = {"ip:port","ip:port"};
for (int i = 0; i < nodes.length; i++) {
//redisson版本是3.5及以上的版本,这里集群的ip前面要加上“redis://”
clusterNodes.add("redis://" + nodes[i]);
}
Config config = new Config();
ClusterServersConfig clusterServersConfig = config.useClusterServers()
.addNodeAddress(clusterNodes.toArray(new String[clusterNodes.size()]));
return (Redisson) Redisson.create(config);
}
}
这样的话Redisson就算是跟你的集群已经绑定起来了。那么接下来要做的就相当简单了。
@Resource
private Redisson redisson;
@RequestMapping("getMarket")
public String getMarket(@RequestParam("userId") String userId){
RLock lock = redisson.getLock(userId);
try {
//设置分布式锁
lock.lock(100,TimeUnit.SECONDS);
//获取库存 默认100个
int num = MarketController.num;
System.out.println("当前库存:" + num + ",用户+" + userId + "+拿到了第" + num + "号商品");
num--;
//重新修改库存
MarketController.num = num;
} catch (Exception e) {
//异常处理
} finally {
//移除分布式锁
lock.unlock();
}
return "购买成功!";
}
现在再来看看这段代码是不是相当简单了?我们只需要注入Redisson,然后调用Redisson的相应API就完成了分布式锁的一整个流程
总体概括来看Redisson就做了三件事
// 获取锁
RLock lock = redisson.getLock(userId);
// 加锁(自己指定过期时间)
lock.lock(100,TimeUnit.SECONDS);
// 加锁(使用系统默认过期时间 系统默认是30秒)
lock.lock();
// 解锁
lock.unlock();
看到这里可能有同学有疑问了,那你上面描述的过期时间的解决方案他又是如何去实现的呢?
带着这个疑问我们一起来看看他这三个api里面都做了什么样的事情
首先先给大家看一下Redisson对于多个线程来争抢锁的一个全过程的图解,方便大家理解代码层面的逻辑
顺着这张图,我们先来分析第一步
// 获取锁
RLock lock = redisson.getLock(userId);
// 这里就是拿着注入的Redisson类根据你传入的字符串New一个RedissonLock类
RLock lock = redisson.getLock(userId);
//点进这个方法的源码你会发现,这一步只是为了后续的加锁准备一些默认属性
//比如锁的默认过期时间,锁的名字等等
1.
public RLock getLock(String name) {
return new RedissonLock(this.connectionManager.getCommandExecutor(), name);
}
2.
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
this.commandExecutor = commandExecutor;
this.id = commandExecutor.getConnectionManager().getId();
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
}
再来分析第二步
第二步又分为两种第一种 加锁(自己指定过期时间)
lock.lock(100,TimeUnit.SECONDS);
指定了过期时间的方式不会启动Watch Dog,锁过期之后,下一个拿到锁的线程就开始执行接来下的步骤第二种 加锁(使用系统默认过期时间 系统默认是30秒)
lock.lock();
使用系统默认过期时间的,会启动一个Watch Dog监听这把锁,每隔默认过期时间的三分之一等份的时间间隔之后会去继续看这把锁是否仍然存在,存在则继续给锁设置默认的过期时间,不存在则直接返回。所以当使用这种方式然后没有去手动解锁时,就会发生死锁问题,除了第一个线程正常结束,其他线程都会陷入死循环不断的去尝试拿锁。
总的来说,不论是第一种加锁还是第二种加锁,对于Redisson来说,都会执行相应的Lua脚本,不太清楚Lua脚本的同学可以百度看一下。就我个人对于这个的简单理解就是,Lua脚本保证了多个命令执行的原子性,如果中间发生了异常,那么整个Lua脚本里面的命令都会执行失败,而不会出现一些成功一些失败的尴尬景象。Lua脚本其实就是执行redis的eval命令,在这个命令里面我们可以执行一组redis的命令来达到特定的效果。我把加锁的eval脚本给大家扣出来简单分析一下。
// 其实就是执行了这一句命令
return this.commandExecutor.evalWriteAsync(this.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(this.getName()), new Object[]{this.internalLockLeaseTime, this.getLockName(threadId)});
//比较长,我们来拆解一下,其实最关键的只需要看这部分
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]);
//这一段Lua脚本后面还跟了几个参数
Collections.singletonList(this.getName()),
new Object[]{this.internalLockLeaseTime, this.getLockName(threadId)}
这一段Lua脚本什么意思呢?我大概给你分析一下
1.首先查看 KEYS[1]存不存在,KEYS[1]对于的参数就是
Collections.singletonList(this.getName())
你可以理解为这把锁的名称
2.如果KEYS[1]不存在,那么会设置一个hash类型的缓存
key为KEYS[1],hashkey为ARGV[2],value为1
然后将名字为KEYS[1]的缓存设置一个ARGV[1]的过期时间。
ARGV[1]指的就是Object[]对象数组的第一个参数,也就是过期时间(过期时间如果手动设置了就会取设置的值,如果没有设置就取Redisson初始化的默认的值,也就是30秒)
ARGV[2]指的就是Object[]对象数组的第二个参数,也就是当前执行的线程ID(这个参数非常关键,他是用来区分当前加锁线程跟其他等待锁线程的)
3.如果KEYS[1]存在,那么就判断当前执行的线程是不是已经获取到锁的线程
如果是,那么就把已经设置的value为1的hash索引结果再加1,然后返回nil
如果不是那么会返回锁的过期时间,后面的程序再判断这个返回的结果决定当前线程是加锁成功还是需要去循环等待加锁。
再来分析第三步
// 解锁
lock.unlock();
这一步就相对简单了,就是一个解锁动作,也是执行一段Lua脚本,怎么去解锁我简单跟你描述一下,其实就是对我们加锁的那么hash索引不断的去减1,直到减少为0那么就认为当前已经解锁成功,然后将当前的锁给del掉
至此,整个分布式锁的演变跟由来,我已经跟你全部讲解了一遍。
希望能够对你理解分布式锁带来帮助