PHP项目库存扣减与超卖

wen PHP项目 3

本文目录导读:

PHP项目库存扣减与超卖

  1. 超卖的本质与常见场景
  2. 解决方案层级图(从简单到复杂)
  3. 完整代码实现(最优方案:Redis + Lua)
  4. 补充优化方案(根据流量选择)
  5. 关键设计原则总结
  6. 最终建议

在PHP项目中处理库存扣减与超卖,通常面临高并发数据一致性两个核心挑战,下面将从底层原理分析具体代码实现,给出完整的解决方案。


超卖的本质与常见场景

超卖:当多个用户同时请求购买同一商品,且库存只剩1件时,如果系统判断“库存 > 0”后扣减,但实际执行的扣减次数大于实际库存,就会卖出超出库存的商品。

常见的失误代码(典型超卖原因):

// ❌ 错误示例:并发下会超卖
$stock = $redis->get('product:100:stock');
if ($stock > 0) {
    // 模拟耗时操作(网络延迟、DB查询等)
    $redis->decr('product:100:stock'); // 扣库存
    // 插入订单...
}

原因getdecr 之间不是原子操作,多个请求可能同时读到 stock=1


解决方案层级图(从简单到复杂)

方案 适用场景 技术核心 性能
数据库乐观锁 中小流量 UPDATE ... WHERE stock > 0 较低(依赖DB)
Redis Lua 脚本 高并发 原子性扣减
Redis 原子操作 + 队列 极高并发 DECR + 异步订单 极高
消息队列(RabbitMQ/Kafka) 超高并发 削峰填谷 最高

完整代码实现(最优方案:Redis + Lua)

这是目前 PHP + Redis 最推荐的方式,Lua脚本保证原子性,避免超卖。

环境准备

确保已安装:

  • PHP 7.4+(推荐8.0+)
  • Redis 扩展(phpredisPredis

Redis 库存初始化

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 商品ID假设为 100
$productId = 100;
$initialStock = 10; // 初始库存
// 初始化库存(只在第一次运行)
$redis->set("product:{$productId}:stock", $initialStock);
$redis->set("product:{$productId}:sold", 0); // 已售数量

核心方案:Lua 脚本原子扣减

/**
 * 原子扣减库存
 * @param int $productId 商品ID
 * @param int $quantity 购买数量
 * @return bool|string 成功返回订单号,失败返回 false
 */
function deductStock($productId, $quantity = 1) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    // Lua 脚本(原子操作)
    $lua = <<<LUA
        -- 检查库存是否存在
        if redis.call('EXISTS', KEYS[1]) == 0 then
            return -1  -- 库存不存在
        end
        -- 获取当前库存
        local stock = tonumber(redis.call('GET', KEYS[1]))
        -- 检查库存是否充足
        if stock < tonumber(ARGV[1]) then
            return 0  -- 库存不足
        end
        -- 扣减库存(原子递减)
        redis.call('DECRBY', KEYS[1], ARGV[1])
        -- 增加已售数量
        redis.call('INCRBY', KEYS[2], ARGV[1])
        -- 生成唯一订单号
        local order_id = 'ORD' .. ARGV[2] .. '_' .. redis.call('INCR', KEYS[3])
        -- 将订单号存入集合(用于后续异步处理)
        redis.call('RPUSH', KEYS[4], order_id)
        return order_id
    LUA;
    // 执行 Lua 脚本
    $keys = [
        "product:{$productId}:stock",    // KEYS[1] 库存键
        "product:{$productId}:sold",     // KEYS[2] 已售键
        "order:seq",                     // KEYS[3] 订单序列号
        "order:pending",                 // KEYS[4] 待处理订单队列
    ];
    $args = [
        $quantity,           // ARGV[1] 扣减数量
        $productId,          // ARGV[2] 商品ID(拼订单号用)
    ];
    $result = $redis->eval($lua, $keys, $args);
    if ($result === 0) {
        return false; // 库存不足
    } elseif ($result === -1) {
        throw new Exception("商品库存不存在");
    }
    return $result; // 返回订单号
}

完整下单流程(含订单写入)

function createOrder($userId, $productId, $quantity) {
    // 步骤1:原子扣减库存
    $orderId = deductStock($productId, $quantity);
    if (!$orderId) {
        return ['success' => false, 'message' => '库存不足'];
    }
    // 步骤2:异步处理订单(通过队列或直接入库)
    // 简化:直接写入数据库,实际业务建议用MQ
    $pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
    $pdo->beginTransaction();
    try {
        // 插入订单表
        $stmt = $pdo->prepare("INSERT INTO orders (order_no, user_id, product_id, quantity, status) VALUES (?,?,?,?, 'paid')");
        $stmt->execute([$orderId, $userId, $productId, $quantity]);
        $pdo->commit();
        return ['success' => true, 'order_no' => $orderId];
    } catch (Exception $e) {
        $pdo->rollBack();
        // 订单写入失败需要回补库存
        rollbackStock($productId, $quantity, $orderId);
        return ['success' => false, 'message' => '订单创建失败'];
    }
}
// 回补库存(订单失败时执行)
function rollbackStock($productId, $quantity, $orderId) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    // 使用 Lua 保证原子回补(同时检查订单是否真是失败)
    $lua = <<<LUA
        local order_no = ARGV[1]
        local product_stock_key = KEYS[1]
        local sold_key = KEYS[2]
        local pending_list = KEYS[3]
        -- 检查订单是否不存在(防止重复回补)
        if redis.call('SISMEMBER', pending_list, order_no) == 1 then
            -- 回补库存
            redis.call('INCRBY', product_stock_key, ARGV[2])
            redis.call('DECRBY', sold_key, ARGV[2])
            -- 删除待处理记录
            redis.call('LREM', pending_list, 1, order_no)
            return 1
        end
        return 0
    LUA;
    $redis->eval($lua, [
        "product:{$productId}:stock",
        "product:{$productId}:sold",
        "order:pending"
    ], [$orderId, $quantity]);
}

高并发压测验证

// 压测脚本(模拟100个并发请求)
$productId = 100;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->set("product:{$productId}:stock", 1); // 只有1件库存
// 模拟并发
$curlMulti = curl_multi_init();
$urls = [];
for ($i = 0; $i < 10; $i++) {
    $ch = curl_init("http://your-server/buy.php?product={$productId}&qty=1");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_multi_add_handle($curlMulti, $ch);
}
$running = null;
do {
    curl_multi_exec($curlMulti, $running);
} while ($running > 0);
// 检查最终库存
$finalStock = $redis->get("product:{$productId}:stock");
echo "最终库存: {$finalStock}\n"; // 应该是 0,绝不会出现负数

补充优化方案(根据流量选择)

方案A:数据库乐观锁(中小流量)

// 数据库表结构
// product 表 (id, stock, version)
// 扣减库存(乐观锁方式)
$sql = "UPDATE product 
        SET stock = stock - :qty, version = version + 1 
        WHERE id = :id AND stock >= :qty AND version = :version";
$stmt = $pdo->prepare($sql);
$stmt->execute([':qty' => $quantity, ':id' => $productId, ':version' => $currentVersion]);
if ($stmt->rowCount() === 0) {
    // 库存不足或版本冲突,需重试
}

方案B:Redis 原子操作简化版(低并发场景)

// 原子递减(不会超卖)
$remaining = $redis->decr("product:{$id}:stock");
if ($remaining >= 0) {
    // 成功
} else {
    // 失败,回补
    $redis->incr("product:{$id}:stock");
}

关键设计原则总结

  1. 原子性:务必使用 Redis Lua 脚本或数据库 UPDATE + WHERE 条件保证原子性。
  2. 避免“先查后扣”:任何 get > check > decr 的非原子操作都可能导致超卖。
  3. 订单与库存解耦:库存扣减成功后再生成订单;订单失败要回补。
  4. 监控与告警:监听 Redis 库存键,当库存 <= 阈值时触发补货或警告。
  5. 超时与重试:在高并发下,数据库写订单可能超时,需设置合理的重试策略。

最终建议

项目阶段 推荐方案
创业初期(日流量 <1万) 数据库乐观锁(方案A)
成长阶段(日流量 <50万) Redis + Lua(推荐方案)
大型电商(日流量 >100万) Redis Lua + 消息队列(消峰)+ 分库分表

最核心的代码就是上面的 Lua 脚本,它解决了99%的并发超卖问题,实际生产环境中,你还需要:

  • 使用 Redis 集群 分散压力
  • 设置 库存预热缓存 避免冷启动
  • 定期 同步 Redis 与 MySQL 库存(用定时任务补偿)

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