PHP 秒杀防超卖技巧

wen PHP项目 2

本文目录导读:

PHP 秒杀防超卖技巧

  1. 核心原则
  2. 数据库级方案(推荐,最可靠)
  3. 高并发架构层面的兜底(提升用户体验)
  4. 完整最佳实践示例(Redis 预扣 + 数据库落地)
  5. 总结避坑指南

在 PHP 高并发秒杀场景下,防超卖(库存不能变负数)的核心在于保证扣减库存操作的原子性

如果处理不当,传统的 SELECTUPDATE 逻辑在并发下极易超卖。

以下是几种由简到繁、适用于不同场景的 PHP 防超卖实战技巧:


核心原则

不要用“先查库存再 UPDATE”,因为这两步之间存在“时间差”(竞态条件)。

正确做法一步到位,利用数据库的行锁原子操作


数据库级方案(推荐,最可靠)

方案 A:乐观锁(版本号 / 条件更新)

SQL 层面直接使用 WHERE 条件判断库存是否大于 0 来作为“锁”。

<?php
// 用户请求扣减库存
$userId = 123;
$updateResult = DB::update(
    "UPDATE goods SET stock = stock - 1, version = version + 1 
     WHERE id = ? AND stock > 0", 
    [$goodsId]
);
if ($updateResult->rowCount() === 1) {
    // 成功,拿到购买资格
    echo "购买成功,生成订单";
} else {
    // 失败,说明库存不足或已被抢光
    echo "当前无货,秒杀失败";
}

原理UPDATE 语句自带行级排他锁,当 stock > 0 条件不满足时(即库存为0),受影响行数为0,从而杜绝了并发下的超卖。


方案 B:悲观锁(SELECT ... FOR UPDATE

适合“库存必须精确扣减”且“事务时间较短”的场景,需要在事务内执行。

<?php
$pdo->beginTransaction();
try {
    // 1. 锁定该行(带排他锁)
    $stmt = $pdo->prepare("SELECT stock FROM goods WHERE id = ? FOR UPDATE");
    $stmt->execute([$goodsId]);
    $stock = $stmt->fetchColumn();
    // 2. 判断库存
    if ($stock <= 0) {
        $pdo->rollBack();
        return false;
    }
    // 3. 扣减库存
    $pdo->prepare("UPDATE goods SET stock = stock - 1 WHERE id = ?")->execute([$goodsId]);
    // 4. 提交事务
    $pdo->commit();
    return true;
} catch (\Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

注意:悲观锁必须确保操作在事务中,并且锁必须在事务内释放,如果事务开启太久,会堵塞其他请求。


方案 C:Redis 原子操作(高性能秒杀首选)

将库存预加载到 Redis,利用 Redis 的单线程特性实现原子操作。注意:最终落库还需要配合数据库方案,防止 Redis 宕机丢失数据。

<?php
$redis = new Redis();
// 场景一:使用 DECR(减一)判断返回值
$stock = $redis->decr("seckill:goods:1001:stock");
if ($stock >= 0) {
    // 扣减成功(库存足够)
    // 这里可以异步发送 MQ 去数据库扣减并生成订单
    echo "抢购成功,排队生成订单";
} else {
    // 扣减失败(库存不足)
    // 恢复库存(加回来,防止负数残留)
    $redis->incr("seckill:goods:1001:stock");
    echo "已抢光";
}
// 场景二:使用 Lua 脚本保证原子性(更严谨)
$lua = <<<LUA
local stock = redis.call('get', KEYS[1])
if tonumber(stock) > 0 then
    redis.call('decr', KEYS[1])
    return 1
else
    return 0
end
LUA;
$result = $redis->eval($lua, ['seckill:goods:1001:stock'], 1);
if ($result == 1) {
    echo "成功";
}

高并发架构层面的兜底(提升用户体验)

为了防止数据库被高并发瞬间压垮,通常在扣减前先做一层“流量削峰”:

  • 限流:使用令牌桶或信号量限制每秒进入秒杀接口的请求数(如 Nginx 层或 Redis)。
  • 排队:用户在 Redis 扣减成功后,并不直接操作数据库,而是将用户ID放入 MQ(如 RabbitMQ/Kafka)或 Redis 队列,由后台进程异步串行消费队列,再去执行上述 UPDATE ... WHERE stock > 0 语句。
  • 防止重复:数据库表必须加 唯一索引(如 user_id + goods_id),保证同一个用户只能秒杀一次,即使前端发起多次请求。

完整最佳实践示例(Redis 预扣 + 数据库落地)

<?php
// Step 1: 在 Redis 中扣减(拦截 90% 流量,速度快)
$redis = new Redis();
$lua = <<<LUA
if redis.call('exists', KEYS[1]) == 1 then
    if tonumber(redis.call('get', KEYS[1])) > 0 then
        redis.call('decr', KEYS[1])
        return 1
    end
    return 0
end
return -1 -- 表示库存未初始化
LUA;
$result = $redis->eval($lua, ['stock:'.$goodsId], 1);
if ($result === -1) {
    // 初始化库存(这一步通常在系统启动时做)
    $redis->set('stock:'.$goodsId, $goods['stock']);
    echo "系统初始化中";
    exit;
}
if ($result === 0) {
    echo "已被抢光";
    exit;
}
// Step 2: Redis 扣减成功后,进行数据库落库(异步队列)
// 将 $userId 和 $goodsId 发送到消息队列
// $mq->send([
//   'user_id' => $userId, 
//   'goods_id' => $goodsId
// ]);
echo "抢购成功,正在确认订单...";
// Step 3: 消费者(后台Worker)处理队列消息
// 在 Worker 中执行:
try {
    $db->beginTransaction();
    // 使用条件更新,作为最终的“保险”
    $affected = $db->exec("UPDATE goods SET stock = stock - 1 WHERE id = {$goodsId} AND stock > 0");
    if ($affected === 1) {
        $db->exec("INSERT INTO orders (user_id, goods_id) VALUES ({$userId}, {$goodsId})");
        $db->commit();
    } else {
        $db->rollBack();
        // 这里必须手动将 Redis 库存加回去,否则数据会不一致
        $redis->incr("stock:".$goodsId);
    }
} catch (Exception $e) {
    $db->rollBack();
    $redis->incr("stock:".$goodsId);
}

总结避坑指南

技巧 适用场景 核心优点 核心缺点
UPDATE ... WHERE stock > 0 所有场景(数据库) 写代码最简单,零依赖 数据库在极高并发下可能成为瓶颈
SELECT ... FOR UPDATE 对数据准确性要求极高 符合事务直觉 容易死锁,且并发性能差
Redis DECR / Lua 高并发秒杀 抗高并发,速度快 需要处理 Redis 和 DB 的最终一致性

最后的关键提醒永远不要在 PHP 代码中这样判断:

$stock = DB::select("SELECT stock FROM goods WHERE id = 1");
if ($stock > 0) {
    // 模拟业务处理耗时
    sleep(1);
    // 这里大概率会超卖!
    DB::update("UPDATE goods SET stock = stock - 1 WHERE id = 1");
}

解决方案一定要围绕“原子操作”展开。

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