一年一度的双十一又开始了,各大 App 纷纷推出了购物优惠活动。除了常见的淘宝、拼多多、京东等 App,甚至银行、视频类 App 等等也加入了双十一这个战场,推出了秒杀之类的活动。在这些商品的活动页上,往往能够看到“剩余 XXX”的字样,这些其实就是商品的库存。而在秒杀过程中,如何防止商品超卖,也就是超低价货物售卖过量造成亏本,以及商品卖完发不出货的情况,就显得尤为重要。下面我们就来简单讲讲,电商系统中防止库存超卖的几种方法。
说明:本文重点在实现防止库存超卖,为了减少复杂度,其它部分则能简单就简单。
使用工具:
koa:web 框架
apache benchmark:模拟并发请求
Redis:存储库存
我们先来模拟一下商品购买的场景:
const goodKey = 'goodsId:1'; // 商品id
const 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 orders
101
可以看到,一共购买了 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 orders
100
我们可以看到,最终是生成了 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 orders
100
这个办法中我们虽然使用到了 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 orders
100
可以看到并没有超卖。
其它解决超卖的办法
除了上面说的三种基于 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
关于如何防止库存超卖的方法,我要讲的就是上面的这几项啦,其中特别推荐大家前三种办法,如果有需要后续大家可以尝试使用看看。
设为星标
更新不错过