前言
电商业务场景下,对于库存的处理是比较重要的,表面上看只是对商品库存数做一个扣减操作,但是要做到不超卖、不少卖,同时还要保证高性能,却是一件非常困难的事。
传统解决方案
库存扣减的传统解决方案是完全基于关系型数据库来做的,以 MySQL 为例,假设有如下sku表:
sql
CREATE TABLE `sku`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'skuID',
`product_id` BIGINT(20) NOT NULL COMMENT '商品ID',
`stock` INT(11) UNSIGNED DEFAULT '0' COMMENT '库存数',
PRIMARY KEY (`id`)
) ENGINE = InnoDB COMMENT ='商品sku表';
用户下单时,先执行如下SQL扣减库存,库存扣减成功才创建订单。任一商品库存不足时,扣减就会失败,此时可以回滚事务,并给用户一个友好的提示。
sql
UPDATE sku SET stock=stock-#{num} WHERE id=#{id} AND stock>=#{num}
这种方案可以保证不超卖,它依赖的是MySQL事务一致性和行锁,上一个请求扣减库存会持有对应sku的行锁直到事务提交,后续请求抢锁失败会阻塞,相当于库存扣减在MySQL层面被串行化了。缺点也很明显,如果系统并发较高,或者遇到大促就会存在热点问题,大量用户购买同一商品,就会导致大量线程都在竞争锁,进而导致MySQL TPS降低,RT线性上升,最终甚至引发系统雪崩。MySQL针对单行update的tps大概也就在500左右,为了避免MySQL成为瓶颈,建议把库存扣减操作转移到上层执行。
基于Redis扣减库存
Redis 高效的读写性能,是所有关系型数据库望尘莫及的,单台实例轻轻松松就能达到10W tps,高出MySQL几个数量级,基于Redis的库存扣减方案可以满足绝大多数企业。
在主流程上,用户可能一次下单多个商品,我们可以通过执行lua脚本的方式来扣减库存,并对脚本执行结果做处理。库存扣减可能有三种结果:
- 1:库存扣减成功
- 0:库存不足,扣减失败
- -1:库存不存在,还未load到Redis
java
@Slf4j
public class StockService {
private final RedisClient redisClient = RedisClient.getClient();
public void reduce(List<SkuDTO> skuDTOList) {
// lua脚本扣减库存
int result = doReduce(skuDTOList);
if (result == -1) {
// 初始化库存
initStock(skuDTOList);
result = doReduce(skuDTOList);
}
if (result == 0) {
throw new BizException("库存不足");
} else if (result == 1) {
log.info("库存扣减成功");
} else {
throw new BizException("处理失败,请重试");
}
}
}
库存扣减的脚本如下,KEYS是要扣减的sku对应的库存key,ARGV是要扣减的库存数,均是数组。
脚本会先校验,确保库存key和扣减数长度一致。然后遍历KEYS,任一库存key不存在,都会直接返回-1,提醒客户端初始化库存。如果库存key存在就判断库存数是否充足,任一库存数不足都会直接返回0,提醒客户端库存扣减失败。当库存key全都存在,且库存数都足够时,进行KEYS第二次遍历,依次扣减库存并最终返回1。
java
private int doReduce(List<SkuDTO> skuDTOList) {
List<String> keys = skuDTOList.stream().map(s -> String.format(CacheKey.STOCK_KEY, s.getSkuId())).collect(Collectors.toList());
List<String> args = skuDTOList.stream().map(SkuDTO::getNumber).map(String::valueOf).collect(Collectors.toList());
Object result = redisClient.eval("if(#KEYS~=#ARGV)\n" +
"then\n" +
" return nil\n" +
"end\n" +
"for i,key in ipairs(KEYS)\n" +
"do\n" +
" if(redis.call('EXISTS',key)==0)\n" +
" then\n" +
" return -1\n" +
" elseif(tonumber(redis.call('GET',key))<tonumber(ARGV[i]))\n" +
" then\n" +
" return 0\n" +
" end\n" +
"end\n" +
"for i,key in ipairs(KEYS)\n" +
"do\n" +
" redis.call('DECRBY',key,tonumber(ARGV[i]))\n" +
"end\n" +
"return 1", keys, args);
return Integer.valueOf(result.toString());
}
这里解释下为什么要遍历两次,第一次遍历是为了确保所有sku库存充足,第二次遍历是为了扣减库存。如果在第一次遍历时就扣减库存,后面遇到库存不足的sku扣减失败,Redis是不支持回滚操作的,在业务上去回滚就变得非常复杂了。
如果库存key不存在,则要先把库存数从MySQL load 到Redis。首先通过lua脚本判断哪些库存key不存在,然后查询数据库库存并写入到Redis。
注意:可能有多个线程发现缓存key不存在,写缓存必须用
setnx
命令,否则会导致库存数不一致。
java
private void initStock(List<SkuDTO> skuDTOList) {
List<String> keys = skuDTOList.stream().map(s -> String.format(CacheKey.STOCK_KEY, s.getSkuId())).collect(Collectors.toList());
Object result = redisClient.eval("local keys = {}\n" +
"for i,key in ipairs(KEYS)\n" +
"do\n" +
" if(redis.call('EXISTS',key)==0)\n" +
" then\n" +
" keys[#keys+1]=key\n" +
" end\n" +
"end\n" +
"return keys", keys, Collections.emptyList());
if (result != null && result instanceof Collection) {
for (Object key : ((Collection) result)) {
Integer skuId = Integer.valueOf(key.toString().split(":")[1]);
int stock = 0;// mock
redisClient.setnx(String.format(CacheKey.STOCK_KEY, skuId), String.valueOf(stock));
}
}
}
至此,基于Redis扣减库存的流程就结束了。在整个流程中,初始化库存开销是比较大的,因为要查询数据库。所以系统上可以再优化一下,针对秒杀商品或者运营可预见的热点商品,可以在上架时就提前写入Redis,以降低用户下单的延时。
最后就是Redis库存数同步到MySQL了,Redis层只负责库存数扣减拦截,实际的存储还得靠关系型数据库。实现上,可以在下单事务提交后,发送一个MQ消息,利用消息队列来削峰,确保写入数据库的流量是可控的。
尾巴
商品库存扣减的目标是:不超卖、不少卖和高性能。传统基于关系型数据库事务的解决方案实现简单,但是存在热点写问题,数据库沦为性能瓶颈。在高并发场景下更推荐用Redis来做库存扣减,核心是先把库存数从数据库load到Redis,再通过lua脚本来批量扣减库存,细节上要注意先确保所有商品的库存数都充足再统一扣减,否则回滚会非常麻烦。对于可预见的热点商品,可以提前预热,避免用户下单时再初始化缓存,增加下单延时。最后是Redis数据同步到数据库,可以通过消息队列来削峰,确保流量的可控。
用上Redis并不意味着就高枕无忧了,极端情况下仍然会出现数据不一致的情况。因为Redis主从集群复制是异步且有延迟的,如果Master扣减库存后还没同步到Slave就宕机了,此时Slave升级为Master,就会导致库存扣减丢失出现超卖的情况,没办法百分百解决,只能尽可能的在业务低峰期修正缓存里的数据。