PHP项目商品库存如何扣减防止超卖

wen PHP项目 26

在PHP项目中防止商品超卖(即库存扣减为负数),核心在于保证库存操作的原子性,绝不能使用“先查询再计算再更新”的模式(存在并发问题)。

PHP项目商品库存如何扣减防止超卖

以下是几种常见的、从简单到复杂的解决方案:

数据库乐观锁(适合低并发)

利用数据库的 UPDATE 语句中的条件判断,确保只在库存足够时扣减。

原理UPDATE 语句是原子操作,加上 WHERE stock > 0 条件。

SQL 示例

// 假设订单需要扣减 1 件商品
$sql = "UPDATE products 
        SET stock = stock - 1 
        WHERE product_id = :product_id AND stock >= 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
$affectedRows = $stmt->rowCount();
if ($affectedRows === 0) {
    // 库存不足,秒杀失败
    echo "库存不足";
} else {
    // 扣减成功,继续后续操作
}

优点:简单,无外部依赖,适合绝大多数常规业务。 缺点:在高并发下,数据库行锁竞争激烈(行锁),虽然不会超卖,但性能会下降,且 stock 字段频繁更新,影响缓存失效。

Redis 原子递减(适合高并发秒杀)

利用 Redis 的 DECRDECRBY 命令(单线程天然原子)。

原理:将库存预热到 Redis,每次请求执行 DECR,如果返回负数则表示超卖。

代码示例

// 1. 初始化(仅在项目启动时执行一次)
$redis->set('stock:product_123', 100);
// 2. 扣减库存(原子操作)
$currentStock = $redis->decr('stock:product_123'); // 自减1,返回剩余库存
if ($currentStock < 0) {
    // 超卖了,需要回滚(补偿)
    $redis->incr('stock:product_123'); // 紧要:将库存加回来
    echo "库存不足";
} else {
    // 扣减成功,可以将订单信息放入消息队列,异步落库
    // ... 后续逻辑,如写入 MySQL 订单表
    echo "抢购成功,剩余库存:" . $currentStock;
}

关键注意事项

  1. 回滚补偿DECR 返回负数,要立即 INCR 加回来,防止库存变成负数。
  2. Lua 脚本:推荐使用 Lua 脚本将“扣减+判断”合并为一个原子操作,避免网络问题导致补偿失败。
    -- Lua 脚本
    local stock = redis.call('get', KEYS[1])
    if stock and tonumber(stock) >= tonumber(ARGV[1]) then
        redis.call('decrby', KEYS[1], ARGV[1])
        return 1
    else
        return 0
    end
  3. 最终一致性:Redis 扣减成功后,订单数据需要通过消息队列异步写入 MySQL(防刷单、数据持久化),Redis 库存不等于实际数据库库存。

MySQL 悲观锁(SELECT ... FOR UPDATE)(不推荐高并发)

原理:在事务内锁定行,其他请求必须等待。

代码示例

$pdo->beginTransaction();
try {
    // 1. 锁住该行(注意:必须加索引,否则会锁表)
    $sql = "SELECT stock FROM products WHERE product_id = ? FOR UPDATE";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$productId]);
    $row = $stmt->fetch();
    if ($row['stock'] <= 0) {
        throw new \Exception("库存不足");
    }
    // 2. 扣减库存(当前独占锁)
    $updateSql = "UPDATE products SET stock = stock - 1 WHERE product_id = ?";
    $pdo->prepare($updateSql)->execute([$productId]);
    // 3. 插入订单...
    $pdo->commit();
} catch (\Exception $e) {
    $pdo->rollBack();
    echo "失败:" . $e->getMessage();
}

优点:绝对不超卖,简单。 缺点:吞吐量极低,容易导致数据库连接池耗尽,不建议在高并发秒杀中使用

分布式锁(适合多个 PHP 进程/服务器)

使用 Redis 的 SET NX EX 或 Redlock 算法。

原理:用户抢购前,先尝试获取锁(key = product_lock:{id}),拿到锁的用户才能操作库存,操作完释放锁。

代码示例

$lockKey = "product_lock:{$productId}";
$lockValue = uniqid('', true); // 唯一值,用于安全释放锁
// 获取锁(等待 3 秒,锁过期时间 5 秒)
if ($redis->set($lockKey, $lockValue, ['NX', 'EX' => 5])) {
    try {
        // 1. 查询库存(MySQL)
        $stock = getStockFromDB($productId);
        if ($stock <= 0) {
            throw new \RuntimeException("已售罄");
        }
        // 2. 扣减库存(MySQL)
        updateStockInDB($productId, $stock - 1);
        // 3. 生成订单...
    } finally {
        // 安全释放锁(使用 Lua 脚本确保只有自己释放自己的锁)
        $script = '
            if redis.call("get",KEYS[1]) == ARGV[1] then
                return redis.call("del",KEYS[1])
            else
                return 0
            end
        ';
        $redis->eval($script, [$lockKey, $lockValue], 1);
    }
} else {
    echo "系统繁忙,请重试";
}

优点:解决了分布式系统一致性问题。 缺点:存在锁冲突损耗,且需要处理锁的超时/续期(避免死锁)。

最佳实践推荐

对于大多数 PHP 项目,推荐组合使用“方案一 + 方案二”

  1. 前端防护:按钮置灰、限制请求频率(防止恶意脚本)。
  2. Redis 校验层:使用 Redis DECR + Lua 脚本进行并发拦截,这是流量入口,快速过滤无效请求。
  3. MySQL 兜底层:异步写入订单时,再次使用 UPDATE ... WHERE stock >= 1 进行最终校检(防止 Redis 宕机导致数据不一致)。
  4. 流量削峰:用户点击后不直接返回结果,而是返回“排队中”,后台通过消息队列(RabbitMQ/Redis List)异步处理订单落库。

原子性是核心,永远不要分两步操作。UPDATE ... WHERE stock >= N 是成本最低且最可靠的保底方案。

抱歉,评论功能暂时关闭!