我们知道在秒杀系统中肯定是会碰到超卖的问题的,原因就是高并发请求导致了数据库的脏读和不可重复读,进而造成了超额用户下了订单。
解决方法可以通过封锁协议在数据库端对操作进行加锁,进而提高事务的隔离级别,来到达可串行化调度(多个事务的并发执行是正确的,当且仅当其结果与按某一次序串行地执行这些事务时的结果相同。可串行化调度当然也保持数据库的一致状态)。
但是我们通常更多把处理和压力放在后端,通过对业务加悲观锁或是在表中加校验字段构成乐观锁来解决超卖问题。
1. 悲观锁
悲观锁的核心理念是“请求超了一切就完蛋了”、“打死也不能多放进来一个请求”。加了悲观锁之后的接口在服务器调度时会进行线程控制,即该接口同时至多只能被一个线程所控制,也就是只能同时被一个请求所访问,其他的请求都会处于阻塞状态,直至上一个请求处理结束,控制权被释放,下一个请求再进来,就像银行的柜台处理业务一样,都需要排队,一次一个人。
该方法可以有效的解决超卖问题,每一次请求都可以保证数据库数据的一致性,但是缺点是给用户的体验很不好,之后的用户可能已经注定请求失败,还要等待很长一段时间。
悲观锁的写法是要对加锁的业务接口的方法内加上同步代码块:
@GetMapping("/kill")
public AjaxResponse kill(Integer id){
AjaxResponse ajax = AjaxResponse.newSuccess();
try {
//悲观锁
synchronized (this){
//业务层的秒杀方法:包括减库存和下订单,成功下订单后返回订单id
int orderId = stockOrderService.kill(id);
ajax.setData("SUCCESS! " + orderId);
return ajax;
}
}catch (Exception e){ //可以使用自定义异常捕获
e.printStackTrace();
return AjaxResponse.newInstance(500,e.getMessage());
}
}
synchronized同步字段可以放在方法定义上,也可以放在方法内部,这里推荐在接口内部以添加同步代码块的方法加上悲观锁,出现异常可以方便的被自定义异常类捕获。
经Jmeter压力测试后结果无误,没有发生超卖现象。
但需要强调的一点是synchronized不要和@Transactional一起使用,特别是不要在Service层的方法上加上synchronized,由于Service层的业务方法大多加有事务控制,和悲观锁联合使用的时候,悲观锁解锁的时间比事务提交的时间早,可能会导致少量请求在上一次事务未完全提交就进来,最终导致少量的超卖。所以尽量在接口中加上同步代码块来控制业务的访问。
2.乐观锁
乐观锁的核心理念就是“请求访问自管来,弄错一个算我输”,就像超市大减价,大妈疯狂涌入,最后把商品几乎全部掠空。
乐观锁的实现原理是在要秒杀的商品中加上一个校验字段,每次减库存得到时候需要对比和一开始得到的校验值是否相同,如果相同表示此数据是干净的,可以对其修改,如果不同说明已经有人修改过了。
讲个通俗一点的,还是超市大减价,第一批10个大妈一起冲了进来,每个大妈都在前台领了一个令牌,上面写了1(前台也会保留一份)。之后10个大妈便开始疯狂抢东西,其中一个大妈跑得快,拿到东西第一个回到了前台,和前台的一对比,前台也还是1,该大妈抢货成功,可以拿着东西走了,此时服务员把令牌上的数字改为了2。剩下9个大妈陆续回到前台,由于手中的令牌都是1,和前台的2不匹配,算抢货失败,最后空手而归。下一批10个大妈再此涌进来,拿到的令牌从2开始,依此类推。
上面的例子只是方便理解,没有别的意思。。。。(doge)
假设我们的商品有一个字段为库存count,还有一个字段为version用来校验,那么首先根据id查询此商品,得到库存count和version,之后要对库存减一,即count-1,但是要加上where条件,为 更新id = 此商品id and 该商品的version = 一开始获得的version。如果更新结果为true(或是影响的行数不为零),表明更新成功,之后即可下订单,否则抛出异常表示失败。
//Service层的秒杀业务
public Integer kill(Integer id) {
//先查询改商品
Stock stock = stockService.getById(id);
if(stock.getCount() <= 0){ // <=0防止数据击穿
log.info("库存不足");
throw new RuntimeException("库存不足");
}else {
//更新库存,这里使用mybatis plus
LambdaUpdateWrapper<Stock> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.setSql("sale = (sale + 1)"); //设置更新内容
updateWrapper.setSql("version = (version + 1)"); //设置更新内容
updateWrapper.eq(Stock::getId,stock.getId()) //判断条件
.eq(Stock::getVersion,stock.getVersion()); //判断条件
boolean res = stockService.update(updateWrapper); //得到结果
if(!res){
throw new RuntimeException("抢购失败");
}
//创建订单
StockOrder stockOrder = new StockOrder();
stockOrder.setSid(stock.getId()).setName(stock.getName()).setCreateTime(new Date());
stockOrderService.save(stockOrder);
return stockOrder.getId();
}
}
以上就是通过悲观锁和乐观锁来解决秒杀系统中的超卖问题,当然这两个方法是解决改问题的核心,我们还可以用一些额外的方法来优化请求数量,减少并发,环节服务端的压力,例如使用令牌桶算法限流,redis缓存限时,消息队列存储成功请求,加快处理时间等。
如果本文在哪些地方讲解有误,欢迎在下午评论指出~~