本文目录导读:

- 方案一:基于 Redis 预减库存 + 队列异步处理(推荐,生产级)
- 方案二:使用 Redis 事务 + Lua 脚本(原子性操作)
- 方案三:简单项目(低并发)—— 直接使用数据库悲观锁
- 方案四:乐观锁(涉及库存更新)
- 关键优化建议(必看)
实现PHP商品秒杀功能是一个经典的高并发场景挑战,核心难点在于防止超卖(卖出的商品数量超过库存)和解决高并发下的性能瓶颈。
由于PHP本身是“请求-响应”模型,且原生不支持常驻内存的锁,因此单纯依赖PHP代码逻辑很难处理高并发,通常需要结合中间件(如 Redis)和异步处理(如消息队列)来实现。
以下是基于你项目具体情况(从简单到可靠)的几种实现方案:
基于 Redis 预减库存 + 队列异步处理(推荐,生产级)
这是目前中大型项目最常用的方案,将库存存放在 Redis 中,利用其单线程特性(原子操作)来防止超卖。
核心流程:
- 初始化: 秒杀开始前,将数据库库存同步到 Redis。
- 请求拦截(Redis 预减): 用户请求到来,PHP 通过
redis->decr('stock:goods_id')原子减1。- 如果返回值
< 0,说明无库存,直接返回“秒杀结束”。
- 如果返回值
- 队列写入: 减库存成功的用户,将其用户信息和商品ID写入消息队列(如 RabbitMQ、Kafka 或 Redis List)。
- 异步处理(消费者): 创建一个常驻脚本(可以在PHP中运行,也可以是用Go等语言),从队列中拉取数据,然后同步到数据库(写入订单、扣减库存)。
- 返回结果: 立即返回“正在抢购中...”,用户可以通过轮询或 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 单线程执行。
流程:
- PHP 调用
eval执行 Lua 脚本。 - Lua 脚本内完成:读取库存 -> 判断 > 0 -> 扣减库存 -> 写入订单数据(Redis或MySQL)。
- 返回结果。
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 消耗大。
关键优化建议(必看)
- 库存预热: 秒杀前把库存从 MySQL 加载到 Redis,不要让请求穿透到数据库。
- 防刷(漏斗限流):
- 限流: 使用 Redis 的
incr+expire实现 IP 或用户级别的令牌桶,限制请求频率。 - 验证码: 秒杀前强制输入验证码,或计算滑块验证,拦截机器。
- 同一用户限制: 使用 Redis Set 或 String 记录已抢购用户(
setex user:goods:1 3600 1)。
- 限流: 使用 Redis 的
- 静态化: 商品详情页尽量使用静态 HTML,不要请求 PHP 动态生成。
- Nginx 限流: 在 Nginx 层直接限制到 PHP 的连接数,防止 PHP 进程被打满。
- 练手/低并发: 用方案三(MySQL悲观锁),简单易懂。
- 生产环境: 强烈推荐方案一(Redis预减+队列),它是目前的主流解法,兼顾性能和安全性。
- 追求极致性能: 在方案一的基础上,把 Lua 脚本下放到 Redis 做库存扣减。
如果你在你的项目中已经使用了 Laravel 或 ThinkPHP,可以利用其内置的队列系统(Laravel Queue / ThinkPHP Queue)来实现方案一的消费者部分,无需额外搭建 RabbitMQ。