PHP项目怎么实现商品库存锁定?

wen PHP项目 73

本文目录导读:

PHP项目怎么实现商品库存锁定?

  1. 方案一:MySQL 行锁 + 事务(适合中小型项目,推荐作为首选)
  2. 方案二:MySQL 乐观锁(适合读多写少,冲突不严重的场景)
  3. 方案三:Redis 分布式锁(适合高并发、分布式架构)
  4. 方案四:使用消息队列(MQ,如 RabbitMQ / Kafka)
  5. 综合建议
  6. 关键补充:库存锁定的“生命周期”

这是一个非常经典的电商/交易系统设计问题,在PHP项目中实现商品库存锁定,核心目标是在高并发场景下防止超卖(卖出的商品数量超过实际库存)。

不能简单地用 if($stock > 0) { $stock--; } 这样的逻辑,因为在高并发下,多个请求可能同时读到库存为1,然后同时减去,导致库存变为负数。

以下是几种从简单到复杂、从低并发到高并发的实现方案:

MySQL 行锁 + 事务(适合中小型项目,推荐作为首选)

这是最经典、可靠且容易理解的方法,利用数据库自身的锁机制来保证库存扣减的原子性。

核心原理: 在开始扣减库存前,使用 SELECT ... FOR UPDATE 锁定该商品的行记录,直到事务提交才释放,其他请求要操作同一行时,必须等待锁释放。

实现步骤(PHP + MySQL + InnoDB):

  1. 创建订单表 (orders)库存表 (inventory)

    • inventory 表结构:id, product_id, sku (库存单位,如颜色/尺码), quantity (当前可用库存), locked_quantity (锁定库存,可选), version (乐观锁备用)。
  2. PHP 代码逻辑(关键部分):

    <?php
    // 1. 确保数据库使用 InnoDB 引擎
    // 2. 开始数据库事务
    $db = getConnection();
    $db->beginTransaction();
    try {
        $productId = 100; // 商品ID
        $quantityToLock = 1; // 要锁定的数量
        // 3. 关键步骤:使用 FOR UPDATE 锁定该商品的行
        //    这会阻止其他事务读取或修改这一行,直到当前事务提交或回滚
        $sql = "SELECT quantity FROM inventory WHERE product_id = :pid FOR UPDATE";
        $stmt = $db->prepare($sql);
        $stmt->execute([':pid' => $productId]);
        $inventory = $stmt->fetch();
        // 4. 检查库存是否足够
        if ($inventory['quantity'] < $quantityToLock) {
            throw new \Exception('库存不足');
        }
        // 5. 扣减库存(原子性操作)
        $updateSql = "UPDATE inventory SET quantity = quantity - :qty WHERE product_id = :pid AND quantity >= :qty";
        $updateStmt = $db->prepare($updateSql);
        $affectedRows = $updateStmt->execute([
            ':qty' => $quantityToLock,
            ':pid' => $productId
        ]);
        // 6. 检查是否更新成功(防止超卖)
        if ($updateStmt->rowCount() === 0) {
            throw new \Exception('库存更新失败,可能已被扣除');
        }
        // 7. 插入订单记录(或其他业务逻辑)
        $orderSql = "INSERT INTO orders (product_id, user_id, status) VALUES (:pid, :uid, 'pending')";
        $orderStmt = $db->prepare($orderSql);
        $orderStmt->execute([':pid' => $productId, ':uid' => 123]);
        // 8. 事务提交,释放行锁
        $db->commit();
        echo "订单创建成功,库存已锁定";
    } catch (\Exception $e) {
        // 9. 回滚事务,库存自动恢复
        $db->rollBack();
        echo "锁定失败:" . $e->getMessage();
    }

优缺点:

  • 优点: 实现简单,数据强一致,保证不超卖。
  • 缺点: 并发性能较低,大量请求等待行锁时,数据库压力大,不适合秒杀等极高并发场景。

MySQL 乐观锁(适合读多写少,冲突不严重的场景)

不锁行,通过版本号(或条件)来防止超卖。

核心原理: 在更新库存时,加上一个条件 WHERE version = :old_version,如果版本号变了,说明有其他请求先更新了,则本次更新失败。

<?php
// 1. 先读取当前库存和版本号
$sql = "SELECT quantity, version FROM inventory WHERE product_id = :pid";
$stmt = $db->prepare($sql);
$stmt->execute([':pid' => 100]);
$row = $stmt->fetch();
$currentQuantity = $row['quantity'];
$currentVersion = $row['version'];
// 2. 检查库存
if ($currentQuantity < 1) {
    die('库存不足');
}
// 3. 尝试更新,带上版本号条件
$updateSql = "UPDATE inventory SET quantity = quantity - 1, version = version + 1 
              WHERE product_id = :pid AND quantity >= 1 AND version = :old_version";
$stmt = $db->prepare($updateSql);
$stmt->execute([
    ':pid' => 100,
    ':old_version' => $currentVersion
]);
if ($stmt->rowCount() === 0) {
    // 说明版本号变了,有其他请求先改了,需要重试或返回失败
    die('操作失败,请重试');
}
// 更新成功,继续后续流程...

优缺点:

  • 优点: 没有行锁,性能比方案一高。
  • 缺点: 在极端高并发下,大量请求会因版本冲突而失败,需要客户端重试机制。

Redis 分布式锁(适合高并发、分布式架构)

使用 Redis 的原子操作(如 DECR 或 Lua 脚本)或 SETNX 实现锁。

1 使用 Redis 原子操作(最简单,适合纯扣减)

利用 Redis 的 DECR 命令是原子性的,天然防止超卖。

<?php
$redis->decr('stock:product:100'); // 库存减1
// 但要注意,如果库存为0,它仍然会减为-1,所以需要前置检查。

2 使用 Lua 脚本(推荐方式,保证原子性)

Redis 的 Lua 脚本是原子执行的,可以同时完成检查和更新。

<?php
// Lua 脚本
$lua = <<<LUA
    local key = KEYS[1]          -- 库存的Redis key
    local needed = tonumber(ARGV[1]) -- 需要锁定的数量
    local stock = tonumber(redis.call('GET', key))
    if stock and stock >= needed then
        redis.call('DECRBY', key, needed)
        return 1 -- 成功
    else
        return 0 -- 库存不足
    end
LUA;
$result = $redis->eval($lua, ['stock:product:100', 1], 1); // 1表示有1个key
if ($result === 1) {
    // 库存锁定成功,可以继续执行数据库操作
    echo "锁库存成功";
} else {
    echo "库存不足";
}

3 使用 RedLock 等分布式锁

更复杂但更严谨的方案,在设置锁时,给予一个过期时间(TTL),防止死锁,适用于强一致性要求的分布式系统。

优缺点:

  • 优点: 性能极高,适合秒杀。
  • 缺点: 需要搭建 Redis 集群,数据在 Redis 中,Redis 宕机且数据没持久化,会丢失库存信息,需要写回数据库做最终一致性。

使用消息队列(MQ,如 RabbitMQ / Kafka)

将库存锁定请求放入队列,消费者串行化处理,这是解决高并发的经典思路。

流程:

  1. PHP Web 端{ user_id, product_id, quantity } 发给消息队列。
  2. 消费者 Worker 从队列取出消息,单线程分片锁 的方式处理库存扣减。
  3. 关键点: MQ 的消费端必须能保证消息不丢失不重复消费(或业务上幂等)。

优缺点:

  • 优点: 极高的吞吐量,削峰填谷,将瞬时高并发平摊到长时间。
  • 缺点: 架构复杂,引入了 MQ 中间件,库存锁定不是实时的(有延迟),用户需要轮询结果。

综合建议

项目规模/并发量 推荐方案 理由
小型项目 (< 200 QPS) MySQL 行锁 + 事务 简单,可靠,不需要额外组件。
中型项目 (< 2000 QPS) 方案一 + 缓存预热,或 Redis Lua 用 Redis 做流量过滤,真正扣库存还是用 MySQL 事务,或者完全Redis。
大型项目/秒杀 Redis Lua + 消息队列 Redis 抗峰值流量,MQ 保证最终一致性。
任何场景 避免使用 SELECT ... FOR UPDATE 后不立即更新 锁行后要尽快操作,不要做大量的业务逻辑(如调外部API),否则会严重拖慢性能。

关键补充:库存锁定的“生命周期”

库存锁定后,如果用户不支付怎么办?需要解锁释放

  1. 支付成功: 将“可售库存”真正扣减为已售。UPDATE inventory SET quantity = quantity - 1, sold = sold + 1 WHERE ...
  2. 超时未支付/取消订单: 需要将锁定的库存加回去。UPDATE inventory SET quantity = quantity + 1 WHERE ...

    需要有定时任务或延迟队列(如 Redis 的 Keyspace Notification 或 MQ 的延迟队列)来扫描超时订单并释放库存。

对于大多数 PHP 项目,从方案一(MySQL事务+行锁)开始 是最稳妥的,当业务量增长到瓶颈时,再逐步引入 Redis 作为缓存层或库存主力,最后再考虑 MQ。不要一开始就追求最复杂的架构,根据真实业务流量演进。

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