案例:
简单的模拟多线程同时抢购一件商品的场景。
一、环境搭建
在springBoot中引入redis依赖
<!--SpringBoot的Redis支持-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!--注意这里使用2.1.x版本用于支持redis的分布式锁设置超时时间的功能!-->
<version>2.1.8.RELEASE</version>
</dependency>
<!--SpringBoot缓存支持-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
二、编写controller,用于处理减库存业务
1、版本一:直接获取商品库存,进行if判断,然后设置库存来操作减库存流程
@RestController
@RequestMapping("/myredis")
public class MyRedisController {
@Autowired
StringRedisTemplate stringRedisTemplate;
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id){
String key = "stock:"+id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if(stock<=0){
System.out.println(Thread.currentThread().getName()+"库存不足!"+stringRedisTemplate.opsForValue().get(key));
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key,String.valueOf(stock-1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}
}
浏览器或者请求工具调用接口/myredis/stock/1001 来减指定id(1001) 编号的货物库存。
版本1中对该接口只是简单的获取指定id的库存,然后判断再设置减库存后的值。这种模式在高并发的情况下肯定会出现超卖的现象。假设多个线程同时的拿到最后一个商品,stock值都是一样的,判断stock>0之后就进行减1的操作,再将剩余的结果存放到redis中,这就会引起超卖的现象。
2、版本二:采用加锁synchronized
可以在代码块上加synchronized锁。
@RestController
@RequestMapping("/myredis")
public class MyRedisController {
@Autowired
StringRedisTemplate stringRedisTemplate;
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id){
String key = "stock:"+id;
synchronized(this){
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if(stock<=0){
System.out.println(Thread.currentThread().getName()+"库存不
足!"+stringRedisTemplate.opsForValue().get(key));
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key,String.valueOf(stock-1));
}
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}
}
说明下问题,加锁的在高并发场景下对性能并不是特别好,因为其互斥性,可能会导致过多的线程排队等候,系统开销也比较大,不建议使用,并且在分布式环境下,使用synchronized关键字就不管用了,会出现分布式问题。
3、版本三:采用分布式锁setnx
setnx命令的语法: setnx key value
如果key存在,则不能设置成功,返回0(false),如果key不存在,那么久执行设置key,value。
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id){
//进来的线程必须要获取是否存在分布式锁
String lockKey = "stock:"+id+":lockey";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKey);
if (!flag) {
return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
}
try {
String key = "stock:" + id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if (stock <= 0) {
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}finally {
//最后必须释放分布式锁,让下个线程获取
stringRedisTemplate.delete(lockKey);
}
}
}
上面采用了setnx命令,假设线程1、线程2、线程3同时访问这个扣减库存接口,那么同时想要去执行setIfAbsent方法(实际上就是setnx命令)。因为redis是单线程模型,所以无论要执行多少条命令,都是按照请求到达的先后顺序进行排队的,redis会把所有待执行的命令按先后顺序排在队列中:
故此,不管有多少个线程同时执行,对应的分布式锁有且仅有一个成功上锁! 所以上面的方法成功的解决了超卖的问题。并且注意到上面对成功加锁的线程才处理业务,如果处理业务期间抛出异常,最终都会执行finally块来释放分布式锁,这很重要!
如果不释放分布式锁,那么之后的线程就会一直访问不了!除非手动的去redis服务器中删除分布式锁,故此为了避免执行过程抛异常,要在finally块释放锁!
3.2 分布式锁设置过期时间
上面的方法仍然会出现一些严重的bug。有下面一场景:
假设你处于高并发场景下,线程1刚刚好加上分布式锁,执行减库存业务逻辑,此时刚刚获取完库存即将进行减库存的时刻,突然redis服务器宕机了,那么就会导致一个问题,刚刚加上的分布式锁就会永久的残留在redis服务器中!
因为没有对分布式锁做超时过期处理 ,故此我们要将锁加上超时时间来修复这个bug,这样即使redis服务器宕机了,也不会说这个分布式锁永远残留在redis服务器中的现象。
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id){
//进来的线程必须要获取是否存在分布式锁
String lockKey = "stock:"+id+":lockey";
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKey);
//为分布式锁设置过期时间
stringRedisTemplate.expire(lockKey,10,TimeUnit.SECONDS);
if (!flag) {
return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
}
try {
String key = "stock:" + id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if (stock <= 0) {
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}finally {
stringRedisTemplate.delete(lockKey);
}
}
如果细心一点去想一下,其实上面设置超时时间的做法是不完美的,那万一你在执行完setIfAbsent,也就是刚刚设置完分布式锁,还没来得及设置过期时间的时候,突然redis宕机了怎么办呢?
针对上面的情况,在stringRedisTemplate中,我们可以直接使用下面的命令
stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockKeyValue,10,TimeUnit.SECONDS);
这条命令在spring-boot-starter-data-redis 2.1.x版本才存在喔!
这条命令就将设置分布式锁和超时时间合成了一个原子操作,也就是一条执行命令,保证设置上过期时间。
更进一步思考
即使你为分布式锁加上了过期时间,但仍然会出现bug。假设有三个线程
- 线程a 首先加上了分布式锁 ,按照上面的代码逻辑分布时锁key和value都是一样的。 然后线程a由于网络原因,其任务要执行15s。但分布式锁只是设置了10秒的过期时间,所以当线程a执行完10s后,线程b在那个时刻刚好访问接口
- 线程b检测发现没有分布式锁,所以线程b进行加锁。然后执行b的逻辑。 假设线程b执行8s ,在线程a执行完后面的5s后,线程a紧接着要去执行finally块,去删除刚刚加的分布式锁(但是已经过期了!),线程a误以为这把锁时刚刚自己加的,原因是分布式锁按照上面的逻辑,不管是哪个线程,都是加到同样的key和value,此时删的就是线程b加的分布式锁,而线程b还在执行业务逻辑!
- 这个时候,恰巧线程c也访问到了这个接口,线程c发现没有分布式锁(实际上被a删了,但b还在执行!),故此c线程加锁,然后执行逻辑。然后线程b执行完了之后,如果c线程没执行完,那么b线程删的就是c线程加的分布式锁!
经过上面的分析,发现如果处于高并发,且网络有延时的情况,就可能会导致分布式锁失效问题,而且可能是永久失效!
3.3 解决分布式锁失效问题
我们想要只允许当前线程去释放自己加的分布式锁,不允许其他线程随意的删除其他线程加的锁,那么就要给锁加一个标志,比如我们给分布式锁使用uuid生成一个当前线程对应的序列号,并设置到分布式锁对应的value中,那么当要释放锁的时候,就要先去检测一下分布式锁的key对应的value是否是当前线程枷锁前生成的序列号值,如果,是才允许释放锁。
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id){
//进来的线程必须要获取是否存在分布式锁
String lockKey = "stock:"+id+":lockey";
String lockKeyValue = UUID.randomUUID().toString();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKeyValue,10,TimeUnit.SECONDS);
//为分布式锁设置过期时间
if (!flag) {
//设置失败,返回
System.out.println(Thread.currentThread().getName() + "服务器繁忙,请稍后重试");
return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
}
try {
//首先用分布式锁,查看是否存在锁
String key = "stock:" + id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if (stock <= 0) {
System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}finally {
if(lockKeyValue.equals(stringRedisTemplate.opsForValue().get(lockKey))){
stringRedisTemplate.delete(lockKey);
}
}
}
上面的代码还是会存在一个问题。如果一个线程执行时间有超过了锁过期时间,别的线程依旧能够加锁。这种解决办法通常是设置一个定时器进行锁的续期。
假设线程a,加了把分布式锁,设置过期10s,但是它却要执行30s,当他执行到10s,锁就没了,其他线程可以随意加锁,放到抢购环境下,就会出现超卖问题! 那么解决这种现象,我们为每一个加锁的线程,设置一个定时器,定期的去轮询当前线程是否执行完任务,判断是否执行完成的标准是查看是否存在分布式锁,如果检测发现存在分布式锁,那就将锁的过期时间延长,如果没有发现该线程对应的分布式锁,说明任务已经完成了,结束定时任务。这样来就能保证线程执行完成,分布式锁依然存在。
实际上市面上有很多成熟的框架用以支持分布式锁,比如我们的redission,这套框架可以很简单的实现上面我们加分布式锁的流程。
4、使用redisson实现分布式锁
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.3</version>
</dependency>
在配置类中往spring容器加入redis对象,下面是使用单台redis服务器的,当然redisson可以支持哨兵模式,redis集群等等api。
@Bean
public Redisson redisson(){
Config config = new Config();
//设置redis服务器连接地址,并设置服务器的redis数据库
config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
//调用Redisson静态构造方法创建redisson对象
return (Redisson)Redisson.create(config);
}
修改controller方法如下:
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id) throws InterruptedException {
//进来的线程必须要获取是否存在分布式锁
String lockKey = "stock:"+id+":lockey";
//redission加锁,注意这里只用传一个lockKey,对应的value会在redisson后台自动加上唯一标志。
RLock lock = redisson.getLock(lockKey);
lock.lock();
try {
//首先用分布式锁,查看是否存在锁
String key = "stock:" + id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if (stock <= 0) {
System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}finally {
lock.unlock();
}
}
要看懂上面的代码,首先了解下redisson加分布式锁后端逻辑
假设线程1和线程2同时访问接口,线程1先调用了redission的lock接口,而线程2迟一步调用
- 此时线程1就开始执行扣减库存的业务操作,并且redisson会为线程1开启一个线程,每个1/3的过期时间(redisson默认的过期时间是30s,1/3过期时间则为10s轮询一次),就会去轮询访问是否该分布式锁还存在,如果存在则进行续期。
- 那么在线程1执行它的减库存任务时,线程2会一直因为lock而阻塞着,直到线程1执行完毕,线程2才可以访问。(当然你也可以使用lock.tryLock方法直接让加不了锁的线程直接返回)
当然线程1处理完业务逻辑后,就会进行lock.unlock,此时不必去担心,是否别的线程会干掉线程1加的分布式锁,这些redisson已经帮我们解决了。
更进一步思考
问题一:redis集群下,主节点宕机,从节点未同步分布式锁,导致超卖
上面代码使用redisson改造后的代码,已经可以实现比较完整的分布式锁了,在单题redis服务的情况下,不会出现超卖的情况。但是如果时在redis集群架构的情况,还是会出现问题。当你保存分布式锁的那台redis服务器挂掉后,如果你部署了哨兵模式,那就会将主redis节点替换成从redis节点,但是从redis节点还没有来的及将锁同步过去,此时如果有线程继续访问接口,那就会检测到没有分布式锁从而操作减库存业务!在分布式场景下,超卖问题依然会存在!
采用RedissonRedlock
当线程要加分布式锁的时候,我们可以同时向多个redis集群中的节点一起加该分布式锁,当超过半数的redis节点加锁成功的时候,才返回加锁成功。然后线程再进行下面的扣减库存操作,最后对加锁成功的节点当线程执行完成后,要释放分布式锁。
代码:
@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id) throws InterruptedException {
//进来的线程必须要获取是否存在分布式锁
String lockKey = "stock:"+id+":lockey";
//这里需要自己实例化不同的redis实例的redisson客户端连接。
RLock lock = redisson.getLock(lockKey);
RLock lock2 = redisson2.getLock(lockkey);
//根据多个RLock对象构建RedissonRedLock,最根本的差别在这
RedissonRedLock redLock = new RedissonRedLock(lock,lock2);
try {
//首先用分布式锁,查看是否存在锁
String key = "stock:" + id;
//获取货物库存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
//检测库存是否小于等于0,如果是则提示库存不足
if (stock <= 0) {
System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
}
//处理减库存业务
stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
}finally {
//最后释放redLock
redLock.unlock();
}
}
问题二:在高并发场景下,性能有瓶颈
redis本身时一个单线程模型,他会对请求先后排序执行命令,尽管redis的性能很高,可以达到每秒几万的qps。但是如果在一些大并发量的场景下,这些就不够用了,比方说抢购商品,同时有30w人抢购,那必然会遇到redis的一个性能瓶颈。
redis分布式锁天生就是与高并发相悖的。
采用分段锁思想(ConcurrentHashMap底层也是使用这个去提升性能的)
假设某件商瓶id为1001 库存为 1000 , 如果我们直接的设置一个key = stock:1001 value=1000 去存的话,你在redis集群架构通过对hash取值之后,只会映射到固定的一个redis节点的槽点。最终存放的该库存数都集中于这台redis服务器上。
采用分段锁的思想就是,我们可以将这个商品的key划分成几段,每段存放一定的数量的该商品货物,比如可以将stock:1001划分如下:
- key = stock:1001:fragement1 value = 200
- key = stock:1001:fragement2 value = 200
- key = stock:1001:fragement3 value = 200
- key = stock:1001:fragement4 value = 200
- key = stock:1001:fragement5 value = 200
我们将这个商品的key和value平均分成5段,那么加分布式锁的时候,就可以按这个key的段去加,分散开来,这样性能就提升上去了!