秒杀库存设计

1,347 阅读10分钟

背景问题

在高并发秒杀的情景下,对于库存来说,主要存在三个问题,超卖重复扣减库存、高性能的问题

  • 超卖:原价999元商品的秒杀价为599,库存100件却卖出了10000件,那么我们就会面临严重的客诉或资损;
  • 重复扣减库存:10000件的库存仅有10人成单,库存明明还在却显示已经售罄;
  • 高性能:根据之前的缓存设计,我们要考虑设计如何提高库存获取的速度;

常见扣减库存的方案

基于数据库的缓存扣减方案

利用数据库的特性在数据库层面完成库存扣减。这种方式实现起来比较简单,对于并发量低或库存低的场景,推荐使用这种方案,但在高并发的场景下,这种方式是不适合的。

基于缓存的缓存扣减方案

先把数据库中的库存存储入Redis,在Redis层面进行库存的扣减。这样大大提高了处理的速度,适合于高并发的秒杀场景。但是要保证缓存不丢失的稳定,提高了实现的复杂度。

这里可以实现 缓存 + 数据库 的综合实现方案,先把数据库的库存读取到缓存中,当有扣减请求过来时,先去缓存中扣减库存,若成功,则再在数据库层面完成最终的库存扣减;若失败,则直接返回即可。这样不仅兼顾了处理请求的快速,而且还保证最终库存的一致性。

  1. 对于库存合法的订单,才对数据库继续更进一步的修改,减少数据库压力;
  2. 在Redis层面进行库存判断,可以防止超卖问题;

库存扣减时机

  1. 下单扣减库存实现简单,但存在刷单行为,可能被恶意下单清空库存;
  2. 支付扣减库存:可以控制恶意下单,最终得到的都是有效订单,但在支付时要重新查询库存的数量
  3. 下单预扣减库存超时取消:这是前两种方式的折中方案,对于用户来说抢单即是得到,对于恶意下单的,占据的库存会超时释放;

对于秒杀场景来说,可以使用下单扣减库存的方式,这种方式实现方式快且简单。而扣减库存的方案采取数据库 + 缓存 的实现方式。

订单生成流程

流程图

image.png

  1. 在请求进入后,整个流程要进行加锁处理,防止多次请求进入造成重复下单的后果(防抖);
  2. 库存扣减和下单操作用进行异常处理,发生异常后要恢复预减库存/库存
  3. 库存扣减和下单操作要进行事务处理,保证数据库库存的回滚

合法校验

  1. 基础校验
  • 账号校验:当前账号是否合法;
  • 风控检验:当前用户是否是机器,黄牛,是否在黑名单中等;
  1. 秒杀活动,秒杀商品的校验(是否存在,是否开始或结束,商品是否在活动中等)
// 校验活动
Long activityId = seckillPlaceOrderRequest.getActivityId();
BaseResponse<SeckillActivitiesResponse> seckillActivitiesResponse = seckillActivityClient.getSeckillActivity(userId, activityId);
if (!isSeckillActivityValid(seckillActivitiesResponse, activityId)) {
    logger.info("活动校验失败|{}, {}", userId, activityId);
    return ResultUtils.error(PLACE_ORDER_FAILED.getCode(), PLACE_ORDER_FAILED.getMessage());
}

// 校验商品
Long itemId = seckillPlaceOrderRequest.getItemId();
BaseResponse<SeckillGoodResponse> seckillGoodResponse = seckillGoodClient.getSeckillGood(userId, activityId, itemId);
if (!isSeckillGoodValid(seckillGoodResponse, activityId, itemId)) {
    logger.info("活动校验失败|{}, {}", userId, activityId);
    return ResultUtils.error(PLACE_ORDER_FAILED.getCode(), PLACE_ORDER_FAILED.getMessage());
}

库存预扣减

基本逻辑

首先需要明确的是,这里的库存扣减是预扣库存,而不是最终的库存扣减。如果缓存中的库存扣减成功,则表示数据库中有库存,此时放行到数据库中进行新一轮的竞争。但是,如果缓存中的库存扣减失败,则表示库存已经不存在,此时不必再进入数据库竞争,可以直接拒绝本次请求

 // 预减库存
isPreDecreaseStock = goodStockCacheService.decreaseGoodStock(stockDeduction);
if (!isPreDecreaseStock) {
    logger.info("预减库存失败|{}, {}, {}", activityId, itemId, JSON.toJSONString(seckillPlaceOrderRequest));
    return ResultUtils.error(PLACE_ORDER_FAILED.getCode(), PLACE_ORDER_FAILED.getMessage());
}

防止超卖产生

通过保证Redis命令的原子性,可以保证超卖的产生,只要每次判断当前预库存是否小于购买的数量,若是则直接返回,若不是则扣减即可;

  1. 通过缓存判断,请求处理的时间缩减,避免了对数据库的访问;
  2. 继续优化的方法:可以使用hashMap在内存中作为库存的缓存;

Lua脚本保证原子性

在Redis层面进行库存的扣减,考虑到扣减过程中要对库存进行数量的判断判断是否处于缓存预热的状态,判断key是否存在,扣减库存等操作,要把以上操作当成一条原子命令执行,避免并发问题,所以要使用lua脚本;

  1. 当处于预热状态,这时请求重试
  2. 当商品未预热,直接返回;
  3. 若库存不够,直接返回;
  4. 若库存充足,则扣减;

Lua脚本代码:

if (redis.call('exists', KEYS[2]) == 1) then
    return -9;
end
if (redis.call('exists', KEYS[1]) == 1) then
    local stock = tonumber(redis.call('get', KEYS[1]));
    local num = tonumber(ARGV[1]);
    if (stock < num) then
        return -3;
    end
    if (stock >= num) then
        redis.call('incrby', KEYS[1], 0 - num);
        return 1;
    end
    return -2
end
return -1

Java实现代码:

@Override
    public boolean decreaseGoodStock(StockDeduction stockDeduction) {
        try {
            String goodsStockCacheKey = getGoodsStockCacheKey(stockDeduction.getItemId());
            String goodsStockCacheAlignKey = getGoodsStockCacheAlignKey(stockDeduction.getItemId());
            ArrayList<String> keys = Lists.newArrayList(goodsStockCacheKey, goodsStockCacheAlignKey);

            Long result = null;
            long startTime = System.currentTimeMillis();
            while ((result == null || result == IN_STOCK_ALIGNING) && (System.currentTimeMillis() - startTime < 1500)) {
                result = redisCacheService.getRedisTemplate().execute(DECREASE_GOOD_STOCK_SCRIPT, keys, stockDeduction.getQuantity());
                if (result == null) {
                    logger.info("库存扣减失败|{}", goodsStockCacheKey);
                    return false;
                }

                if (result == IN_STOCK_ALIGNING) {
                    logger.info("库存校准中|{}", goodsStockCacheKey);
                    Thread.sleep(20);
                }

                if (result == -3) {
                    logger.info("库存小于当前秒杀数量|{}", goodsStockCacheAlignKey);
                    return false;
                }

                if (result == -1 || result == -2) {
                    logger.info("库存扣减失败|{}", goodsStockCacheKey);
                    return false;
                }

                if (result == 1) {
                    logger.info("库存扣减成功|{}", goodsStockCacheAlignKey);
                    return true;
                }

            }
        } catch (Exception e) {
            logger.info("库存扣减失败", e);
            return false;
        }
        return false;
    }

库存扣减

在缓存层面完成扣减库存后,可以在数据库进一步完成库存的扣减

避免重复扣减

这种极端情况会在某一条请求 会在不严谨的重试情况下发生

  1. 通过CAS防止重复扣减

Java代码

/**
     * 更新库存
     *
     * @param isDecrease
     * @param itemId
     * @param quantity
     * @return
     */
    @Override
    public BaseResponse<SuccessCode> updateSeckillGoodStock(boolean isDecrease, Long itemId, Integer quantity) {

        int oldAvailableStock = seckillGoodMapper.selectAvailableStockById(itemId);
        if (isDecrease) {
            int update = seckillGoodMapper.decreaseAvailableStockById(itemId, quantity, oldAvailableStock);
            if (update > 0) {
                return ResultUtils.success(SuccessCode.GOOD_STOCK_DECREASE_SUCCESS);
            }
        } else {
            int update = seckillGoodMapper.increaseAvailableStockById(itemId, quantity, oldAvailableStock);

            if (update > 0) {
                return ResultUtils.success(SuccessCode.GOOD_STOCK_INCREASE_SUCCESS);
            }
        }
        return ResultUtils.error(ErrorCode.FREQUENTLY_ERROR);
    }

获取旧库存跟数据库的库存进行比较即可,若一样进行扣减,不一样则返回

相应的SQL语句

<update id="decreaseAvailableStockById">
        update seckill_good
        set available_stock = available_stock - #{quantity,jdbcType=NUMERIC}
        where id = #{id,jdbcType=NUMERIC} and available_stock = #{oldAvailableStock,jdbcType=NUMERIC} and available_stock <![CDATA[ >= ]]> #{quantity,jdbcType=NUMERIC}
</update>
  1. 通过设置库存代替扣减库存

从数据库中获取库存,在代码中进行库存的操作,最后再设置到数据中,不过要求数据库中有个version的字段来判断是否是同一次操作;

<update id="decreaseItemStock">
    UPDATE flash_item
    SET modified_time = now(),
    available_stock = #{newAvailableStock}
    stock_version = #{newStockVersion}
    where id = #{itemId} and stock_version = #{oldStockVersion}
</update>

用户下单

这里没有进行幂等性处理,因为CAS失败后没有重试机制,而且成功的只有一个,不会有重复下单的情况,同时订单ID是Mybatis-Plus 通过雪花算法自动生成,保证唯一ID;

// 构造 实体类
SeckillGoodResponse seckillGoodResponseData = seckillGoodResponse.getData();
SeckillOrder seckillOrder = SeckillOrderBuilder.toDomain(seckillPlaceOrderRequest);
seckillOrder.setItemTitle(seckillGoodResponseData.getItemTitle());
seckillOrder.setFlashPrice(seckillGoodResponseData.getFlashPrice());
seckillOrder.setUserId(userId);
boolean isSuccessSave = save(seckillOrder);
if (!isSuccessSave) {
    logger.info("订单创建失败|{},{}", userId, JSON.toJSONString(seckillOrder));
    return ResultUtils.error(PLACE_ORDER_FAILED.getCode(), PLACE_ORDER_FAILED.getMessage());
}

库存恢复

当扣减库存的过程中,若出现异常,要对已经扣减的 预库存/库存 进行恢复(因为开启了Mysql事务,所以库存的恢复可以交给事务本身,这里不用我们进行严格地处理)

预库存恢复

因为也要判断是否处于预热状态key是否存在等,所以这里也要使用 Lua 脚本,保证Redis操作的原子性

Lua脚本

if (redis.call('exists', KEYS[2]) == 1) then
    return -9
end
if (redis.call('exists', KEYS[1]) == 1) then
    local stock = tonumber(redis.call('get', KEYS[1]));
    local num = tonumber(ARGV[1]);
    redis.call('incrby', KEYS[1] , num);
    return 1;
end

return -1

Java代码

@Override
    public boolean increaseGoodStock(StockDeduction stockDeduction) {
        try {
            String goodsStockCacheKey = getGoodsStockCacheKey(stockDeduction.getItemId());
            String goodsStockCacheAlignKey = getGoodsStockCacheAlignKey(stockDeduction.getItemId());

            ArrayList<String> keys = Lists.newArrayList(goodsStockCacheKey, goodsStockCacheAlignKey);
            Long result = null;
            long startTime = System.currentTimeMillis();

            while ((result == null || result == IN_STOCK_ALIGNING) && System.currentTimeMillis() - startTime < 1500) {
                result = redisCacheService.getRedisTemplate().execute(INCREASE_GOOD_STOCK_SCRIPT, keys, stockDeduction.getQuantity());

                if (result == null) {
                    logger.info("恢复库存失败|{}", goodsStockCacheKey);
                    return false;
                }

                if (result == -1) {
                    logger.info("恢复库存失败|{}", goodsStockCacheKey);
                    return false;
                }

                if (result == IN_STOCK_ALIGNING) {
                    logger.info("校准库存中|{}", goodsStockCacheKey);
                    Thread.sleep(20);
                }

                if (result == 1) {
                    logger.info("increaseItemStock|库存增加成功|{}", goodsStockCacheKey);
                    return true;
                }
            }
        } catch (Exception e) {
            logger.info("恢复库存失败", e);
            return false;
        }
        return false;
    }

库存恢复

这个较为简单,使用CAS恢复原来的库存即可;

<update id="increaseAvailableStockById">
        update seckill_good
        set available_stock = available_stock + #{quantity,jdbcType=NUMERIC}
        where id = #{id,jdbcType=NUMERIC} and available_stock = #{oldAvailableStock,jdbcType=NUMERIC}
</update>

取消订单

当有取消订单操作时,要考虑将预库存和库存同时恢复,即同时操作 MySQL和 Redis,这时就要保证两者能够同时恢复,考虑到安全性,我们要 先恢复 MySQL,再恢复 Redis; 因为恢复MySQL的时间较长,Redis的时间较短,不容易产生并发问题;

其实在秒杀过程中,在时效性很强的时效性,Redis中的预库存可以不用恢复,因为用户取消订单的时候,秒杀活动已经结束了;

若不考虑Redis中库存的恢复,可以不用取消订单中进行异常处理(其实本来就不用)

@Override
@Transactional
public BaseResponse<SuccessCode> cancelOrder(Long userId, Long orderId) {
    SeckillOrder seckillOrder = seckillOrderMapper.selectById(orderId);
    
    seckillOrder.setStatus(SeckillOrderStatus.CANCELED.getCode());
    boolean isUpdateSuccess = updateById(seckillOrder);
    StockDeduction stockDeduction = new StockDeduction()
            .setItemId(seckillOrder.getItemId())
            .setUserId(userId)
            .setQuantity(seckillOrder.getQuantity());

    // 先恢复数据库,再恢复缓存
    boolean isIncreaseSuccess = goodStockDeductionService.increaseItemStock(stockDeduction);
    if (!isIncreaseSuccess) {
        logger.info("预库存恢复失败|{}, {}", userId, orderId);
        throw new BusinessException(ErrorCode.ORDER_CANCEL_FAILED);
    }

    isIncreaseSuccess = goodStockCacheService.increaseGoodStock(stockDeduction);
    if (!isIncreaseSuccess) {
        logger.info("预库存恢复失败|{}, {}", userId, orderId);
        throw new BusinessException(ErrorCode.ORDER_CANCEL_FAILED);
    }

    logger.info("cancelOrder|订单取消成功|{}", orderId);
    return ResultUtils.success(SuccessCode.ORDER_CANCEL_SUCCESS);
}

库存扣减中的缓存处理

缓存调度与预热

从数据库中获取未预热的商品,修改其状态改为已经预热,同时将该商品的秒杀库存的值存入Redis中,避免第一次请求的时候缓存中没有值,大量请求闯入数据库;

注意事项:

  1. 预热前必要的数据校验,如秒杀品是否存在等;
  2. 使用spring的定时器,在秒杀过程中要定时更新Redis中的库存与数据库中的库存;
  3. 使用Redisson的分布式锁保证多台机器只能有一个执行商品的预热,保证预热过程的完成;
  4. 获取库存初始化的锁,再次防止并发更新(可能存在其他线程在减少(下单)/增加(恢复)库存);
  5. 修改Redis的操作要封装成一个Lua脚本保证命令执行的原子性,,根据Lua脚本的返回值确认有没有预热成功,若没有成功则等待下次执行
  6. 库存缓存的有效时间要设置足够长

调度代码

@Scheduled(cron = "0 0 0 * * ?")
    public void warmUpFlashItemTask() {
        DistributedLock distributedLock = distributedLockFactoryService.getDistributedLock(SCHEDULED_WARM_UP_LOCK.getKeyName());
        try {
            // 获取分布式锁
            boolean tryLock = distributedLock.tryLock(0, -1, TimeUnit.SECONDS);
            if (tryLock) {
                logger.info("warmUpFlashItemTask|开启定时任务");
                // 获取所有未预热的商品列表
                SeckillGoodQuery seckillGoodQuery = new SeckillGoodQuery()
                        .setStockWarmUp(0);
                BaseResponse<MultiSeckillGoodsResponse> allSeckillGoods = seckillGoodClient.getAllSeckillGoods(-1L, seckillGoodQuery);
                if (allSeckillGoods == null) {
                    logger.info("获取商品列表返回值失败");
                    return;
                }

                if (allSeckillGoods.getData() == null || allSeckillGoods.getData().getTotal() == null || allSeckillGoods.getData().getTotal() == 0) {
                    logger.info("当前预热商品为空");
                    return;
                }
                // 将未预热商品的库存存入redis,并修改预热状态
                List<SeckillGoodResponse> seckillGoodResponses = allSeckillGoods.getData().getSeckillGoodResponses();
                seckillGoodResponses.forEach(seckillGoodResponse -> {
                    boolean isSuccess = goodStockCacheService.alignItemStocks(seckillGoodResponse.getActivityId(), seckillGoodResponse.getId());

                    if (!isSuccess) {
                        logger.info("秒杀品库存校准失败|{}, {}", seckillGoodResponse.getActivityId(), seckillGoodResponse.getId());
                        return;
                    }
                    UpdateSeckillGoodRequest updateSeckillGoodRequest = new UpdateSeckillGoodRequest()
                            .setStockWarmUp(1);
                    seckillGoodClient.updateSeckillGood(-1L, seckillGoodResponse.getId(), updateSeckillGoodRequest);
                    logger.info("秒杀品库存校准成功|{}, {}", seckillGoodResponse.getActivityId(), seckillGoodResponse.getId());
                });
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

预热代码

@Override
    public boolean alignItemStocks(Long activity, Long itemId) {
        if (itemId == null) {
            logger.info("秒杀品不存在|{}", itemId);
            return false;
        }
        try {
            SeckillGoodResponse seckillGoodResponse = seckillGoodClient.getSeckillGood(-1L, activity, itemId).getData();
            if (seckillGoodResponse == null) {
                logger.info("秒杀品不存在|{}", itemId);
                return false;
            }

            Integer initialStock = seckillGoodResponse.getInitialStock();
            if (initialStock == null) {
                logger.info("秒杀品未设置库存|{}", itemId);
                return false;
            }
            
            // 获取锁
            String goodsStockCacheKey = getGoodsStockCacheKey(itemId);
            String goodsStockCacheAlignKey = getGoodsStockCacheAlignKey(itemId);
            
            ArrayList<String> keys = Lists.newArrayList(goodsStockCacheKey, goodsStockCacheAlignKey);
            Long result = redisCacheService.getRedisTemplate().execute(INIT_OR_ALIGN_ITEM_STOCK_SCRIPT, keys, initialStock);
            if (result == null) {
                logger.info("alignItemStocks|秒杀品库存校准失败|{},{},{}", itemId, goodsStockCacheKey, initialStock);
                return false;
            }
            if (result == -997) {
                logger.info("alignItemStocks|已在校准中,本次校准取消|{},{},{},{}", result, itemId, goodsStockCacheKey, initialStock);
                return true;
            }
            if (result == 1) {
                logger.info("alignItemStocks|秒杀品库存校准完成|{},{},{},{}", result, itemId, goodsStockCacheKey, initialStock);
                return true;
            }
        } catch (Exception e) {
            logger.error("alignItemStocks|秒杀品库存校准错误|{}", itemId, e);
            return false;
        }
        return false;
    }

Lua脚本

--- 锁已存在
if (redis.call('exists', KEYS[2]) == 1) then
  return -997;
end
--- 设置锁
redis.call('set', KEYS[2], 1);
local stockNumber = tonumber(ARGV[1]);
redis.call('set', KEYS[1], stockNumber);

--- 释放锁
redis.call('del', KEYS[2]);
return 1

库存获取

即通过 本地缓存 + 分布式缓存 的方式 来获取可用库存

  1. 先从本地缓存中读取库存数据;
  2. 本地缓存不在时,再从分布式缓存中读取数据;
  3. 分布式缓存获取时先获取对应的锁,否则返回稍后重试;

实现代码

@Override
public GoodStockCache getAvailableItemStock(Long userId, Long itemId) {
    // 从本地缓存获取
    GoodStockCache goodStockCache = goodStockLocalCache.getIfPresent(itemId);
    if (goodStockCache != null) {
        return goodStockCache;
    }
    
    // 从远程缓存获取
    Integer availableStock = redisCacheService.getObject(getGoodsStockCacheKey(itemId), Integer.class);
    if (availableStock == null) {
        return null;
    }
    
    // 返回对象
    goodStockCache = new GoodStockCache().with(availableStock);
    // 设置到本地缓存中
    goodStockLocalCache.put(itemId, goodStockCache);
    return goodStockCache;
}