聊聊超卖

发表信息: by

聊聊超卖

焦点: 这篇文章,主要是想聊一聊 “超卖” 这个场景,并且借此,串一下整个电商的交易流程,加深对电商交易的认真。

电商交易流程

我们先看下电商的交易流程:

image

不管是同城外卖场景,还是异城的的传统电商购物,主要流程都不会有太多区别。

  • 商品筛选

交易的实体是商品,而商品筛选是最先经历的一环,用户的目的是从无数的商品中,筛选(搜索)出自己想要买的那一款,所以搜索这一环是平台必须提供的能力,而平台的目的,就是在无数的商品中,尽可能的将用户要买的商品摆到用户面前,这也就是所谓的 “推荐“

根据业务场景不同,外卖电商会有很强的商家概念,所谓“很强”,就是会在用户端凸显商家的存在,用户第一选择的主体是商铺,然后才是商铺中的商品。而普通电商并不是没有商家,而是商家不会在用户端被很强的凸显出来。

  • 商品详情

对交易实体-商品的详细介绍,包括商品图片,商品介绍,商品价格等等,目的是让用户能够对商品本身有个更清晰的认知,一些特殊的技术如:全景,模拟,视频,等都在努力丰富用户对产品的认知。

  • 购买确认

用户选好商品后准备购买的二次确认操作,无论是选一件商品,还是选多件商品,都需要用户确认,本质上来说,买一件商品和买多件商品并没有区别,但在存储上,根据业务场景不同,会有不同的实现手段,比如外卖电商是只能在一个店铺下进行多商品购买的(方便后面配送履约),普通电商是可以跨店购买商品的(后面的逻辑是拆单,将不同店铺的商品,拆成不同单)。二次确认主要是让用户再次明确:商品价格、优惠政策、配送地址等,也叫预签合同,当用户对此合同支付完成之后,合同便开始生效(创建订单,订单状态开始流转),直到商品配送到用户手里,用户满意后确认收货,合同完成(订单完结)

  • 支付货币

有自主钱包的,可以免跳转第三方支付,没有自主钱包的平台,都会跳转三方支付平台进行支付,用户确认付款后。

  • 商品履约

商品配送可以分为本地生活类的骑手配送(商家送,自取等),和快递类的全国送(得益于国家强大,基建完善:中国邮政无处不在),履约也有可能有自身的状态流转(比如待接单,接单送出,商品送达等),所以仔细一点的电商 平台都会有区别于“用户单”的“履约单”,履约单又是一大堆的细节,这里不做细节讨论。

超卖是什么

超卖本身是一个股票概念,不知道为什么出现在电商交易中,而在电商交易中,超卖指:卖出商品的数量 大于 商品可卖库存。

还原真实场景,看下什么时候,卖出的商品数量 大于 商品可卖库存。打个比方 A是商户,手里有10个苹果,每个1块钱;B、C、D是买家,都要买苹果,不过B、C、D的需求各不相同,用表格来具体一下数据。

类型 动作 苹果数量
:—: :—: :—: :—:
A 商户 出售 10
B 用户 购买 5
C 用户 购买 4
D 用户 购买 2

在看几种有意思的交易流程:

场景(一) 顺序交易,先付款,后交货,无逆向

  • B准备买5个苹果,然后把5块钱给了A,A收了钱后发现自己还有10个苹果,就把其中5个给了B,B交易完成
  • C准备买4个苹果,然后把4块钱给了A,A收了钱后发现自己还有5个苹果,就把其中4个给了C,C交易完成
  • D准备买2个苹果,然后把2块钱给了A,A收了钱后发现自己还有1个苹果,就把钱还给了A,说库存不够了,A交易停止
  • A剩余1个苹果

场景(二)顺序交易,先交货,后付款,无逆向

  • B准备买5个苹果,A发现自己有10个苹果,就拿出5个给了B,B给了A 5块钱,B交易完成
  • C准备买4个苹果,A发现自己有5个苹果,就拿出4个给了C,C给了A 4块钱,C交易完成
  • D准备买2个苹果,A发现自己有1个苹果,就说库存不够,B交易停止
  • A剩余1个苹果

场景(三)顺序交易,先交货,后付款,有逆向

  • B准备买5个苹果,A发现自己有10个苹果,就拿出5个给了B,B给了A 5块钱,B交易完成
  • C准备买4个苹果,A发现自己有5个苹果,就拿出4个给了C,C不想付款了,就把4个苹果还给了A,C交易停止
  • D准备买2个苹果,A发现自己有5个苹果,就拿出2个给了D,D给了A 2块钱,D交易完成
  • A剩余3个苹果

场景(四)并发交易,先交货,后付款,有逆向

  • B准备买5个苹果,A发现自己有10个苹果,就拿出5个给了B,B给了A 5块钱,B交易完成
  • C和D同时来买苹果,C要4个,D要2个,A先给C拿出4个,又准备给D拿出2个,发现库存不足,D交易停止,C不想付款了,C把4个苹果还给了A,C交易停止
  • A剩余5个苹果

所以,现实场景中,根本 不会发生 超卖, 由于业务是现实场景的反馈,所以,业务上也 不会发生 超卖

业务上不会发生超卖

把准备交易比作“创建订单”,把A拿出苹果比作“扣减库存”,把B交钱给A比作“付款”, 那上面的场景可以简化为:

  • 创建订单,支付成功,扣减库存(可简称 支付后扣库存)
  • 创建订单,扣减库存,支付成功(可简称 创单后扣库存)
  • 创建订单,扣减库存,支付取消,回滚库存

为什么会有超卖

上面说了,业务上不会发生超卖,可大家一直谈论的“交易超卖”是什么呢?还是要分两个方面看:

  • 业务超卖
  • 技术超卖

业务超卖

刚说了“业务上不会发生超卖”,为什么会有“业务超卖”呢? 是因为一些公司,在正常的流程中,人为的改变了一些业务因素,才会导致。

举个例子,拼多多的电商场景,是以用户为主的,原则是大力维护用户利益,在发生交易的时候,如果用户付款之后,平台发现商品已经没有库存了,宁可想办法补调商品(可能会损失平台利益),争取让用户购买到,即特殊情况除外,不会主动发生退款现象(即不会发生场景一,D购买的情况)

那,根据拼多多电商场景,默认不主动退款,而支付后扣库存的场景,我们模拟一个超卖的逻辑:

场景(五)并发交易,先付款,后交货,不主动退款

  • B准备买5个苹果,然后把5块钱给了A,A收了钱后发现自己还有10个苹果,就把其中5个给了B,B交易完成
  • C和D同时来买苹果,C给A 4块钱,要买4个,D给A 2块钱要买2个,A收了C和D的钱,A发现还剩5个苹果,就先给A了4个,然后A发现就剩1个了,不足以支付D 2个苹果,但是因为 不主动退款 的原则,只能想办法掉(或者自己从其他人那买)1个进来,然后再给D 2个苹果。对A来说,超卖了1个(计划卖10个,但是卖了11个)

所谓的业务超卖,就是在正常交易场景下,因为一些业务原因,导致部分环节不执行,导致超卖
支付后扣库存, 不主动退款的情况下,会超卖
下单后扣库存,不主动拦截下单的情况下,会超卖
其中逻辑,耐人寻味

技术超卖

为了变量单一,我们假设业务流程正常,即无业务超卖,单从技术上来看,如何发生超卖?如何避免超卖?

image

把流程聚焦,在交易链路中,存在两种顺序的扣库存行为,一种是下单之后扣库存,一种是支付之后扣库存,由于这两种场景都属于业务场景,业务场景不发生超卖的前提下,只考虑技术导致超卖,那超卖只会发生在「扣减库存」这个点上。

把「扣减库存」这个点放大,可以看到最简单的扣库存架构,将扣库存流程做到最简化,时序图如下:

image

假设两种场景:

场景一:顺序操作
初始化条件:商品A有1个库存

  • 订单服务第一次请求库存服务,查询商品还有库存,要求对商品A减少1个库存,库存DB执行 update goods_stock quantity = quantity - 1 where goods_id = A 之后,quantity 字段为0,下单成功
  • 订单服务第二次请求库存服务,查询商品没有库存,则下单失败

场景二:并发操作
初始化条件:商品A有1个库存

  • 订单第一次扣库存请求和第二次扣库存请求,都同时到了 库存服务,查询都有库存,都进行 update goods_stock quantity = quantity - 1 where goods_id = A 之后,quantity 字段为-1,两次下单都成功

场景二会导致多卖一件,即发生超卖

发生超卖的原因:查询拦截 和 扣减库存 是两个割裂开的动作,并非一个原子操作, 解决办法,也比较简单, 将查询拦截 和 扣减库存 两个动作 做成原子的,有很多办法

小并发防超卖

如果并发比较小(扣库存操作的OPS < 库存DB能够承担的 OPS)最简单的可能是利用sql对数据的影响条数来判断

可用sql:update goods_stock quantity = quantity - 1 where goods_id = A and quantity >0
判断依据:影响行数 >0 则说明扣减成功,影响行数 <=0 则说明扣减失败,拦截订单

大并发防超卖

如果并发比较大(扣库存操作的OPS > 库存DB能够承担的 OPS),单用 DB 做原子操作,对DB的压力比较大,所以要想个办法,既能让 库存判断 和 库存扣减 组成一个原子操作,又能承受很高的OPS。

最先想到的就是用Redis来代替DB支持高OPS。但是用Redis就需要考虑如下几个场景:

  • DB 和 Redis 数据同步(什么时候将DB数据同步到Redis, 什么时候将Redis数据同步DB)
  • 查询拦截 和 扣减库存 两个操作,如何用Redis做成原子化?

Redis在高并发场景下的使用姿势基本是固定的,先不考虑 更新DB 和 更新Redis 失败的情况,在保证 更新DB 和 更新Redis 这两个操作都能成功的情况下,更新Redis的 Design Pattern有四种:

  • Cache Aside
  • Read Through
  • Write Through
  • Write Behind Caching

简单说下四种模式的区别:

Cache aside,查询:应用程序从cache中取数据,取到后返回; 没有得到,则从数据库中取数据,成功后,放到缓存中。跟新:先把数据存到数据库中,成功后,再让缓存失效。

Read Through Pattern, 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through Pattern,套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

Write Behind Caching 在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

再说明一点:所有 Redis 跟新 Desing Pattern 都是会有问题的,在软件设计上,我们基本上不可能做出一个没有缺陷的设计,软件设计从来都是取舍

根据上面四种模式,基本能看出, Write Behind Caching 是我们需要的模式,在请求中,只更新缓存,异步更新数据库,至于异步的逻辑,也有提到(可以是单条处理,可以是批处理)

那有个问题,由于库存以redis为准,何时初始化redis数据?redis过期时间怎么处理? 是否全部都以Redis为准?

先思考个问题,是所有商品都会有大并发的扣库存操作(爆品)?还是部分商品是爆品?成为爆品是否可预测 ?

在我了解的电商场景里面,这个几个问题的答案是:不是所有的商品都是爆品,爆品可预测。 经验来看,仅秒杀场景(定时开售,高性价比)商品所产生的库存扣减流量,才会导致DB承受不住扣减库存OPS。

image

上图是整个操作流程,看图再回答下上面的问题:

  • 何时初始化redis数据? 在创建商品的时候,可把数据设置进去
  • redis过期时间怎么处理? 随着商品被删除、售罄 等动作删除,也可以用job来做数据兜底,比如check删除的商品,缓存是否都被删除了
  • 是否全部都以Redis为准? 爆款商品以redis为准,普通商品以DB为准(扣库存DB是正规操作,为了维稳,只能针对爆款(少数)商品 以Redis为准

再说下这个问题:查询拦截 和 扣减库存 两个操作,如何用Redis做成原子化?

Redis对命令是原子化的(因为对命令处理是单线程的),所以使用lua脚本把 查询拦截 和 扣减库存 做成一个命令,保证原子化。

@Service
public class StockService {

    /**
     * Redis 客户端
     */
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 执行扣库存的脚本
     */
    public static final String STOCK_LUA;

    static {
        /**
         *
         * @desc 扣减库存Lua脚本
         * 库存(stock)0:表示扣库存失败,没有可用库存
         * 库存(stock)1:表示扣库存成功
         */
        StringBuilder sb = new StringBuilder();
        sb.append("if (redis.call('exists', KEYS[1]) == 1) then");
        sb.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
        sb.append("    local num = tonumber(ARGV[1]);");
        sb.append("    if (stock >= num) then");
        sb.append("        redis.call('incrby', KEYS[1], 0 - num);");
        sb.append("        return 1;
        sb.append("    end;");
        sb.append("    return 0;");
        sb.append("end;");
        sb.append("return 0;");
        STOCK_LUA = sb.toString();
    }

    /**
     * 扣库存
     *
     * @param key 库存key
     * @param num 扣减库存数量
     * @return true 扣成功;false 扣失败
     */
    private boolean decrStock(String key, int num) {
        // 脚本里的KEYS参数
        List<String> keys = new ArrayList<>();
        keys.add(key);
        // 脚本里的ARGV参数
        List<String> args = new ArrayList<>();
        args.add(Integer.toString(num));

        long result = redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Object nativeConnection = connection.getNativeConnection();
                // 集群模式和单机模式虽然执行脚本的方法一样,但是没有共同的接口,所以只能分开执行
                // 集群模式
                if (nativeConnection instanceof JedisCluster) {
                    return (Long) ((JedisCluster) nativeConnection).eval(STOCK_LUA, keys, args);
                }

                // 单机模式
                else if (nativeConnection instanceof Jedis) {
                    return (Long) ((Jedis) nativeConnection).eval(STOCK_LUA, keys, args);
                }
            }
        });

        return result == 1;
    }

}

总结

  • 超卖分为 业务导致超卖 和 技术导致超卖
  • 只要业务流程正规,则不会导致业务超卖
  • 只要保证 查询拦截 和 扣减库存 两个操作能被一个原子操作包裹,则不会导致技术超卖
  • 保证 查询拦截 和 扣减库存 两个操作能被一个原子操作包裹, 在大并发 和 小并发 场景,分别有不同的实现方案

上述均个人观点,欢迎拍砖,有问题可 留言/邮件 讨论。