目录
1、超卖现象
超卖现象大家都知道是什么,我们思考一下,为什么会超卖?
当库存接近于0的时候,在高并发的情况下会出现某时刻多个线程查询库存够的,但下一时刻某个线程秒杀成功,对库存进行减操作,使得库存变为0,照理现在的状态是不能下单成功的,因为库存已经不够了,但别的线程仍然认为数量还够,对库存进行减操作,从而导致库存出现负数的情况,那这就是超卖了。那么有小伙伴说这个问题简单,对库存加锁啊,Lock、Synchronized或者cas乐观锁,那不就解决了。加锁是一个思路,那我们再考虑一个问题,我们的秒杀服务部署是单机还是分布式呢?如果是单机的,加锁当然可以解决问题,就是可能性能会差点,并发量没有那么大,那如果是分布式部署,单机的锁已经无法解决问题了。所以我们需要换一个思路,使用redis来解决超卖的问题,以下上干货!
2、重复购买现象
同一个用户在同一时刻多次秒杀同一个商品,造成同一个用户可以购买多个秒杀商品的现象,这个问题与上面的超卖问题类似。
3、Jmeter压测演示
首先我们看下如何使用Jmeter来压测,着急的小伙伴直接通过目录跳到解决方案。
下载地址:Apache JMeter - Download Apache JMeter
下载之后解压,双击/bin/jmeter.bat,会出现dos窗口,接着会出现jmeter界面,注意不要关闭dos窗口,否则jmeter也会跟着关闭了
具体的使用方法,这里也不说了,百度一下,自行解决。下面看下配置就行了
这里的线程组配置的是1000
one-one表示同一个人多次秒杀同一件商品,many-one表示多个人多次秒杀同一件商品。
这里配置了一个同步定时器,表示的意思是依次启动1000个线程,当到达1000个线程的时候,同时去请求接口
这里可以看到redis缓存的库存数已经被减到负数了,这就是超卖的现象。。
4、Redis解决方案
方案一:使用Redis的Watch机制解决
具体的机制理解参考:https://blog.youkuaiyun.com/qq_43371004/article/details/103439599
简单来说就是:对一个键设置监听器,当没有别的线程对这个键进行操作的时候,执行操作,代码类似下面这样
public boolean preOrder(Long userId, String goodsId) {
//判断库存是否足够
int preStock = goodsService.getStock(goodsId);
if (preStock <= 0) {
return false;
}
//先生成预订单,分布式锁,表示用户已经购买过了,否则并发情况下会产生重复购买的情况
PreOrder preOrder = new PreOrder(userId, goodsId);
long result = stringRedisService.hSet(RedisConstant.PREFIX_GOODS_SECKILLING + goodsId, userId.toString(), preOrder);
if (result != 1) {
return false;
}
//如果result == 1则成功,再扣库存
boolean flag = goodsService.decrStock(goodsId);
//可能存在并发情况,扣减之后的库存 < 0,表示库存扣减失败
if (!flag) {
logger.info("秒杀失败");
//删除已经创建的预订单
stringRedisService.hDel(RedisConstant.PREFIX_GOODS_SECKILLING + goodsId, userId.toString());
return false;
}
//判断库存是否小于等于0,更新标志位已售完
int afterStock = goodsService.getStock(goodsId);
logger.info("秒杀成功");
if (afterStock <= 0) {
goodsService.setSaleOver(goodsId);
}
return true;
}
更新库存的代码使用到watch机制,如下:
public boolean decr(String key) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.watch(key);// watch key
String result = jedis.get(key);
if (StringUtils.isEmpty(result) || Integer.parseInt(result) <= 0) {//获取值,如果为0,则返回失败
return false;
}
Transaction tx = jedis.multi();// 开启事务
tx.decr(key);
return !CollectionUtils.isEmpty(tx.exec());//提交事务,如果此时key被改动了,则返回null,否则返回非空
} finally {
returnToPool(jedis);
}
}
这种方案虽然可以解决超卖问题,但是会存在问题,那就是不公平,先来的用户不一定先秒杀到,如下所示:
先来的线程都秒杀失败了,后面的反而成功了,当前如果不在意这些,使用这样方案基本可以解决超卖现象
方案二:使用Lua脚本借本
使用lua脚本解决公平性的问题,使得让先来的用户能够秒杀的商品,如果秒杀10个商品,先来的10个用户可以秒杀到商品,后面的用户没有机会秒杀到商品
代码如下,lua脚本的逻辑很容易看明白,这里不多解释,注意这里在扣减库存的同时也加入用户秒杀成功的订单,防止用户重复秒杀成功
/**
* 输入参数
* 1: goodsId
* 2: userId
* 3: goods_seckilling_key 用户预订单key
* 4: goods_stock_key 库存key
* 5:stock_over_key 售完的key
* 6: stock_over_value 售完标志位
* 7: pre_order 预订单
* 返回结果:
* 0:表示已经抢光了
* 1: 表示抢成功了
* 2:表示已经抢过了
*/
private static final String decrScript =
"local goods_id=KEYS[1];\r\n" +
"local user_id=KEYS[2];\r\n" +
"local goods_seckilling_key=KEYS[3];\r\n" +
"local stock_key=KEYS[4];\r\n" +
"local stock_over_key=KEYS[5];\r\n" +
"local stock_over_value=KEYS[6];\r\n" +
"local pre_order=KEYS[7];\r\n" +
"local userExists=redis.call(\"hexists\", goods_seckilling_key, user_id);\r\n" +
"if tonumber(userExists)==1 then \r\n" +
" return 2;\r\n" +
"end\r\n" +
"local num = redis.call(\"get\" , stock_key);\r\n" +
"if tonumber(num)<=0 then \r\n" +
" redis.call(\"hset\", stock_over_key, goods_id, stock_over_value);\r\n" +
" return 0;\r\n" +
"else \r\n" +
" redis.call(\"decr\", stock_key);\r\n" +
" redis.call(\"hset\", goods_seckilling_key, user_id, pre_order);\r\n" +
"end\r\n" +
"return 1";
public String decrStockAndSavePreOrder(String goodsId, String userId, PreOrder preOrder) {
return stringRedisService.execLua(decrScript, goodsId, userId, RedisConstant.PREFIX_GOODS_SECKILLING + goodsId,
RedisConstant.PREFIX_GOODS_STOCK + goodsId, RedisConstant.GOODS_SALE_OVER, "1", JSON.toJSONString(preOrder)).toString();
}
jmeter启动10000线程同时秒杀,效果如下:可以看到只有先来的线程能够秒杀成功,后面的线程全部显示秒杀结束,这样对所有的用户都是公平的,先来先得!!!