cover_image

双十一,电商系统如何防止库存超卖

潘佳俊 又拍云
2021年11月04日 10:30

图片

一年一度的双十一又开始了,各大 App 纷纷推出了购物优惠活动。除了常见的淘宝、拼多多、京东等 App,甚至银行、视频类 App 等等也加入了双十一这个战场,推出了秒杀之类的活动。在这些商品的活动页上,往往能够看到“剩余 XXX”的字样,这些其实就是商品的库存。而在秒杀过程中,如何防止商品超卖,也就是超低价货物售卖过量造成亏本,以及商品卖完发不出货的情况,就显得尤为重要。下面我们就来简单讲讲,电商系统中防止库存超卖的几种方法。

说明:本文重点在实现防止库存超卖,为了减少复杂度,其它部分则能简单就简单。

使用工具:

  • koa:web 框架

  • apache benchmark:模拟并发请求

  • Redis:存储库存

我们先来模拟一下商品购买的场景:

const goodKey = 'goodsId:1'; // 商品idconst ordersKey = 'orders'; // 模拟订单表
app.use(async ctx => { if (Number.parseInt(await redis.get(goodKey)) > 0) { await redis.incrby(goodKey, -1); // 减库存 await redis.rpush(ordersKey, 1); // 模拟抢购成功 生成订单 ctx.status = 200; } else { ctx.status = 500; }});

我们先手动将库存设置为 100,订单表清空,然后使用 ab 工具模拟并发请求。

ab -c 10 -n 1000 http://localhost:4000/

我们用 Redis 列表的长度来记录生成了多少订单。下面是本次运行结果(每次结果可能不一样):

> llen orders101

可以看到,一共购买了 101 个,超卖了一个。

这是因为 Redis 对外宣称单机 QPS 可以达到 10w 级别,所以在请求处理过程中,redis.get 和 redis.incrby 两条命令之间可能会运行很多条其它 Redis 命令。而在并发场景下,这样的处理会极大概率出现超卖情况。

要处理这个问题有三种方法:

  • 判断 incrby 命令的返回值

  • 使用 lua 脚本让 Redis 命令具有原子性

  • 使用 Redis 的 rpop 命令



判断 incrby 命令的返回值


由于 incrby 命令有返回值,因此我们可以直接判断返回值是否大于 0 来判断是否抢购成功,不再需要 get 命令。下面是示例:

const goodKey = 'goodsId:1';const ordersKey = 'orders';
app.use(async ctx => { if (Number.parseInt(await redis.incrby(goodKey, -1)) >= 0) { // 减库存 await redis.rpush(ordersKey, 1); // 模拟抢购成功 生成订单 ctx.status = 200; } else { ctx.status = 500; }});

模拟并发请求后,查看生成了多少订单。

> llen orders100

我们可以看到,最终是生成了 100 个订单,并没有超卖。而且和超卖的代码相比,代码更加简洁了,并且每次请求还少了一次请求 Redis 的命令。



使用 Lua 脚本让 Redis 命令具有原子性


如下方示例所示,使用 Lua 脚本是解决超卖最经典的办法:

redis.defineCommand('createOrder', {  numberOfKeys: 1,  lua: `  if (redis.call('exists', KEYS[1]) ==1) then    local stock = tonumber(redis.call('get', KEYS[1]));    if (stock <= 0) then      return -1;    end;    redis.call('incrby', KEYS[1], -1);    return stock - 1;  end;  return -1;  `,});
const goodKey = 'goodsId:1';const ordersKey = 'orders';
app.use(async ctx => { if (Number.parseInt(await redis.createOrder(goodKey)) >= 0) { // 减库存 await redis.rpush(ordersKey, 1); // 模拟抢购成功 生成订单 ctx.status = 200; } else { ctx.status = 500; }});

查看并发请求的结果,通过生成的订单数量,可以看到并没有超卖。

> llen orders100

这个办法中我们虽然使用到了 Lua 脚本,但是这个脚本可以在网上很容易搜索到,并不需要深入学习 Lua,而且上面用到有其它语言基础的同学看懂这段代码也并不难。



使用 Redis 的 rpop 命令


上面两个解决办法中,我们都是将库存设置为 string。在这个办法中,我们将库存设置为 list。我们在 list 中存储与库存相同的数量的数据,存什么并不重要(也可以提前生成订单 ID 或者 一些虚拟商品 比如演唱会门票的唯一 ID),目前这种场景下,因为我们并不需要这个值,我们只需要判断是否有数据能 pop 出来。

app.use(async ctx => {  if (await redis.lpop(goodKey)) { // 减库存    await redis.rpush(ordersKey, 1); // 模拟抢购成功 生成订单    ctx.status = 200;  } else {    ctx.status = 500;  }});

查看并发请求的结果

> llen orders100

可以看到并没有超卖。



其它解决超卖的办法


除了上面说的三种基于 Redis 实现的防止超卖的解决方案,还有其它几种解决办法,但是都不是很推荐,所以这里只是简单介绍下。

分布式锁

使用分布式锁也可以解决超卖的问题,我们可以基于 Redis 和 zookeeper 实现分布式锁(使用 go 开发的同学也可以使用 etcd 实现分布式锁)。但是这两种实现分布式锁都有一些小问题。

基于 Redis 实现分布式锁有比较难实现阻塞的缺点,因为不是阻塞所以并不是先到先得,在秒杀时会出现后支付的用户越过前面支付的用户购买成功的情况。

而基于 zookeeper 实现分布式锁有下面两个缺点:

  • java程序员和熟悉kafka的程序员比较熟悉,其他语言的使用者需要一定的学习成本

  • 引入了新的中间件,提高了维护成本

还有一个不管使用哪种方法实现分布式锁都会有的问题,那就是性能问题。

综上所述,不推荐使用分布式锁解决超卖。

乐观锁

Redis 和 MySQL 都可以实现乐观锁,而且难度也不大。但是也有一个缺点,就是无法做到“先到先得”,有些人明明比你晚点抢购按钮,但是竟然比你先抢到了,这对于用户的体验是很差的。

MySQL

在一些并发量没那么大的情况下,我们可以不使用 Redis 来扛并发,直接使用 MySQL 就好。一般解决方案有以下几种:

  • 在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;

  • 直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;

  • 使用 CASE WHEN 判断语句,例如这样的 SQL 语句:

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

关于如何防止库存超卖的方法,我要讲的就是上面的这几项啦,其中特别推荐大家前三种办法,如果有需要后续大家可以尝试使用看看。



快 来 小 拍


图片

推 荐 阅 读
图片
图片
图片

设为星标

图片

更新不错过

继续滑动看下一个
又拍云
向上滑动看下一个