在PHP项目中防止商品超卖(即库存扣减为负数),核心在于保证库存操作的原子性,绝不能使用“先查询再计算再更新”的模式(存在并发问题)。

以下是几种常见的、从简单到复杂的解决方案:
数据库乐观锁(适合低并发)
利用数据库的 UPDATE 语句中的条件判断,确保只在库存足够时扣减。
原理:UPDATE 语句是原子操作,加上 WHERE stock > 0 条件。
SQL 示例:
// 假设订单需要扣减 1 件商品
$sql = "UPDATE products
SET stock = stock - 1
WHERE product_id = :product_id AND stock >= 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([':product_id' => $productId]);
$affectedRows = $stmt->rowCount();
if ($affectedRows === 0) {
// 库存不足,秒杀失败
echo "库存不足";
} else {
// 扣减成功,继续后续操作
}
优点:简单,无外部依赖,适合绝大多数常规业务。
缺点:在高并发下,数据库行锁竞争激烈(行锁),虽然不会超卖,但性能会下降,且 stock 字段频繁更新,影响缓存失效。
Redis 原子递减(适合高并发秒杀)
利用 Redis 的 DECR 或 DECRBY 命令(单线程天然原子)。
原理:将库存预热到 Redis,每次请求执行 DECR,如果返回负数则表示超卖。
代码示例:
// 1. 初始化(仅在项目启动时执行一次)
$redis->set('stock:product_123', 100);
// 2. 扣减库存(原子操作)
$currentStock = $redis->decr('stock:product_123'); // 自减1,返回剩余库存
if ($currentStock < 0) {
// 超卖了,需要回滚(补偿)
$redis->incr('stock:product_123'); // 紧要:将库存加回来
echo "库存不足";
} else {
// 扣减成功,可以将订单信息放入消息队列,异步落库
// ... 后续逻辑,如写入 MySQL 订单表
echo "抢购成功,剩余库存:" . $currentStock;
}
关键注意事项:
- 回滚补偿:
DECR返回负数,要立即INCR加回来,防止库存变成负数。 - Lua 脚本:推荐使用 Lua 脚本将“扣减+判断”合并为一个原子操作,避免网络问题导致补偿失败。
-- Lua 脚本 local stock = redis.call('get', KEYS[1]) if stock and tonumber(stock) >= tonumber(ARGV[1]) then redis.call('decrby', KEYS[1], ARGV[1]) return 1 else return 0 end - 最终一致性:Redis 扣减成功后,订单数据需要通过消息队列异步写入 MySQL(防刷单、数据持久化),Redis 库存不等于实际数据库库存。
MySQL 悲观锁(SELECT ... FOR UPDATE)(不推荐高并发)
原理:在事务内锁定行,其他请求必须等待。
代码示例:
$pdo->beginTransaction();
try {
// 1. 锁住该行(注意:必须加索引,否则会锁表)
$sql = "SELECT stock FROM products WHERE product_id = ? FOR UPDATE";
$stmt = $pdo->prepare($sql);
$stmt->execute([$productId]);
$row = $stmt->fetch();
if ($row['stock'] <= 0) {
throw new \Exception("库存不足");
}
// 2. 扣减库存(当前独占锁)
$updateSql = "UPDATE products SET stock = stock - 1 WHERE product_id = ?";
$pdo->prepare($updateSql)->execute([$productId]);
// 3. 插入订单...
$pdo->commit();
} catch (\Exception $e) {
$pdo->rollBack();
echo "失败:" . $e->getMessage();
}
优点:绝对不超卖,简单。 缺点:吞吐量极低,容易导致数据库连接池耗尽,不建议在高并发秒杀中使用。
分布式锁(适合多个 PHP 进程/服务器)
使用 Redis 的 SET NX EX 或 Redlock 算法。
原理:用户抢购前,先尝试获取锁(key = product_lock:{id}),拿到锁的用户才能操作库存,操作完释放锁。
代码示例:
$lockKey = "product_lock:{$productId}";
$lockValue = uniqid('', true); // 唯一值,用于安全释放锁
// 获取锁(等待 3 秒,锁过期时间 5 秒)
if ($redis->set($lockKey, $lockValue, ['NX', 'EX' => 5])) {
try {
// 1. 查询库存(MySQL)
$stock = getStockFromDB($productId);
if ($stock <= 0) {
throw new \RuntimeException("已售罄");
}
// 2. 扣减库存(MySQL)
updateStockInDB($productId, $stock - 1);
// 3. 生成订单...
} finally {
// 安全释放锁(使用 Lua 脚本确保只有自己释放自己的锁)
$script = '
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
';
$redis->eval($script, [$lockKey, $lockValue], 1);
}
} else {
echo "系统繁忙,请重试";
}
优点:解决了分布式系统一致性问题。 缺点:存在锁冲突损耗,且需要处理锁的超时/续期(避免死锁)。
最佳实践推荐
对于大多数 PHP 项目,推荐组合使用“方案一 + 方案二”:
- 前端防护:按钮置灰、限制请求频率(防止恶意脚本)。
- Redis 校验层:使用 Redis DECR + Lua 脚本进行并发拦截,这是流量入口,快速过滤无效请求。
- MySQL 兜底层:异步写入订单时,再次使用
UPDATE ... WHERE stock >= 1进行最终校检(防止 Redis 宕机导致数据不一致)。 - 流量削峰:用户点击后不直接返回结果,而是返回“排队中”,后台通过消息队列(RabbitMQ/Redis List)异步处理订单落库。
原子性是核心,永远不要分两步操作。UPDATE ... WHERE stock >= N 是成本最低且最可靠的保底方案。