秒杀系统优化以及解决超卖问题

问题描述

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

解决超卖的方案

每一个用户只能抢购一件商品的限制;在数据库减库存时加上库存数量判断,库存数量为0时阻止秒杀订单的生成。

  1. 数据库加唯一索引:防止用户重复购买
  2. SQL加库存数量判断:防止库存变为负数

解决性能问题

  1. 使用Redis缓存预减库存,减少数据库的访问。因为缓存的访问速度比数据库访问快得多。
  2. 使用内存标记,减少Redis缓存的访问次数。
  3. 使用队列等异步手段,请求先入队缓冲,异步下单,将数据写入数据库,增强用户体验。

性能解决方案

总体思路就是要减少对数据库的访问,尽可能将数据缓存到Redis缓存中,从缓存中获取数据。

  1. 在系统初始化时,将商品的库存数量加载到Redis缓存中;
  2. 接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
  3. 将请求放入异步队列中,返回正在排队中;
  4. 服务端异步队列将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。
  5. 用户在客户端申请秒杀请求后,进行轮询,查看是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。

缺陷

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

实现

  1. 系统初始化,将库存加载到Redis缓存中
Controller 实现 InitializingBean 接口,并重写 afterPropertiesSet方法,这样可以保证系统初始化的时候讲物品库存加载到Redis缓存中
@Override
    public void afterPropertiesSet() throws Exception {
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        if (goodsList == null)
            return;
        for (GoodsVo goods : goodsList) {
            redisService.set(GoodsKey.getMiaoshaGoodsStock, "" + goods.getId(), goods.getStockCount());
            localOverMap.put(goods.getId(), false);// 设置商品是否卖完的内存标志,初始化时为false,没有卖完
        }
    }
  1. 预减库存,入队
 //利用map定位某件商品是否已经卖完,卖完了就可以不用去访问Redis缓存了,分别放置<商品ID,是否卖完标志>
 private HashMap<Long, Boolean> localOverMap = new HashMap<>();

public Result<Integer> miaosha(Model model, MiaoshaUser user,
                                   @RequestParam("goodsId")long goodsId) {
        model.addAttribute("user", user);
        if(user == null) {
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        //做了一个内存标记,减少Redis缓存访问,一旦对于商品库存没有了,over为真,将直接返回秒杀失败,不进行后面的Redis缓存访问。
        boolean over = localOverMap.get(goodsId);
        if (over) {
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
        //从Redis缓存中进行预减库存
        long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
        // 预减库存时库存不足,直接返回秒杀失败
        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);
        //在异步队列中写入用户信息和对应商品ID,因为服务端可以根据用户ID和商品ID定位用户是否秒杀过对应商品,并且服务端需要根据商品ID去查询数据库中对应商品的库存是否足够。
        sender.sendMiaoshaMessage(mm);
		//并非直接返回下单结果,而是分步骤进行,相当于将提交操作变成两段式,先申请后确认。
		//申请之后进入排队中,确认是否可以秒杀成功由服务端进行确认。
        return Result.success(0);//0代表正在排队中
    }
  1. RabbitMQ实现异步队列
// 配置文件,构建异步队列
@Configuration
public class MQConfig {
	public static final String MIAOSHA_QUEUE = "miaosha.queue";
	// Direct模式
	@Bean
    public Queue miaoShaQueue(){
        return new Queue(MIAOSHA_QUEUE,true);
    }
}
// 发送
public class MQSender {
    @Autowired
    AmqpTemplate amqpTemplate;
    public void sendMiaoshaMessage(MiaoshaMessage mm) {
    	// 将对象转化为String进行发送
        String msg = RedisService.beanToString(mm);
        log.info("send message:"+msg);
        amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
    }
}
// 接收到请求,进行处理
public class MQReceiver {
	// 监听一个名为MQConfig.MIAOSHA_QUEUE的队列
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receive(String message) {
        log.info("receive message:"+message);
        // 将接收到的String转化回Object对象,可以从中获取相应信息
        MiaoshaMessage mm  = RedisService.stringToBean(message, MiaoshaMessage.class);
        MiaoshaUser user = mm.getUser();
        long goodsId = mm.getGoodsId();
		//服务端从数据库中获取相应商品信息,查看库存是否不足
        GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        int stock = goods.getStockCount();
        if(stock <= 0) { // 库存不足,直接返回
            return;
        }
        //判断是否已经秒杀过了
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            return;
        }
        //在最后才进行数据库的操作,进行减库存 下订单 写入秒杀订单操作
        miaoshaService.miaosha(user, goods);
    }
}
  1. 服务端操作数据库进行 减少库存 下订单 秒杀订单
	// SQL加库存数量判断:利用stock_count > 0限制,防止出现超卖,只有stock_count > 0时才能更新库存操作
	@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
    public int reduceStock(MiaoshaGoods g);

	// 判断是否可以对数据库进行减库存
	public boolean reduceStock(GoodsVo goods) {
        MiaoshaGoods g = new MiaoshaGoods();
        g.setGoodsId(goods.getId());
        int res = goodsDao.reduceStock(g);
        return res > 0;
    }

	//减少库存 下订单 秒杀订单 这三个操作必须是原子性,利用@Transactional注解将这3个操作放在一个事务里面,保证同时成功,否则失败
    @Transactional
    public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
        //判断是否可以减少库存
        boolean success = goodsService.reduceStock(goods);
		// 库存足够
        if (success) {
            //下订单,秒杀订单
            return orderService.creatOrder(user, goods);
        } else { //无法减少库存,说明该对应商品库存不足了
            return null;
        }
    }

其他类似解决方案说明

  • 解决方案1

将存库MySQL迁移到Redis中,所有的写操作放到内存中,由于Redis中不存在锁故不会出现互相等待,并且由于Redis的写性能和读性能都远高于MySQL,这就解决了高并发下的性能问题。然后通过队列等异步手段,将变化的数据异步写入到DB中。

优点:解决性能问题

缺点:没有解决超卖问题,同时由于异步写入DB,存在某一时刻DB和Redis中数据不一致的风险。

  • 解决方案2

引入队列,然后将所有写DB操作在单队列中排队,完全串行处理。当达到库存阀值的时候就不在消费队列,并关闭购买功能。这就解决了超卖问题。

优点:解决超卖问题,略微提升性能。

缺点:性能受限于队列处理机处理性能和DB的写入性能中最短的那个,另外多商品同时抢购的时候需要准备多条队列。

  • 解决方案3

将提交操作变成两段式,先申请后确认。然后利用Redis的原子自增操作(相比较MySQL的自增来说没有空洞),同时利用Redis的事务特性来发号,保证拿到小于等于库存阀值的号的人都可以成功提交订单。然后数据异步更新到DB中。

优点:解决超卖问题,库存读写都在内存中,故同时解决性能问题。

缺点:由于异步写入DB,可能存在数据不一致。另可能存在少买,也就是如果拿到号的人不真正下订单,可能库存减为0,但是订单数并没有达到库存阀值。

你可能感兴趣的:(消息队列)