高并发场景-订单库存防止超卖

背景

在电商系统中买商品过程,先加入购物车,然后选中商品,点击结算,即会进入待支付状态,后续支付。
过程需要检验库存是否足够,保证库存不被超卖。

场景一:买家需要购买数量可以多件
场景二:秒杀活动,到时间点只能购买一件

目的

  • 防止相同用户重复下单
  • 检查库存准确数量
  • 防止扣错库存数量
  • 扣库存时性能效率提升、不阻塞用户

主要解决手段

  • 利用redis的incr、decr的原子性做操作
  • redis的lpush、rpop的原子性做操作,但是这个只能一个一个的扣,但不能原子地同时扣多个
  • sql乐观锁

交互流程

商品扣库存.jpg

主要环节:购物车->结清->支付

本文讲述结清时,扣库存环节,分布式系统产生订单环节后续文章再详细分析。

备注:挺推荐使用https://www.processon.com/在线来做流程图的

一、防止重复

利用redis分布式锁

用分布式锁,是为了防刷、防止同一个用户同一秒里面把购物车里的商品进行多次结算,防止前端代码出问题触发两次。
利用Jedis客户端编写分布式锁

  String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

lockKey是redis的Key,为用户id+商品id+商品数量组成,这样同一秒中只能有一次处理逻辑。
requestId是redis的value,实际是当前线程id,表示有一条线程占用。

大家要注意这种分布式锁写法,是同时设定超时时间的。有些分布式锁的文章可能是比较旧版的redis不支持同时设置超时时间,他就一条语句先设置key value,另一条语句后设置超时时间。所以大家留意一下。

二、扣减库存

安全扣减库存方案有很多说法,列一下几个方案和我推荐的方案。

方案一:分布式锁

有的文章会用redis分布式锁来做保证扣库存数量准确的环节,让点击结算时,后端逻辑会查询库存和扣库存的update语句同时只有一条线程能够执行,以商品id为分布式锁的key,锁一个商品。但是这样,其他购买相同商品的用户将会进行等待。

  • 优点:这样做虽然安全
  • 缺点:但是失去的是性能问题。

方案二:分布式锁+分段缓存

也有文章会说借鉴ConcurrenthashMap,分段锁的机制,把100个商品,分在3个段上,key为分段名字,value为库存数量。用户下单时对用户id进行%3计算,看落在哪个redis的key上,就去取哪个。

如key1=product-01,value1=33;key2=product-02,value2=33;key3=product-03,value3=33;

其实会有几个问题:

  • 一个是用户想买34件的时候,要去两个片查
  • 一个片上卖完了为0,又要去另外一个片查
  • 取余方式计算每一片数量,除不尽时,让最后一片补,如100/3=33.33。

缺点:

  • 方案复杂
  • 有遗留问题

方案三: redis的lpush rpop

redis队列的lpush、rpop都是只能每次进出一个,对于购买多个数量的情况下不适用,只适用于秒杀情况购买一个的场景、或者抢红包的场景,所以觉得不是很通用。

备注:这个抢红包场景以后再分享。

方案四:推荐使用redis原子操作+sql乐观锁

利用Redis increment 的原子操作,保证库存数安全

  1. 先查询redis中是否有库存信息,如果没有就去数据库查,这样就可以减少访问数据库的次数。
    获取到后把数值填入redis,以商品id为key,数量为value。
    注意要设置序列化方式为StringRedisSerializer,不然不能把value做加减操作。
    还需要设置redis对应这个key的超时时间,以防所有商品库存数据都在redis中。

  2. 比较下单数量的大小,如果够就做后续逻辑。

  3. 执行redis客户端的increment,参数为负数,则做减法。因为redis是单线程处理,并且因为increment让key对应的value 减少后返回的是修改后的值
    有的人会不做第一步查询直接减,其实这样不太好,因为当库存为1时,很多做减3,或者减30情况,其实都是不够,这样就白减。

  4. 扣减数据库的库存,这个时候就不需要再select查询,直接乐观锁update,把库存字段值减1 。

  5. 做完扣库存就在订单系统做下单。

样例场景:

  1. 假设两个用户在第一步查询得到库存等于10,A用户走到第二步扣10件,同时一秒内B用户走到第二部扣3件。
  2. 因为redis单线程处理,若A用户线程先执行redis语句,那么现在库存等于0,B就只能失败,就不会出更新数据库了。
    public void order(OrderReq req) {
        String key = "product:" + req.getProductId();
        // 第一步:先检查 库存是否充足
        Integer num = (Integer) redisTemplate.get(key);
        if (num == null){
          // 去查数据库的数据
          // 并且把数据库的库存set进redis,注意使用NX参数表示只有当没有redis中没有这个key的时候才set库存数量到redis
          //注意要设置序列化方式为StringRedisSerializer,不然不能把value做加减操作
          // 同时设置超时时间,因为不能让redis存着所有商品的库存数,以免占用内存。
           if (count >=0) {
            //设置有效期十分钟
            redisTemplate.expire(key, 60*10+随机数防止雪崩, TimeUnit.SECONDS);
        }
          // 减少经常访问数据库,因为磁盘比内存访问速度要慢
        }
        if (num < req.getNum()) {
            logger.info("库存不足");
        }
        // 第二步:减少库存
        long value = redisTemplate.increment(key, -req.getNum().longValue());
        // 库存充足
        if (value >= 0) {
            logger.info("成功购买");
            // update 数据库中商品库存和订单系统下单,单的状态未待支付
            // 分开两个系统处理时,可以用LCN做分布式事务,但是也是有概率会订单系统的网络超时
            // 也可以使用最终一致性的方式,更新库存成功后,发送mq,等待订单创建生成回调。
            boolean res= updateProduct(req);
            if (res)
                createOrder(req);
        } else {
            // 减了后小小于0 ,如两个人同时买这个商品,导致A人第一步时看到还有10个库存,但是B人买9个先处理完逻辑,
            // 导致B人的线程10-9=1, A人的线程1-10=-9,则现在需要增加刚刚减去的库存,让别人可以买1个
            redisTemplate.increment(key, req.getNum().longValue());
            logger.info("恢复redis库存");
        }
    }

update使用乐观锁

updateProduct方法中执行的sql如下:

update Product set count = count - #{购买数量} where id = #{id} and count - #{购买数量} >= 0;

虽然redis已经防止了超卖,但是数据库层面,为了也要防止超卖,以防redis崩溃时无法使用或者不需要redis处理时,则用乐观锁,因为不一定全部商品都用redis。

利用sql每条单条语句都是有事务的,所以两条sql同时执行,也就只会有其中一条sql先执行成功,另外一条后执行,也如上文提及到的场景一样。

简单说一下分布式事务:

分开两个系统处理库存和订单时,这个时候可以用LCN框架做分布式事务,但是因为是http请求的,也是有概率会订单系统的网络超时,导致未返回结果。

其实也可以使用最终一致性的方式,数据表记录一条交互流水记录,更新库存成功后,更新这个交互流水记录的库存操作字段为已处理,订单处理字段为处理中,然后发送mq,等待订单创建生成回调。也要做定时任务做主动查询订单系统的结果,以防没有结果回来。

方案优势

  • 不需要频繁访问数据库商品库存还有多少
  • 不阻塞其他用户
  • 安全扣减库存量
  • 内存访问库存数量,减少数据库交互

高并发额外优化

  • 用户访问下单是,前端ui可以让用户触发结算后,把按钮置灰色,防止重复触发。
  • 可以按照库存数量来选定是否要用redis,因为如果库存数量少,或者说最近下单次数少的商品,就不用放redis,因为少人看和买的情况下,不必放redis导致占用内存。
  • 如果到时间点抢购时,可以使用mq队列形式,用户触发购买商品后,进入队列,让用户的页面一直在转圈圈,等轮到他买的时候再进入结算页面,结算页面的后续流程和本文一致。

欢迎关注

我的公众号 :地藏思维

掘金:地藏Kelvin

简书:地藏Kelvin

我的Gitee: 地藏Kelvin https://gitee.com/kelvin-cai

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容