先看一个例子:在商品秒杀活动中,多个线程一起进行下单,它们首先查询库存数量,接着判断是否符合条件,然后保存订单信息,最后将库存数量减一后更新到数据库。在这个过程中,存在着共享资源(库存数量),而且一系列的操作没有原子性(每个环节之间都可能被其它线程抢走了CPU执行权)。所以,就存在着线程安全问题,表现出来的结果通常就是“商品超卖”。
下面我们用代码模拟一下这个场景:
1、定义两种商品1和2,并且分别设置库存为300件和500件。并声明两个变量,用于记录各种商品卖出情况。
2、编写代码,模拟多线程下单情况,并最后记录卖出和剩余信息。
※多线程模拟(CyclicBarrier和CountDownLatch为java.util.concurrent包提供的多线程并发协同工具)
※业务逻辑
3、使用Junit测试。
4、测试结果。很明显,出现了超卖的情况。
那么,有没有解决办法呢?
有!解决办法就是同步:将一系列的操作设置为原子性。
首先想到的是使用Synchronized关键字,它可以保证一段代码具有原子性、共享变量具有可见性。
那我们在方法上加上synchronized关键字,然后测试一下
结果很慢,但是总算没有出现超卖的情况。不管怎么说,synchronized是可以的。
所以,使用synchronized这个重量级锁的缺点是:
1、无法做到细粒度控制
比如秒杀商品,不能只锁住指定商品id
2、只适合单点的情况
使用集群之后无法使用
在这种情况下,redis分布式锁闪亮登场!!!
一、redis简介
redis是一个key-value存储系统。它支持存储的value类型很多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。为了保证效率,数据都是缓存在内存中。redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
中文网站:http://www.redis.cn/
扩展:设置外网访问
二、相关命令
1、SETNX(详情请参考http://www.redis.cn/commands/setnx.html)
SETNX key value
将key设置值为value,如果key不存在,这种情况下等同SET命令。
当key存在时,什么也不做。SETNX是”SET If Not Exists”的简写。
2、GETSET(详情请参考http://www.redis.cn/commands/getset.html)
将key的值设置为value并且返回原来key对应的value。
三、搭建开发环境
1、Spring Boot项目引入redis
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、配置
3、 引入bean,注意:Spring Boot封装好的对象,它的操作方法名可能跟Jedis不一样
四、写工具类:加锁和解锁
第一部分大家都知道,如果不存在key并且设值成功,说明加锁成功,是理想状态。
但是,也存在特殊情况导致的加锁后没有释放锁:这样,就要根据value(当前时间+超时时间)判断了。
/**
* 加锁
* @param key
* @param value 当前时间+超时时间:这样是为了处理异常情况下没有删掉的key
* @return true or false
*/
public boolean lock(String key, String value) {
/*
* 理想状态下,能够设值表示这个key不存在,没有被占用,加锁成功
*/
if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
return true;
}
/*
* 但是,有时候加锁了之后,在释放锁之前,由于中间代码抛异常(可以用try...finally解决)或者redis服务器宕机而导致的锁没释放。
* 所以,需要判断过期时间,处理死锁问题
*/
// 之前的值
String currentValue = redisTemplate.opsForValue().get(key);
// 如果这个锁存在并且过期了,会执行这段代码,注意:并发情况下多个线程会进去
if (!StringUtils.isEmpty(currentValue)
&& Long.parseLong(currentValue) < System.currentTimeMillis()) {
// 这段代码也可能多线程执行,但是getAndSet是原子方法,只有第一个执行的线程会拿到之前的值
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
// 只有拿到之前的值的线程(也就是第一个执行上一行代码的线程)会拿到锁
// 存在的问题:因为多个线程执行了getAndSet方法,所以导致value是最后一个线程设置的值,存在误差
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) {
return true;
}
}
return false;
}
扩展,尝试指定时间获取锁
/**
* 加锁
* @param key
* @param value 当前时间+超时时间:这样是为了处理异常情况下没有删掉的key
* @param trySeconds 尝试的秒数
* @return true or false
*/
public boolean tryLock(String key, String value, int trySeconds) {
long expire = System.currentTimeMillis() + trySeconds * 1000;
while (System.currentTimeMillis() < expire) {
if (lock(key, value)) {
return true;
}
// 随机等待1-2秒
Utils.randomSleepSecond(1, 2);
}
return lock(key, value);
}
释放锁
/**
* 释放锁:也就是删除key
* @param key
* @param value
*/
public void unlock(String key, String value) {
try {
String currentValue = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
redisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
System.out.println("【redis分布式锁】解锁异常!" + e);
}
}
五、测试
定义一些常量
业务逻辑前面加锁,后面解锁
private/* synchronized*/ void consume(Integer productId) {
// 加锁
String key = KEY_PREFIX + productId;
String value = Long.toString(System.currentTimeMillis() + TIMEOUT);
boolean isLock = false;
try {
// 获取不到锁时重试指定秒数
isLock = lock.tryLock(key, value, 10);
} catch (Exception e) {
System.out.println("【redis分布式锁】加锁失败!" + e);
throw e;
}
if (isLock) {
AtomicInteger residue = productId == 1 ? residue1 : residue2;
AtomicInteger consume = productId == 1 ? consume1 : consume2;
Integer residueCount = residue.get();
// 有库存
if (residueCount > 0) {
// 随机睡眠,模拟下单场景
Utils.randomSleepSecond(0, 1);
// 减一个库存
residue.decrementAndGet();
// 记录卖出信息
consume.incrementAndGet();
}
// 解锁
lock.unlock(key, value);
}
}
结果
总结:个人觉得redis分布式锁的的缺点是:当获取不到锁的时候 ,会直接返回(也可以重试一段时间,但最终还是要返回)给客户提示不能往下操作啥的,但是商品还是有库存的。这一点不同于synchronized关键字,它会阻塞,一定会判断有没有库存。
解决方法:这种情况是不是使用消息队列可以处理?或者还有其它什么方案?欢迎广大网友们在评论区留言讨论!