本文目录导读:

在 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");
}
解决方案一定要围绕“原子操作”展开。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播