本文目录导读:

在 PHP 高并发秒杀场景下,防超卖(库存不能变负数)的核心在于保证扣减库存操作的原子性。
如果处理不当,传统的 SELECT 再 UPDATE 逻辑在并发下极易超卖。
以下是几种由简到繁、适用于不同场景的 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");
}
解决方案一定要围绕“原子操作”展开。