问题:
对于高并发的数据请求(简称秒杀活动~~),比如像双十一的活动,有很多的用户去买东西,(哦,偶的荷包。。)
额,,对于如此之大的数据量,我都怀疑在多线程的处理下,这,数据不丢失,我当时测试多线程的时候如果线程过多的话,1+1都可能不去等于2了,而这样的情况就会产生所谓的“超卖”现象。。
所以,我有点吃多了,非要去弄一下这个高并发线程处理数据的问题。。
果然,我被gank了,额,这里因为重做电脑,测试软件没了,截图也没了。。
反正知道多线程的人都会知道,,我模拟了一下“秒杀”事件
这里来一段代码(模拟“秒杀商品”情况)
public class KillServiceImpl implements KillService {
private static final int TIMEOUT = 10 * 1000;//超时 10s
@Autowired
private RedisLock redisLock;
/**
* 某产品,数量为10w
*/
static Map<String, Integer> products;
static Map<String, Integer> stock;
static Map<String, String> orders;
static {
/**
* 模拟一下数据库表,
*/
products = new HashMap<>();// 商品信息表
stock = new HashMap<>();// 库存表
orders = new HashMap<>();// 订单表(秒杀)
products.put("1", 100000);
stock.put("1", 100000);
}
private String queryMap(String productId) {
System.out.println("商品" + products.get(productId) + "还剩: " + stock.get(productId) + "份" + "成功下单人数: "
+ orders.size() + "人");
return null;
}
@Override
public String query(String productId) {
return this.queryMap(productId);
}
@Override
public void orderProductMockDiffUser(String productId) {
//加锁
long time = System.currentTimeMillis() + TIMEOUT;
if(!redisLock.lock(productId, String.valueOf(time))) {
throw new RuntimeException();
}
int stockNum = stock.get(productId);
// 查询该商品的库存,若为0,则终止,即秒杀活动 结束
if (stockNum == 0) {
throw new RuntimeException();
} else {
// 下单
orders.put(genUniqueKey(), productId);
// 减库存
stockNum = stockNum - 1;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
stock.put(productId, stockNum);
}
//解锁
redisLock.unLock(productId, String.valueOf(time));
}
public static synchronized String genUniqueKey() {
Random random = new Random();
Integer number = random.nextInt(900000) + 100000;
return System.currentTimeMillis() + String.valueOf(number);
}
}
这就是模拟的 sever层的代码,因为是在一个web项目上测试的,所以,你懂的,Controller层什么的就不写了,反正就是个URL路由。。
这里最后的那个 genUniqueKey()是为了当时功能需求,是一个自动生成唯一主键的函数,逻辑都在前面
结果:
我设置了 500个线程同时运行,明显的出现了 订单人数 + 剩余库存 > 总库存的现象,即“超卖现象”
解决一:(synchronized,很难受的解决方案)
对于多线程并发处理的问题,我直接想到了通过加锁(synchronized)
就是在orderProductMockDiffUser()的函数上设置了synchronized属性,来保证每一个线程都会完整的执行这段代码,,
额,想法是好的,结果呢,也实现了不出现“超卖”的现象,但是!!!!这速度,也太慢了,,我才设置了500个线程,,足足3分钟才完事,我如果是用户。此刻一定会摔手机的。。
解决二(Redis分布式锁):
加锁:
通过使用Redis的命令:
- setnx Redis官方文档命令-setnx
- getset Redis官方文档命令-getset
先上代码,然后解释
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加锁
*
* @param key
* @param value 当前时间 + 超时时间
* @return
*/
public boolean lock(String key, String value) {
if (!redisTemplate.opsForValue().setIfAbsent(key, value)) {
return true;
}
String currentValue = redisTemplate.opsForValue().get(key);
// 如果锁过期
if (StringUtils.isEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) {
// 获取上一个锁时间
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) {
return true;
}
}
return false;
}
}
首先在这里定义一个lock()方法,来实现加锁操作。
这里,先只解释setnx的逻辑,关于getset后面会继续!
解释一下这里的逻辑:
- 使用setnx来判断当前线程是否加锁
这里就是使用了setnx的命令,在java中为setIfAbsent(key, value),
通过上面给的Redis文档上可以看到,这个函数返回的是0,1;意思是如果设置一个value(对应的key不存在),那么,就set一个key-value,如果设置的这个value以及有对应的key值,那么就不执行任何操作;
这样的话,我们就可以理解:当一个线程执行setnx返回1时,说明key原本不存在,该线程成功的得到了锁;当一个线程执行setnx返回0时,说明key已经存在,该线程抢占锁失败。
- 锁超时,设置value值为 当前时间 + 超时时间
锁超时就是说:如果一个得到锁的线程在执行任务的过程中挂掉,没有来得及释放锁,这样,这块资源将会被永远锁住,别的线程再也进不来了。
so,这里就要要设置超时时间,以保证即使产生上述情况的线程没有被显示释放,这把锁也一定要在一段时间后自动释放
但是,setnx不支持设置超时参数,所以需要用expire命令
关于使用expire设置超时时间,上述代码没有通过这个实现,而是在service直接设置了超时时间。
==========================================分割线============================================
但是!!如果仅仅是使用setnx命令就能实现分布式加锁的话,那就错了
所以这里一会就要说getset的使用了
举个场景例子:
在一段代码中,先是进行了加锁操作,即上述代码的lock()函数(仅使用setnx指令),然后程序继续进行,突然,因为某种原因抛出了一个异常,程序在这里就终止了,然后,继续有其他线程进到这个方法时,因为setnx此时的返回为1,为false,就不会获得锁,这样的话,就会导致死锁的产生
所以,对于这种情况,就还需要getset了
- 首先判断一下锁是否过期,即判断一下“当前时间+超时时间”即currentValue是否为空并且是小于当前时间,
- 然后获取上一个锁的时间,进行判断,当前的currentValue是否和上一个锁的时间相等,如果相等了,就返回true,这样就可以保证有一个线程可以打开一把锁,避免了死锁。
这里再举个例子,用场景来执行一下这段代码:
此时,有两个线程,线程A和线程B,在执行到setnx的函数时,锁都被占用,然后继续进行代码,先设置此时的currentValue为A,线程A,线程B的value都是B,然后它们的锁都过期了,这样就执行到了getset的函数那里,线程A,线程B执行getset的函数时一定有先后顺序,虽然间隔很短,这里多线程的问题不多说,这样的话,oldvalue的值此时为A,然后再将Bset到key中,再然后进行下面的判断时,oldValue为A,currentValue也为A,所以返回了一个true,然后另一个线程再来执行这里,此时oldValue为B,currentValue为A,返回一个false;
所以,通过这个getset代码,可以解决了死锁的问题,又保证了多个线程进来时,只会有一个线程能拿到锁。
额,因为线程这里不能打断点调试看结果,所以,口头叙述,不知道到时候自己再回来看这块的逻辑能不能看得明白。。。
========================================分割线==============================================
剩下的就是解锁了。就是一个delete操作。
/**
* 解锁
* @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) {
e.printStackTrace();
}
}
总结:
在我认为,分布式锁就是多台机器上多个线程对一个数据进行操作的互斥;
而Redis适合作分布式锁的原因很大程度上是因为它是单线程的。所以实现起来很符合要求。