> 文章列表 > 高并发写场景:库存扣减

高并发写场景:库存扣减

高并发写场景:库存扣减

在设计商品的库存扣减逻辑时,可能一开始想到的(伪)代码是:

<?php
/*** 商品库存扣减** @param int $skuId 商品ID* @param int $num   库存扣减数量** @return bool 扣减成功返回true,失败返回false*/
function stock_decr($skuId, $num)
{$db = new DB();$db->beginTransaction();try {// 查询商品信息$skuInfo = $db->query("SELECT stock FROM sku where id = {$skuId}");if (empty($skuInfo)) {throw new Exception("商品不存在");}// 判断库存是否充足if ($skuInfo['stock'] < $num) {throw new Exception("库存不足");}// 计算新的库存值,并更新到数据表中$newStock = $skuInfo['stock'] - $num;$ok = $db->query("UPDATE sku SET stock = {$newStock} WHERE id = {$skuId} LIMIT 1");if (!$ok) {throw new Exception("库存扣减失败");}$db->commit();return true;} catch (Exception $e) {$db->rollBack();return false;}
}

比较容易看出,上面的代码在同一商品的高并发场景下会有超卖的问题。

方案一:悲观锁

使用FOR UPDATE语句锁住数据,不让其他人查询和修改:
高并发写场景:库存扣减
这样一来,在并发时,只有一个请求可以拿到锁,其他请求都要卡在 SELECT 语句这个地方等待锁。相当于把并发请求变成串行执行,而且等待锁的请求越多,对 MySQL 的性能影响越大,因此这种方案也很少使用。

方案二:乐观锁

所谓乐观锁,就是在表中新增一个 version 字段,在并发请求下,多个请求 SELECT 到的 stock 和 version 是一样的,因此在第一个请求成功扣减库存后,需要对 version 字段加1;当第二个请求扣减库存时,由于 version 不匹配就会 UPDATE 不成功。为了提升库存扣减的成功率,可以进行适当次数的重试。伪代码:

function stock_decr($skuId, $num)
{$db = new DB();for ($i = 0; $i < 5; $i++) {$db->beginTransaction();try {// 查询商品信息$skuInfo = $db->query("SELECT stock,version FROM sku where id = {$skuId}");if (empty($skuInfo)) {throw new Exception("商品不存在");}// 判断库存是否充足if ($skuInfo['stock'] < $num) {throw new Exception("库存不足");}// 计算新的库存值,并更新到数据表中$newStock = $skuInfo['stock'] - $num;$newVersion = $skuInfo['version'] + 1;$ok = $db->query("UPDATE sku SET stock = {$newStock},version = {$newVersion} WHERE id = {$skuId} AND version = {$skuInfo['version']} LIMIT 1");if (!$ok) {throw new Exception("库存扣减失败", 100);}$db->commit();return true;} catch (Exception $e) {$db->rollBack();if ($e->getCode() !== 100) {return false;}}}return false;
}

但即使使用了乐观锁,在高并发时,由于都是针对同一个数据行执行 UPDATE 操作,必然会引起大量的请求相互竞争 InnoDB 的行锁,而且即使成功获得锁,也有很大可能会因为 version 不匹配导致 UPDATE 失败,进而不断重试。并发越大,竞争锁的线程就越多,这会严重影响数据库的性能。因此乐观锁并不适用于锁冲突十分严重的场景

方案三:基于Redis的悲观锁方案

Redis 与生俱来就拥有高效的读写性能,所以将库存扣减逻辑转移到 Redis 中来对性能提升十分有效。跟关系型数据库相似,Redis 也有对应的悲观锁 / 乐观锁实现方案。

悲观锁方案是结合使用 SETNXDECRBY命令实现库存扣减,首先使用 SETNX 命令获得锁,获取成功后再使用 DECRBY扣减库存,扣减成功后,释放获得的锁。其实跟方案一的FOR UPDATE加锁,逻辑上是一样的。

在这里,有人可能会有疑惑:Redis本身就是串行执行命令的,不存在并发的问题,为什么还要先用 SETNX 锁住数据呢?直接使用 DECRBY 命令扣减库存,然后判断返回值是否大于等于0不就可以了吗?

是的,这样也是可以的,但是这样库存值有可能会变成负数。比如现有库存是10,同时来了100个请求,每个请求扣减 1 个库存,等全部请求执行完毕后(10个请求成功,90个失败),库存值就会变成 -90;

还有一种情况,假设现有库存是 1,来了一个请求是要买10个商品的,DECRBY后得到的值是 -9,小于0于是返回错误给用户。这时候还没结束,我们还要将库存从 -9 恢复为原本的 1,这样其它用户才能购买。但是因为我们在 DECRBY 之前没有先查询现有库存是多少,不知道原来的库存是 1,所以恢复不了!如果我们改为在 DECRBY 之前,先查询库存有多少,那么就又会回到原来的并发问题,无解。

因此,用 SETNX 锁住数据是有必要的。

方案四:基于Redis的乐观锁方案

乐观锁方案需要结合使用 WATCHMULTIDECRBYEXECUNWATCH命令。WATCH命令用于监视一个或多个key,MULTI命令用于将事务块内的多条命令按顺序加入到队列,最后由EXEC命令原子性地进行提交执行,UNWATCH命令用于取消监视。示例:

127.0.0.1:6379> WATCH sku:123
OK
127.0.0.1:6379> MULTI
127.0.0.1:6379> DECRBY sku:123 10
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 88
127.0.0.1:6379> UNWATCH sku:123
OK

上述示例中,首先使用WATCH命令监视商品的库存key,然后通过MULTI命令标记一个事务的开始,当库存扣减命令(DECRBY)成功添加进队列后,执行EXEC命令提交事务,如果在此过程中,监视的 key 的值发生了变化,那么事务会执行失败;最后使用 UNWATCH命令取消掉对库存 key 的监视。

在同一商品的高并发库存扣减场景下,因为库存key的值会变化得很快,所以EXEC执行的成功率会比较低,往往需要通过重试来提高成功率。

方案五:基于Redis的嵌入lua脚本方案(推荐)

先写一段扣减库存的 Lua 示例代码:

local sku = KEYS[1]
local num = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', sku))
local result = 0if (stock >= num)
thenredis.call('DECRBY', sku, num)result = 1
end
return result

在 Redis 中,我们可以使用 EVALEVALSHA 命令执行 Lua 脚本代码,但使用 EVAL 命令客户端每次都要重复向 Redis 传递一段相同的 Lua 代码,网络开销较大。而 EVALSHA 命令则是从 Redis 中获取已经缓存好的脚本执行,网络开销较小,但需要先使用 SCRIPT LOAD 命令把 Lua 脚本加载到 Redis。综上,推荐使用 EVALSHA 命令。

Lua 脚本中的代码,Redis会把它们当作单条命令执行,所以是原子性的。而且在这个方案中,我们并没有使用到悲观锁 或者 乐观锁,因此性能上会更好,推荐使用此种方案。