由秒杀引发的一个问题

  • 秒杀最大的一个问题就是解决超卖的问题。其中一种解决超卖如下方式:
update goods set num = num - 1 WHERE id = 1001 and num > 0

我们假设现在商品只剩下一件了,此时数据库中 num = 1;

但有100个线程同时读取到了这个 num = 1,所以100个线程都开始减库存了。

但你会最终会发觉,其实只有一个线程减库存成功,其他99个线程全部失败。

为何?

这就是MySQL中的排他锁起了作用。

排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。

就是类似于我在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能被任何其他线程修改和读写

  • 第二种解决超卖的方式如下
select version from goods WHERE id= 1001;
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND version = @version(上面查到的version);

而且还应该在执行该sql语句前增加一个num数目是否大于0的业务逻辑判断。
在mysql中,这里实际上还是会加排它锁的,但是采用版本号也是解决超卖的一种方式,只不过用version的方式代替掉了数据库中num>0这语句的作用,将num>0判断放置在了业务逻辑中进行
实际上,这两种方式解决超卖的方式也有细微的一点区别。考虑两个线程,当库存数量为2时,如果是第一种方式,那么两个线程都能成功执行。如果为第二种方式,如果在第一个线程提交事务之前,第二个线程也执行了相同的sql拿到了version值(也就是线程1和线程2拿到了相同的version值),那么这两个线程之间将只有一个线程能够让库存数目减一成功执行。最终库存数目不为0,而为1

这种方式采用了版本号的方式,其实也就是CAS的原理。

假设此时version = 100num = 1; 100个线程进入到了这里,同时他们select出来版本号都是version = 100。

然后直接update的时候,只有其中一个先update了,同时更新了版本号。

那么其他99个在更新的时候,会发觉version并不等于上次selectversion,就说明version被其他线程修改过了。那么我就放弃这次update

  • 第三种解决超卖的方式如下

利用redis单线程预减库存

比如商品有100件。那么我在redis存储一个k,v。例如 <gs1001, 100>

每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。

那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象。

SpringBoot + redis解决商品秒杀库存超卖,看这篇文章就够了 - 知乎 (zhihu.com)

在众多抢购活动中,在有限的商品数量的限制下如何保证抢购到商品的用户数不能大于商品数量,也就是不能出现超卖的问题;还有就是抢购时会出现大量用户的访问,如何提高用户体验效果也是一个问题,也就是要解决秒杀系统的性能问题。

本文主要介绍基于redis 实现商品秒杀功能。先来跟大家讲下大概思路。总体思路就是要减少对数据库的访问,尽可能将数据缓存到Redis缓存中,从缓存中获取数据。

  • 在系统初始化时,将商品的库存数量加载到Redis缓存中,并不是需要先请求一次才能缓存
  • 接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,减少对数据库的访问。否则继续进行第3步;
  • 将请求放入异步队列(RabbitMQ)中,立即给前端返回一个值,表示正在排队中
  • 服务端异步队列将请求出队,出队成功的请求可以然后进行秒杀逻辑,减库存–>下订单–>写入秒杀订单,成功了就返回成功。
  • 当后台订单创建成功之后可以通过websocket向用户发送一个秒杀成功通知。前端以此来判断是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。
  1. 系统初始化的时候将秒杀商品库存放入redis缓存
//首先我们需要实现InitializingBean接口,InitializingBean接口为bean提供了初始化方法的方式,它就包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候会执行该方法。
@Component
public class WebListener implements InitializingBean{
  @Autowired
  private RedisTemplate redistemplate;
  
  @Override
  public void afterPropertiesSet() throws Exception{
    List<GoodsVo> goodsList = goodsService.listGoodsVo();
		if(goodsList == null) {
			return;
		}
		for(GoodsVo goods : goodsList) {
			redistemplate.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
			localOverMap.put(goods.getId(), false);//先初始化 每个商品都是false 就是还有库存
		}
  }
}
//这就实现了我们系统启动就把所有缓存加载完毕,然后我们通过操作redis来实现预减库存
  1. 预减库存 请求放到异步队列
//然后当我们的并发量够大,redis的压力页很大,然后我们可以通过map集合标记缓存,减少redis服务器的压力

// 1、生成一个map,并在初始化的时候,将所有商品的id为键,标记false 存入map中。
// 2、在预减库存之前,从map中取标记,若标记为false,说明库存,还有,
// 3、预减库存,当遇到库存不足的时候,将该商品的标记置为true,表示该商品的库存不足。
这样,下面的所有请求,将被拦截,无需访问redis进行预减库存。

//系统启动时会对其初始化,将所有秒杀商品id存入map,库存为0是为true
private Map<Long,Boolean> localOverMap = new HashMap<Long,Boolean>();


//====================================================================================

	@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
	@ResponseBody
	public Result<Integer> miaosha(HttpServletRequest request, HttpServletResponse response,
								   Model model,MiaoshaUser user,
								   @RequestParam("goodsId")long goodsId,
								   @PathVariable("path") String path) {
		model.addAttribute("user", user);
		//如果用户为空,则返回至登录页面
		if(user == null) {
			return Result.error(CodeMsg.SESSION_ERROR);
		}
		//验证path
		boolean check = miaoshaService.checkPath(user, goodsId, path);
		if(!check){
			return Result.error(CodeMsg.REQUEST_ILLEGAL);
		}
		//内存标记,从map取值判断,减少redis访问
		boolean over = localOverMap.get(goodsId);
		if(over) {
			return Result.error(CodeMsg.MIAO_SHA_OVER);
		}
		//预减库存 这里的减库存是原子性的操作
		long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
		if(stock < 0) {
			localOverMap.put(goodsId, true);
			return Result.error(CodeMsg.MIAO_SHA_OVER);
		}
		//判断是否已经秒杀到了
		MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
		if(order != null) {
			return Result.error(CodeMsg.REPEATE_MIAOSHA);
		}
		//入队
		MiaoshaMessage mm = new MiaoshaMessage();
		mm.setUser(user);
		mm.setGoodsId(goodsId);
		sender.sendMiaoshaMessage(mm);
		//返回0代表排队中
		return Result.success(0);
	}
// redis给数据库减轻压力,利用map标记库存给redis减轻压力

还有一种写法:

img

这里使用到了redis api中的decrement操作,预减用户抢购的数量,同时判断redis中的库存是否大于用户抢购数量,如果小于0,直接提示用户秒杀失败,否则秒杀成功,进入redis消息队列执行数据库建库存操作。

  • 由于是通过异步队列写入数据库中,可能存在数据不一致。

闲谈秒杀系统(二)解决一致性问题 - 知乎 (zhihu.com)

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐