• 基于Redis商品库存扣减方案


    前言

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

    传统解决方案

    库存扣减的传统解决方案是完全基于关系型数据库来做的,以 MySQL 为例,假设有如下sku表:

    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表';
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    用户下单时,先执行如下SQL扣减库存,库存扣减成功才创建订单。任一商品库存不足时,扣减就会失败,此时可以回滚事务,并给用户一个友好的提示。

    UPDATE sku SET stock=stock-#{num} WHERE id=#{id} AND stock>=#{num}
    
    • 1

    这种方案可以保证不超卖,它依赖的是MySQL事务一致性和行锁,上一个请求扣减库存会持有对应sku的行锁直到事务提交,后续请求抢锁失败会阻塞,相当于库存扣减在MySQL层面被串行化了。缺点也很明显,如果系统并发较高,或者遇到大促就会存在热点问题,大量用户购买同一商品,就会导致大量线程都在竞争锁,进而导致MySQL TPS降低,RT线性上升,最终甚至引发系统雪崩。MySQL针对单行update的tps大概也就在500左右,为了避免MySQL成为瓶颈,建议把库存扣减操作转移到上层执行。

    基于Redis扣减库存

    Redis 高效的读写性能,是所有关系型数据库望尘莫及的,单台实例轻轻松松就能达到10W tps,高出MySQL几个数量级,基于Redis的库存扣减方案可以满足绝大多数企业。

    在主流程上,用户可能一次下单多个商品,我们可以通过执行lua脚本的方式来扣减库存,并对脚本执行结果做处理。库存扣减可能有三种结果:

    • 1:库存扣减成功
    • 0:库存不足,扣减失败
    • -1:库存不存在,还未load到Redis
    @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("处理失败,请重试");
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    库存扣减的脚本如下,KEYS是要扣减的sku对应的库存key,ARGV是要扣减的库存数,均是数组。
    脚本会先校验,确保库存key和扣减数长度一致。然后遍历KEYS,任一库存key不存在,都会直接返回-1,提醒客户端初始化库存。如果库存key存在就判断库存数是否充足,任一库存数不足都会直接返回0,提醒客户端库存扣减失败。当库存key全都存在,且库存数都足够时,进行KEYS第二次遍历,依次扣减库存并最终返回1。

    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)) +
                " 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());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    这里解释下为什么要遍历两次,第一次遍历是为了确保所有sku库存充足,第二次遍历是为了扣减库存。如果在第一次遍历时就扣减库存,后面遇到库存不足的sku扣减失败,Redis是不支持回滚操作的,在业务上去回滚就变得非常复杂了。

    如果库存key不存在,则要先把库存数从MySQL load 到Redis。首先通过lua脚本判断哪些库存key不存在,然后查询数据库库存并写入到Redis。

    注意:可能有多个线程发现缓存key不存在,写缓存必须用setnx 命令,否则会导致库存数不一致。

    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));
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    至此,基于Redis扣减库存的流程就结束了。在整个流程中,初始化库存开销是比较大的,因为要查询数据库。所以系统上可以再优化一下,针对秒杀商品或者运营可预见的热点商品,可以在上架时就提前写入Redis,以降低用户下单的延时。
    最后就是Redis库存数同步到MySQL了,Redis层只负责库存数扣减拦截,实际的存储还得靠关系型数据库。实现上,可以在下单事务提交后,发送一个MQ消息,利用消息队列来削峰,确保写入数据库的流量是可控的。

    尾巴

    商品库存扣减的目标是:不超卖、不少卖和高性能。传统基于关系型数据库事务的解决方案实现简单,但是存在热点写问题,数据库沦为性能瓶颈。在高并发场景下更推荐用Redis来做库存扣减,核心是先把库存数从数据库load到Redis,再通过lua脚本来批量扣减库存,细节上要注意先确保所有商品的库存数都充足再统一扣减,否则回滚会非常麻烦。对于可预见的热点商品,可以提前预热,避免用户下单时再初始化缓存,增加下单延时。最后是Redis数据同步到数据库,可以通过消息队列来削峰,确保流量的可控。
    用上Redis并不意味着就高枕无忧了,极端情况下仍然会出现数据不一致的情况。因为Redis主从集群复制是异步且有延迟的,如果Master扣减库存后还没同步到Slave就宕机了,此时Slave升级为Master,就会导致库存扣减丢失出现超卖的情况,没办法百分百解决,只能尽可能的在业务低峰期修正缓存里的数据。

  • 相关阅读:
    微服务实战微服务网关Gateway入门与实战
    成功解决ImportError: cannot import name ‘PILLOW_VERSION‘
    根据当前日期获取前一天日期-小工具
    Naive 组件库 动态渲染icon图标
    PyQt5学习系列之新项目创建并使用widget
    slam学习笔记
    探索艺术新边界:Stable Diffusion 在艺术领域的创新应用
    竞赛 深度学习+opencv+python实现昆虫识别 -图像识别 昆虫识别
    【数据结构--排序】堆排序
    安卓和ios设置自己的短链
  • 原文地址:https://blog.csdn.net/qq_32099833/article/details/136238930