高并发写场景:秒杀系统库存扣减

前言

本文中的伪代码示例均以 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;
    }
}

比较容易看出,上面 “先 SELECT 后 UPDATE” 的代码会有超卖的问题,下面给出几种解决方案供参考。

方案

方案一:仅使用UPDATE

放弃使用 “先 SELECT 后 UPDATE” 的做法,改为只使用 UPDATE:

-- {num} 和 {id} 是变量
UPDATE sku SET stock = stock - {num} WHERE id = {id} AND stock >= {num}

因为有 InnoDB 的行锁存在,所以这种方案并不会出现超卖情况。

方案二:悲观锁

使用FOR UPDATE语句锁住数据,不让其他人查询和修改:
在这里插入图片描述
这样一来,在并发时只有一个请求可以拿到锁,其他请求都要阻塞在 SELECT 语句这个地方等待锁,相当于把并发请求变成串行执行。

方案三:乐观锁

所谓乐观锁,就是在表中新增一个 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 中来可以大大减轻 MySQL 的压力。跟关系型数据库相似,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 锁住数据是有必要的。

代码示例:

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的乐观锁方案

乐观锁方案需要结合使用 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 = 0

if (stock >= num)
then
    redis.call('DECRBY', sku, num)
    result = 1
end
return result

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

Lua 脚本中的代码,Redis会把它们当作单条命令执行,所以是原子性的。

总结

首先,我们知道乐观锁的适用场景是读多写少,而同一商品的秒杀场景下,多个请求都需要修改(写操作)同一行数据,锁冲突会很严重,因此使用乐观锁可能会导致应用在修改库存时需要不断地重试,反而会降低性能,因此排除 方案三方案五

MySQL 的高并发性能并不理想,请求量大的话可能会压垮 MySQL,因此排除 方案一方案二方案三

在剩下的 Redis 方案中,只有 方案六 是没有使用到锁的,而且 Redis 本身的高并发性能十分出色,因此推荐使用 方案六

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值