一秒杀场景扣减库存

1.业务场景

背景:分布式系统,高并发场景 商品A只有500库存,现在有3000或者更多的用户购买。如何保证库存在高并发的场景下库存扣减是安全的。 理想结果:1.商品不超卖 2.商品不少卖 3.下单响应速度要快 4.用户体验好

1.1具体步骤

  • 在用户下单时生成订单,减库存,同时记录库存流水,但是这里需要先进行库存操作再生成订单数据,这样库存修改成功,响应超时的特殊情况也可以通过第四步定时校验库存流水来完成最终一致性。
  • 支付成功删除库存流水,处理完成删除可以让库存流水数据表数据量少,易于维护。
  • 未支付取消订单,还库存+删除库存流水
  • 定时任务校验库存流水记录,结合订单状态进行相应处理,保证最终一致性
  • (退单有单独的库存流水,申请退单插入流水,退单完成删除流水+还库存)
  • 1.2 扣减库存的时机

    1.2.1 大概有四种方案

  • 方案一:加购车时减库存。
  • 方案二:确认订单页减库存。
  • 方案三:提交订单时减库存。
  • 方案四:支付时减库存
  • 1.2.2 具体方案分析

  • 方案一:加入购物车并不代表用户一定会购买,如果这个时候处理库存,会导致想购买的用户显示无货。而不想购买的人一直占着库存。显然这种做法是不可取的。但是有一种方案也是可以解决,购物车有相应时效性,超时会删除购物车,但是这种方案在jd和tb中都没有使用,这种也会有相应影响,比如秒杀的时候,有可能过了秒杀时段就没有用户下单,或者过了秒杀时段用户看到邮可以下单体验很不好。
  • 方案二:确认订单页用户确实有购买欲望,但是此时没有提交订单,减库存会增加很大的复杂性和不确定性,而且确认订单页的功能是让用户确认信息,减库存也不太不合理。
  • 方案三:提交订单时减库存。用户选择提交订单,说明用户有强烈的购买欲望。生成订单会有一个支付时效,例如半个小时。超过半个小时后,系统自动取消订单,还库存,现在主流大多用的这种
  • 方案四:支付时去减库存。比如:只有200个用户可以支付,800个用户不能支付。到了支付环节用户耗费了很多精力,但是不付款这样用户体验太差,同时生成了800个无效订单数据,也不可取。 所以对比四种方案,第三种还是较为可行的。
  • 1.3 重复下单问题

    1.3.1 重复下单我现在想到的场景有如下:

  • 用户点击过快,重复提交。
  • 网络延时,用户重复提交。
  • 网络延时高的情况下某些框架自动重试,导致重复请求。
  • 前端拦截,点击后按钮置灰。
  • (1)redis 防重复点击,在下单前获取用户token,下单的时候后台系统校验这个 token是否有效,导致的问题是一个用户多个设备不能同时下单。
    //key , 等待获取锁的时间 ,锁的时间
    redis.lock("user-order-submit" + token, 1L, 10L);
    

    如果加上设备限制的可以使用下边的 , 一个用户多个设备可以同时下单。

    //key , 等待获取锁的时间 ,锁的时间
    redis.lock("user-order-submit" + token + deviceType, 1L, 10L);
    

    (2)防止恶意用户,恶意攻击,比如爬虫流量 : 一分钟调用下单超过10次 ,加入临时黑名单 ,10分钟后才可继续操作,30分钟允许一次跨时段的用户校验。这个时候如果我们是集群部署则可以使用reids的list结构,过期时间30分钟。

    public boolean verifyUserToken(String token) {
        //获取用户下单次数 1分钟50次
        String blackUser = "user-order-submit-black-" + token;
        if (redis.get(blackUser) != null) {
            return false;
        String keyCount = "user-order-submit-count-" + token;
        Long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
        //每30分钟清一次key 过期时间1小时
        Long count = redis.rpush(keyCount, String.valueOf(nowSecond), 30 * 60);
        if (count < 50) {
            return true;
        //获取第50次的时间
        List<String> secondString = redis.lrange(keyCount, count - 50, count - 49);
        Long oldSecond = Long.valueOf(secondString.get(0));
        //now > oldSecond + 60 用户可下单
        boolean result = nowSecond.compareTo(oldSecond + 60) > 0;
        if (!result) {
            //触发限制,加入黑名单,过期时间10分钟
            redis.set(blackUser, String.valueOf(nowSecond), 10 * 60);
        return result;
    

    1.4 怎么才能安全扣减库存

    1.4.1 库存数据库模型

    Insert into antiRe(code) value (‘订单号+Sku’) Update stockNum set num=num-下单数量 where skuId=商品ID and num-下单数量>0

    面临系统流量越来越大,数据库的性能瓶颈就会暴露出来:就算分库分表也是没用的,促销的时候高并发都是针对少量商品的,最终并发流量会打向少数表,只能去提升单分片的抗量能力。我们接下来设计一种使用Redis缓存做库存扣减的方案。

    1.4.2 多用户抢购时,并发情况下如何做到并发安全减库存?

  • 方案1: 在并发量不高的情况下使用数据库操作商品库存采用乐观锁防止超卖:
  • sql:update sku_stock set stock = stock - num where sku_Id = '' and stock - num > 0;
    

    分析: 高并发场景下,假设库存只有 1件 ,两个请求同时进来,抢购该商品. 数据库层面会限制只有一个用户扣库存成功。在并发量不是很大的情况下可以这么做。但是如果是秒杀,抢购,瞬时流量很高的话,压力会都到数据库,可能拖垮数据库,这种方案就不太适用啦。

  • 方案2:利用Redis串行扣减库存
  • * 缺点并发不高 * 同时只能一个用户抢占操作 * @param orderCode 订单编号 public boolean reduceStock(String orderCode) { String lockKey = "product-stock-reduce" + orderCode; if(redis.get(lockKey)){ return false; try { lock.lock(lockKey, 1L, 10L); //业务处理逻辑 }catch (Exception e){ LogUtil.error("e=",e); }finally { lock.unLock(lockKey); return true;

    分析: 利用Redis 分布式锁,强制控制同一个商品库存操作处理请求串行化,缺点并发不高 ,处理比较慢,不适合抢购,高并发场景。用户体验差,但是减轻了数据库的压力。可以考虑 leapArray那种设计方式,下边会讲到

  • 方案3 :redis + mq + mysql 保证库存安全,满足高并发处理,实现起来很复杂。
  • * 扣库存操作,秒杀的处理方案 * @param orderCode * @param skuCode * @param num * @return public boolean reduceStock(String orderCode,String skuId, Integer num) { String key = "product-stock" + skuId; Object value = redis.get(key); if (value == null) { //前提 提前将商品库存放入缓存 ,如果缓存不存在,视为没有该商品 return false; //先检查 库存是否充足 Integer stock = (Integer) value; if (stock < num) { log.info("库存不足"); return false; //不可在这里直接操作数据库减库存,否则导致数据不安全 //因为此时可能有其他线程已经将redis的key修改了 //redis 减少库存,然后才能操作数据库 Long newStock = redis.increment(key, -num.longValue()); //库存充足 if (newStock >= 0) { log.info("库存扣减成功"); //TODO 真正扣库存操作 可用MQ 异步进行 redis 和 mysql 的数据同步,减少响应时间,保证最终一致性 } else { //库存不足,需要增加刚刚减去的库存 redis.increment(key, num.longValue()); log.info("并发操作库存不足"); return false; return true;

    分析: 利用Redis increment 的原子操作,保证库存安全,利用MQ保证高并发响应时间。但是需要把库存的信息保存到Redis,并保证Redis 和 Mysql 数据同步。缺点是redis宕机后不能下单。这时候就要有相应mq消息可靠性保证,redis 可用性保证。 increment 是个原子操作

    1.5综合分析

    方案三满足秒杀、高并发抢购等热点商品的处理,真正减扣库存和下单可以异步执行。在并发情况不高,平常商品或者正常购买流程,可以采用方案一数据库乐观锁的处理,或者对方案三进行重新设计,设计成支持单订单多商品即可,但复杂性提高,同时redis和mysql数据一致性需要定期检查。

    订单时效问题 超过订单有效时间,订单取消,可利用MQ或其他方案回退库存。

    设置定时检查 Spring task (xxx-job等定时框架)的cron表达式定时任务 MQ消息延时队列

    订单与库存涉及的几个重要知识

  • TCC 模型:Try/Confirm/Cancel:不使用强一致性的处理方案,最终一致性即可,下单减库存,成功后生成订单数据,如果此时由于超时导致库存扣成功但是返回失败,则通过定时任务检查进行数据恢复,如果本条数据执行次数超过某个限制,人工回滚。还库存也是这样。
  • 幂等性:分布式高并发系统如何保证对外接口的幂等性,记录库存流水是实现库存回滚,支持幂等性的一个解决方案,订单号+skuId为唯一主键(该表修改频次高,少建索引)
  • 乐观锁:where stock + num>0
  • 消息队列:实现分布式事务 和 异步处理(提升响应速度)
  • redis:限制请求频次,高并发解决方案,提升响应速度
  • 分布式锁:防止重复提交,防止高并发,强制串行化
  • 分布式事务:最终一致性,同步处理(Dubbo)/异步处理(MQ)修改 + 补偿机制
  • 二. 并发库存解决方案压力分摊

    2.1 这个思路就是对库存我们也可以拆多份

    分析 将10万库存,分成100段,每段1000个库存。对应的,就有100把锁去锁这100个库存段了,可以满足100个线程同时跑。

    2.2 利用分布式锁+分段缓存

    把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据

    假设场景:假如你现在商品有100个库存,在redis存放5个库存key,形如

    key1=goods-01,value=20;
    
    key2=goods-02,value=20;
    
    key3=goods-03,value=20
    
    key3=goods-04,value=20
    
    key3=goods-05,value=20
    

    用户下单时对用户id进行%5计算,看落在哪个redis的key上,就去取哪个,这样每次就能够处理5个进程请求

    这种方案可以解决同一个商品在多用户同时下单的情况,但有个坑需要解决:当某段锁的库存不足,一定要实现自动释放锁然后换下一个分段库存再次尝试加锁处理,此种方案复杂比较高。

    三 利用redis的incr、decr的原子性 + 异步队列

    3.1 实现思路

  • 1、在系统初始化时,将商品的库存数量加载到redis缓存中
  • 2、接收到秒杀请求时,在redis中进行预减库存(利用redis decr的原子性),当redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
  • 3、将请求放入异步队列中,返回正在排队中;
  • 4、服务端异步队列将请求出队(哪些请求可以出队,可以根据业务来判定,比如:判断对应用户是否已经秒杀过对应商品,防止重复秒杀),出队成功的请求可以生成秒杀订单,减少数据库库存(在扣减库存的sql如下
  • update goods set stock = stock - 1 where goods_id = #{id} and stock >0
    

    ),返回秒杀订单详情

  • 5、用户在客户端申请秒杀请求后,进行轮询,查看是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败
  • 这种方案的缺点:由于是通过异步队列写入数据库中,可能存在数据不一致,其次引用多个组件复杂度比较高。

  • 私信