当前位置: 首页 > news >正文

基于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表';

用户下单时,先执行如下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
@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。

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 命令,否则会导致库存数不一致。

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

相关文章:

  • 第一个 Angular 项目 - 动态页面
  • Elastic Search:构建语义搜索体验
  • 简单几步通过DD工具把云服务器系统Linux改为windows
  • Linux编译器---gcc/g++使用详解
  • ChatGPT在数据处理中的应用
  • C++从入门到精通 第五章(指针与引用)
  • ai图片放大老照片ai处理ps学习
  • 回调函数(Language C)
  • JavaSec 之 SQL 注入简单了解
  • 利用PaddleNLP进行文本数据脱敏
  • 前后端延迟怎么解决
  • 汽车信息安全--S32K3的HSE如何与App Core通信(1)?
  • 稀疏计算、彩票假说、MoE、SparseGPT
  • 第2.1章 StarRocks表设计——概述
  • SOCKS5、代理 IP、HTTP 在软件工程中的应用
  • JS中 map, filter, some, every, forEach, for in, for of 用法总结
  • 《用数据讲故事》作者Cole N. Knaflic:消除一切无效的图表
  • 03Go 类型总结
  • CSS实用技巧
  • django开发-定时任务的使用
  • ECMAScript入门(七)--Module语法
  • hadoop入门学习教程--DKHadoop完整安装步骤
  • iOS 系统授权开发
  • MaxCompute访问TableStore(OTS) 数据
  • springMvc学习笔记(2)
  • 初识 webpack
  • 技术攻略】php设计模式(一):简介及创建型模式
  • 聊聊flink的BlobWriter
  • 写给高年级小学生看的《Bash 指南》
  • 一起来学SpringBoot | 第十篇:使用Spring Cache集成Redis
  • 栈实现走出迷宫(C++)
  • Java数据解析之JSON
  • LevelDB 入门 —— 全面了解 LevelDB 的功能特性
  • MPAndroidChart 教程:Y轴 YAxis
  • ​软考-高级-系统架构设计师教程(清华第2版)【第1章-绪论-思维导图】​
  • # Java NIO(一)FileChannel
  • #define,static,const,三种常量的区别
  • $.ajax中的eval及dataType
  • (js)循环条件满足时终止循环
  • (SpringBoot)第二章:Spring创建和使用
  • (非本人原创)史记·柴静列传(r4笔记第65天)
  • (企业 / 公司项目)前端使用pingyin-pro将汉字转成拼音
  • (正则)提取页面里的img标签
  • (转)3D模板阴影原理
  • (转)linux自定义开机启动服务和chkconfig使用方法
  • .apk 成为历史!
  • .net core IResultFilter 的 OnResultExecuted和OnResultExecuting的区别
  • .NET DevOps 接入指南 | 1. GitLab 安装
  • .Net转前端开发-启航篇,如何定制博客园主题
  • .pub是什么文件_Rust 模块和文件 - 「译」
  • @Transactional类内部访问失效原因详解
  • [ 英语 ] 马斯克抱水槽“入主”推特总部中那句 Let that sink in 到底是什么梗?
  • [04] Android逐帧动画(一)
  • [Android] 修改设备访问权限
  • [C#基础知识]专题十三:全面解析对象集合初始化器、匿名类型和隐式类型