电商技术 -- 库存设计指北

前言

最近在解决一套老电商系统的库存"超卖"问题。一直以为超卖问题,最难解决的是库存扣减,实则不然,我们的系统在解决了库存扣减问题之后,还会一直有“超卖”现象?这一切的背后到底是道德的沦丧,还是人性的扭曲,欢迎收看本期走近科学

本文带你解决以下电商场景问题

  1. 保证库存线程安全的扣减
  2. 防止库存的多次扣减、回滚
  3. 超时未支付被取消的订单(取消会回滚库存), 如果收到了支付回调怎么办

如何线程安全的扣减库存

先来说说库存扣减的问题,这是我们原来老系统的逻辑,注意!这里是错误的示例

// 以下是伪代码,错误的示例
// 查询出Goods对象
$goods = selectGoodsById($id);
if ($goods->num - $order_num > 0) {
    // 计算出扣减后的库存
    $goods->num = $goods->num - $order_num;
    // 保存
    save($goods);
}

上述代码犯了大忌,并发情况会导致多个线程读到相同的库存数,然后扣减,然后保存到DB,下面我们来说下正确的姿势

正确的做法

利用MySQL update 会持有当前记录锁的特点,保证线程安全的扣减

SQL 示例:

update kucun set num = num - ? where id = ? and num - ? >= 0

我们的这条记录根据主键更新,当事务A update 这条记录时,会持有当前记录的锁,当事务A未提交时,其他想要更新这条记录的事务只能等待锁释放

关于MySQL update 锁的细节,本文不讨论,可以参考MySQL文档

https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

虽然MySQL可以保证数据的准确性,但是大并发量场景下,大量的锁竞争,导致库存的扣减可能成为系统性能的瓶颈

使用 Redis 库存扣减

使用Redis的优势很多,单线程的文件事件处理器保证了并发下可以线程的安全扣减、回滚库存, 以及Redis高性能。

虽然Redis解决了线程安全和性能的问题,但是Redis并不能做到像MySQL那样一条SQL命令完成库存扣减,我们需要先读出已有库存,再和当前下单库存做一个判断是否可以库存扣减。所以最佳的实现方案是通过Redis 执行lua脚本,保证整个逻辑处理期间,不会有其他客户端插进来

    /**
     *
     * 扣减库存Lua脚本
     * 库存(stock)-1:表示不限库存
     * 库存(stock)0:表示没有库存
     * 库存(stock)大于0:表示剩余库存
     *
     * @params 库存key
     * @return
     *      -3:库存未初始化
     *      -2:库存不足
     *      -1:不限库存
     *      大于等于0:剩余库存(扣减之后剩余的库存)
     */
    const SUB_STOCK_LUA = "
        if (redis.call('exists', KEYS[1]) == 1) then
            local stock = tonumber(redis.call('get', KEYS[1]));
            local num = tonumber(ARGV[1]);   
            if (stock == -1) then
                return -1;
            end;
            
            if (stock >= num) then
                return redis.call('incrby', KEYS[1], 0 - num);
            end;
            
            return -2;
        end;
        
        return -3;
    ";

注意:
当对一个订单中的 good_list 扣减库存的时候,需要注意,当某一个商品库存扣减失败时,之前的扣减的商品库存需要回滚。这会涉及到对redis的多次操作,你可以把整体逻辑写到一个lua脚本中

使用Redis 做库存扣减会有一个问题(伪代码如下),Redis数据和MySQL数据并不能保证强一致性,因为Redis的数据相当于直接写进去了,如果在需要回滚的时候,Redis不可用了导致数据无法回滚,最终会造成MySQL没有写入订单数据,Redis却扣减了库存

try {
    $db->beginTransaction();
    
    $db->saveOrder();
    $redis->reduceStock();

    $db->commit();
        
} catch (Exception $e) {
    $db->rollback();
    $redis->rollbackStock();
}

这种情况并没有什么好的解决办法,这是一个几率非常小的故障,首先我们肯定要尽可能的保证Redis的高可用性,其次在发生这种情况后,我们要想办法恢复Redis中的数据,例如我们可以在整个逻辑之后,选择异步的方式(例如MQ)向MySQL中同步库存,当发生故障后,以MySQL数据为准恢复数据

所以Redis是一把双刃剑,提升性能的同时,也带来了问题

AliSQL

这是后来我在网上看到的方案,AliSQL 是阿里自研 MySQL 分支,AliSQL 针对并发修改同一记录的情况,使用数据库层面的缓冲队列,避免大量争锁的代价。感兴趣的同学可以试下(阿里云MySQL 8.0 集成了这一功能),如果AliSQL解决了性能问题的话,那么这个方案相比Redis要更好

关于库存多次扣减的问题

当订单的提交和库存的扣减同步进行的时候,不需要考虑这个问题。

举例:订单系统生成订单之后,通过MQ通知库存系统,库存系统异步扣减库存,这个时候库存系统可能会多次消费,这个时候就需要考虑这个问题了。

或者我们上面说的通过MQ同步MySQL库存也需要考虑可能发生多次扣减

解决方案如图,通过订单做为唯一索引保证流水记录的唯一性,从而保证只能有一次成功的扣减

image.png

库存回滚问题

多数博客对于超卖的讲解只在于库存的扣减,但是库存扣减安全了,真的就可以保证不超卖吗?我们的系统在解决了库存扣减问题后,还是出现成交订单 > 库存的问题,为此我也是绞尽脑汁,抓破了头

在对下单进行压力测试之后,我坚信下单不会出现超卖的问题,后来我怀疑问题出在了库存回滚,如果一个订单回滚了两次库存(取消超时未支付订单的线程和用户线程同时取消一个订单),同样也会出现超卖的现象。

解决方法:
和防止多次扣减一样,采用写入订单回滚流水的方式,个人认为这种方法比较加锁要好,数据有迹可循

超时未支付被取消的订单收到了支付回调

在解决了库存回滚问题之后,超卖问题还没有解决,最后通过日志定位到了这个问题。

问题描述:用户在系统即将自动取消订单的前一瞬间完成了支付,系统取消了该订单并回滚了库存,同时系统收到了该订单的支付回调,该订单的状态更改为已支付,因为不该出现的库存回滚导致了“超卖”

下面说下我们的解决方案,以微信支付为例

我们的系统在提交订单之后,会调用微信的统一下单接口,这时候微信收到了我们的商户订单号(微信已经生成订单),用户选择不支付。超时自动取消逻辑处理之前,先调用微信的关闭订单接口,如果关闭成功,则这个时候用户后续无法对该订单发起支付。如果返回订单已支付,则无需处理该订单,该订单会收到微信支付的回调

参考

https://www.jianshu.com/p/76bc0e963172
https://www.zhihu.com/question/268937734
https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_3

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