在设计商品的库存扣减逻辑时,可能一开始想到的(伪)代码是:
<?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 也有对应的悲观锁 / 乐观锁实现方案。
悲观锁方案是结合使用 SETNX
和 DECRBY
命令实现库存扣减,首先使用 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
锁住数据是有必要的。
代码示例:
function stock_decr($skuId, $num)
{
$conn = new Redis();
for ($i = 0; $i < 5; $i++) {
// 获取悲观锁
$lockKey = 'lock:pessimistic';
$ok = $conn->set($lockKey, 1, ['EX' => 120, 'NX']);
if (!$ok) {
continue;
}
try {
// 获取库存
$skuKey = "sku:$skuId";
$stock = $conn->get($skuKey);
if ($stock === false) {
throw new Exception("商品不存在");
}
$stock = intval($stock);
if ($stock < $num) {
throw new Exception("库存不足");
}
$ok = $conn->decrBy($skuKey, $num);
if ($ok === false) {
throw new Exception("库存扣减失败");
}
return true;
} catch (Exception $e) {
return false;
} finally {
$conn->del($lockKey);
}
}
return false;
}
方案四:基于Redis的乐观锁方案
乐观锁方案需要结合使用 WATCH
、MULTI
、DECRBY
、EXEC
、UNWATCH
命令。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 = 0
if (stock >= num)
then
redis.call('DECRBY', sku, num)
result = 1
end
return result
在 Redis 中,我们可以使用 EVAL
或 EVALSHA
命令执行 Lua 脚本代码,但使用 EVAL
命令客户端每次都要重复向 Redis 传递一段相同的 Lua 代码,网络开销较大。而 EVALSHA
命令则是从 Redis 中获取已经缓存好的脚本执行,网络开销较小,但需要先使用 SCRIPT LOAD
命令把 Lua 脚本加载到 Redis。综上,推荐使用 EVALSHA
命令。
Lua 脚本中的代码,Redis会把它们当作单条命令执行,所以是原子性的。而且在这个方案中,我们并没有使用到悲观锁 或者 乐观锁,因此性能上会更好,推荐使用此种方案。