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

前言

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

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

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

如何线程安全的扣减库存

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

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

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

正确的做法

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

SQL 示例:

1
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脚本,保证整个逻辑处理期间,不会有其他客户端插进来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
*
* 扣减库存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却扣减了库存

1
2
3
4
5
6
7
8
9
10
11
12
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

如果觉得文章有帮助,欢迎点赞、转发、关注我的公众号,你的支持就是我最大的动力