PHP项目怎样实现商品秒杀功能?

wen PHP项目 32

本文目录导读:

PHP项目怎样实现商品秒杀功能?

  1. 方案一:基于 Redis 预减库存 + 队列异步处理(推荐,生产级)
  2. 方案二:使用 Redis 事务 + Lua 脚本(原子性操作)
  3. 方案三:简单项目(低并发)—— 直接使用数据库悲观锁
  4. 方案四:乐观锁(涉及库存更新)
  5. 关键优化建议(必看)

实现PHP商品秒杀功能是一个经典的高并发场景挑战,核心难点在于防止超卖(卖出的商品数量超过库存)和解决高并发下的性能瓶颈

由于PHP本身是“请求-响应”模型,且原生不支持常驻内存的锁,因此单纯依赖PHP代码逻辑很难处理高并发,通常需要结合中间件(如 Redis)和异步处理(如消息队列)来实现。

以下是基于你项目具体情况(从简单到可靠)的几种实现方案:

基于 Redis 预减库存 + 队列异步处理(推荐,生产级)

这是目前中大型项目最常用的方案,将库存存放在 Redis 中,利用其单线程特性(原子操作)来防止超卖。

核心流程:

  1. 初始化: 秒杀开始前,将数据库库存同步到 Redis。
  2. 请求拦截(Redis 预减): 用户请求到来,PHP 通过 redis->decr('stock:goods_id') 原子减1。
    • 如果返回值 < 0,说明无库存,直接返回“秒杀结束”。
  3. 队列写入: 减库存成功的用户,将其用户信息和商品ID写入消息队列(如 RabbitMQ、Kafka 或 Redis List)。
  4. 异步处理(消费者): 创建一个常驻脚本(可以在PHP中运行,也可以是用Go等语言),从队列中拉取数据,然后同步到数据库(写入订单、扣减库存)。
  5. 返回结果: 立即返回“正在抢购中...”,用户可以通过轮询或 WebSocket 查看订单状态。

关键代码示例(PHP):

<?php
// 1. 秒杀接口
public function seckill($userId, $goodsId) {
    $redis = new \Redis();
    $redis->connect('127.0.0.1', 6379);
    // 检查用户是否已抢购过(防刷,Redis)
    $buyLimit = "user_limit:{$goodsId}:{$userId}";
    if ($redis->exists($buyLimit)) {
        return ['code' => -1, 'msg' => '每人限购一件'];
    }
    // 2. Redis预减库存(原子操作)
    $stockKey = "stock:{$goodsId}";
    $stock = $redis->decr($stockKey);
    if ($stock < 0) {
        // 库存不足,恢复(防止超卖)
        $redis->incr($stockKey);
        return ['code' => -1, 'msg' => '商品已抢完'];
    }
    // 3. 设置用户限制(有效期防止重复提交)
    $redis->setex($buyLimit, 3600, $userId);
    // 4. 将请求写入消息队列(Redis List做简单队列)
    $queueKey = "seckill_queue:{$goodsId}";
    $data = [
        'user_id' => $userId,
        'goods_id' => $goodsId,
        'time' => time()
    ];
    $redis->rPush($queueKey, json_encode($data));
    return ['code' => 0, 'msg' => '正在排队处理,请稍后查看订单'];
}
// 5. 消费者脚本(需要单独运行 cli 模式)
function consumer() {
    $pdo = new PDO('mysql:host=...;dbname=...', 'root', '');
    $redis = new \Redis();
    $redis->connect('127.0.0.1', 6379);
    while (true) {
        // 阻塞获取队列数据(超时0秒)
        $data = $redis->blPop(['seckill_queue:1'], 0); 
        if ($data) {
            try {
                $pdo->beginTransaction();
                // 这里需要再次检查数据库库存(最终一致性)
                $sql = "UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0";
                $stmt = $pdo->prepare($sql);
                $stmt->execute([$goodsId]);
                if ($stmt->rowCount() > 0) {
                    // 创建订单
                    $orderSql = "INSERT INTO orders (user_id, goods_id) VALUES (?, ?)";
                    $pdo->prepare($orderSql)->execute([$userId, $goodsId]);
                    $pdo->commit();
                } else {
                    // 数据库扣减失败(理论上Redis已拦截,但这里做兜底)
                    $pdo->rollBack();
                }
            } catch (\Exception $e) {
                $pdo->rollBack();
            }
        }
    }
}

优点: 抗并发能力强,不会阻塞用户请求。 缺点: 需要维护消息队列消费者,架构稍复杂。


使用 Redis 事务 + Lua 脚本(原子性操作)

如果不想引入消息队列,又想保证原子性,可以将“检查库存-扣减库存-创建订单”全部放在一个 Lua 脚本中,由 Redis 单线程执行。

流程:

  1. PHP 调用 eval 执行 Lua 脚本。
  2. Lua 脚本内完成:读取库存 -> 判断 > 0 -> 扣减库存 -> 写入订单数据(Redis或MySQL)。
  3. 返回结果。

Lua 脚本示例:

-- KEYS[1]: 库存键 stock:1
-- KEYS[2]: 订单集合键 orders:1
-- ARGV[1]: 用户ID
-- ARGV[2]: 商品ID
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
    return -1  -- 无库存
end
-- 检查用户是否已经购买(可选)
local userFlag = KEYS[2] .. ":" .. ARGV[1]
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    return -2  -- 重复下单
end
-- 扣减库存
redis.call('decr', KEYS[1])
-- 记录用户ID到集合
redis.call('sadd', KEYS[2], ARGV[1])
-- 这里也可以写入订单(需要转化为字符串)
return 1 -- 成功

PHP 调用:

$script = file_get_contents('seckill.lua'); 
$result = $redis->eval($script, [
    "stock:{$goodsId}", 
    "orders:{$goodsId}", 
    $userId, 
    $goodsId
], 2);  // 2 表示 KEYS 参数个数
if ($result == 1) {
    // 成功
}

优点: 效率极高(单线程无锁),且绝对原子。 缺点: 订单数据存在 Redis 中,最终还是要落盘到 MySQL,如果服务器宕机可能丢数据(可加 AOF 持久化)。


简单项目(低并发)—— 直接使用数据库悲观锁

如果项目并发量不大(日均几万),或者只是开发测试阶段,可以直接用 MySQL 的行锁。

PHP 代码:

<?php
$pdo = new PDO('mysql:host=...;dbname=...', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
    $pdo->beginTransaction();
    // 1. 悲观锁查询(注意:必须针对索引,且 where 精确匹配)
    $sql = "SELECT stock FROM goods WHERE id = ? FOR UPDATE";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([$goodsId]);
    $goods = $stmt->fetch();
    if ($goods['stock'] <= 0) {
        throw new \Exception('库存不足');
    }
    // 2. 扣减库存
    $updateSql = "UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0";
    $updateStmt = $pdo->prepare($updateSql);
    $updateStmt->execute([$goodsId]);
    if ($updateStmt->rowCount() == 0) {
        throw new \Exception('库存更新失败');
    }
    // 3. 创建订单
    $orderSql = "INSERT INTO orders (user_id, goods_id) VALUES (?, ?)";
    $pdo->prepare($orderSql)->execute([$userId, $goodsId]);
    $pdo->commit();
    echo '秒杀成功';
} catch (\Exception $e) {
    $pdo->rollBack();
    echo '失败:' . $e->getMessage();
}

优点: 代码简单,保证数据绝对一致。 缺点: 性能差,高并发下容易死锁和锁等待,用户体验差。


乐观锁(涉及库存更新)

在数据库字段加版本号 version

流程:

-- 1. 查询商品,拿到version
SELECT stock, version FROM goods WHERE id = 1;
-- 2. 更新时检查版本
UPDATE goods SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND stock > 0 AND version = 1;
-- 3. 根据影响行数判断是否成功

PHP 实现:

// 循环尝试(自旋)
$maxRetry = 3;
while ($maxRetry--) {
    $goods = $pdo->query("SELECT stock, version FROM goods WHERE id = {$goodsId}")->fetch();
    if ($goods['stock'] <= 0) break;
    $affected = $pdo->exec("UPDATE goods SET stock = stock - 1, version = version + 1 
                            WHERE id = {$goodsId} AND stock > 0 AND version = {$goods['version']}");
    if ($affected) {
        // 创建订单...
        break;
    }
    // 失败则重试
}

优点: 没有锁等待,性能比悲观锁好。 缺点: 高并发下大量请求会失败重试,CPU 消耗大。


关键优化建议(必看)

  1. 库存预热: 秒杀前把库存从 MySQL 加载到 Redis,不要让请求穿透到数据库。
  2. 防刷(漏斗限流):
    • 限流: 使用 Redis 的 incr + expire 实现 IP 或用户级别的令牌桶,限制请求频率。
    • 验证码: 秒杀前强制输入验证码,或计算滑块验证,拦截机器。
    • 同一用户限制: 使用 Redis Set 或 String 记录已抢购用户(setex user:goods:1 3600 1)。
  3. 静态化: 商品详情页尽量使用静态 HTML,不要请求 PHP 动态生成。
  4. Nginx 限流: 在 Nginx 层直接限制到 PHP 的连接数,防止 PHP 进程被打满。
  • 练手/低并发:方案三(MySQL悲观锁),简单易懂。
  • 生产环境: 强烈推荐方案一(Redis预减+队列),它是目前的主流解法,兼顾性能和安全性。
  • 追求极致性能: 在方案一的基础上,把 Lua 脚本下放到 Redis 做库存扣减。

如果你在你的项目中已经使用了 Laravel 或 ThinkPHP,可以利用其内置的队列系统(Laravel Queue / ThinkPHP Queue)来实现方案一的消费者部分,无需额外搭建 RabbitMQ。

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