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

相关推荐
04Koi.3 小时前
Redis--常用数据结构和编码方式
数据库·redis·缓存
Ven%6 小时前
如何修改pip全局缓存位置和全局安装包存放路径
人工智能·python·深度学习·缓存·自然语言处理·pip
weisian1516 小时前
Redis篇--常见问题篇8--缓存一致性3(注解式缓存Spring Cache)
redis·spring·缓存
向阳12186 小时前
mybatis 缓存
java·缓存·mybatis
HEU_firejef6 小时前
Redis——缓存预热+缓存雪崩+缓存击穿+缓存穿透
数据库·redis·缓存
weisian1517 小时前
Redis篇--常见问题篇7--缓存一致性2(分布式事务框架Seata)
redis·分布式·缓存
白云coy7 小时前
Redis 安装部署[主从、哨兵、集群](linux版)
linux·redis
Logintern097 小时前
Linux如何设置redis可以外网访问—执行使用指定配置文件启动redis
linux·运维·redis
凡人的AI工具箱8 小时前
每天40分玩转Django:Django表单集
开发语言·数据库·后端·python·缓存·django
快乐非自愿8 小时前
.NET 9 中的 多级缓存 HybridCache
缓存·.net