背景问题
在高并发秒杀的情景下,对于库存来说,主要存在三个问题,超卖、重复扣减库存、高性能的问题
- 超卖:原价999元商品的秒杀价为599,库存100件却卖出了10000件,那么我们就会面临严重的客诉或资损;
- 重复扣减库存:10000件的库存仅有10人成单,库存明明还在却显示已经售罄;
- 高性能:根据之前的缓存设计,我们要考虑设计如何提高库存获取的速度;
常见扣减库存的方案
基于数据库的缓存扣减方案
利用数据库的特性在数据库层面完成库存扣减。这种方式实现起来比较简单,对于并发量低或库存低的场景,推荐使用这种方案,但在高并发的场景下,这种方式是不适合的。
基于缓存的缓存扣减方案
先把数据库中的库存存储入Redis,在Redis层面进行库存的扣减。这样大大提高了处理的速度,适合于高并发的秒杀场景。但是要保证缓存不丢失的稳定,提高了实现的复杂度。
这里可以实现 缓存 + 数据库 的综合实现方案,先把数据库的库存读取到缓存中,当有扣减请求过来时,先去缓存中扣减库存,若成功,则再在数据库层面完成最终的库存扣减;若失败,则直接返回即可。这样不仅兼顾了处理请求的快速,而且还保证最终库存的一致性。
- 对于库存合法的订单,才对数据库继续更进一步的修改,减少数据库压力;
- 在Redis层面进行库存判断,可以防止超卖问题;
库存扣减时机
- 下单扣减库存:实现简单,但存在刷单行为,可能被恶意下单清空库存;
- 支付扣减库存:可以控制恶意下单,最终得到的都是有效订单,但在支付时要重新查询库存的数量;
- 下单预扣减库存,超时取消:这是前两种方式的折中方案,对于用户来说抢单即是得到,对于恶意下单的,占据的库存会超时释放;
对于秒杀场景来说,可以使用下单扣减库存的方式,这种方式实现方式快且简单。而扣减库存的方案采取数据库 + 缓存 的实现方式。
订单生成流程
流程图
- 在请求进入后,整个流程要进行加锁处理,防止多次请求进入造成重复下单的后果(防抖);
- 库存扣减和下单操作用进行异常处理,发生异常后要恢复预减库存/库存;
- 库存扣减和下单操作要进行事务处理,保证数据库库存的回滚;
合法校验
- 基础校验
- 账号校验:当前账号是否合法;
- 风控检验:当前用户是否是机器,黄牛,是否在黑名单中等;
- 秒杀活动,秒杀商品的校验(是否存在,是否开始或结束,商品是否在活动中等)
// 校验活动
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命令的原子性,可以保证超卖的产生,只要每次判断当前预库存是否小于购买的数量,若是则直接返回,若不是则扣减即可;
- 通过缓存判断,请求处理的时间缩减,避免了对数据库的访问;
- 继续优化的方法:可以使用hashMap在内存中作为库存的缓存;
Lua脚本保证原子性
在Redis层面进行库存的扣减,考虑到扣减过程中要对库存进行数量的判断,判断是否处于缓存预热的状态,判断key是否存在,扣减库存等操作,要把以上操作当成一条原子命令执行,避免并发问题,所以要使用lua脚本;
- 当处于预热状态,这时请求重试;
- 当商品未预热,直接返回;
- 若库存不够,直接返回;
- 若库存充足,则扣减;
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;
}
库存扣减
在缓存层面完成扣减库存后,可以在数据库进一步完成库存的扣减
避免重复扣减
这种极端情况会在某一条请求 会在不严谨的重试情况下发生
- 通过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>
- 通过设置库存代替扣减库存
从数据库中获取库存,在代码中进行库存的操作,最后再设置到数据中,不过要求数据库中有个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中,避免第一次请求的时候缓存中没有值,大量请求闯入数据库;
注意事项:
- 预热前必要的数据校验,如秒杀品是否存在等;
- 使用spring的定时器,在秒杀过程中要定时更新Redis中的库存与数据库中的库存;
- 使用Redisson的分布式锁,保证多台机器只能有一个执行商品的预热,保证预热过程的完成;
- 获取库存初始化的锁,再次防止并发更新(可能存在其他线程在减少(下单)/增加(恢复)库存);
- 修改Redis的操作要封装成一个Lua脚本保证命令执行的原子性,,根据Lua脚本的返回值确认有没有预热成功,若没有成功则等待下次执行
- 库存缓存的有效时间要设置足够长
调度代码
@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
库存获取
即通过 本地缓存 + 分布式缓存 的方式 来获取可用库存
- 先从本地缓存中读取库存数据;
- 本地缓存不在时,再从分布式缓存中读取数据;
- 分布式缓存获取时先获取对应的锁,否则返回稍后重试;
实现代码
@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;
}