基于Redis商品库存扣减方案

前言

电商业务场景下,对于库存的处理是比较重要的,表面上看只是对商品库存数做一个扣减操作,但是要做到不超卖、不少卖,同时还要保证高性能,却是一件非常困难的事。

传统解决方案

库存扣减的传统解决方案是完全基于关系型数据库来做的,以 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,就会导致库存扣减丢失出现超卖的情况,没办法百分百解决,只能尽可能的在业务低峰期修正缓存里的数据。

相关推荐
fat house cat_43 分钟前
【redis】线程IO模型
java·redis
敖云岚2 小时前
【Redis】分布式锁的介绍与演进之路
数据库·redis·分布式
让我上个超影吧4 小时前
黑马点评【基于redis实现共享session登录】
java·redis
懒羊羊大王呀7 小时前
Ubuntu20.04中 Redis 的安装和配置
linux·redis
John Song9 小时前
Redis 集群批量删除key报错 CROSSSLOT Keys in request don‘t hash to the same slot
数据库·redis·哈希算法
Zfox_18 小时前
Redis:Hash数据类型
服务器·数据库·redis·缓存·微服务·哈希算法
呼拉拉呼拉18 小时前
Redis内存淘汰策略
redis·缓存
咖啡啡不加糖1 天前
Redis大key产生、排查与优化实践
java·数据库·redis·后端·缓存
MickeyCV1 天前
使用Docker部署MySQL&Redis容器与常见命令
redis·mysql·docker·容器·wsl·镜像
肥仔哥哥19301 天前
springCloud2025+springBoot3.5.0+Nacos集成redis从nacos拉配置起服务
redis·缓存·最新boot3集成