本文目录导读:

- 方案一:数据库乐观锁(推荐,最稳妥)
- 方案二:Redis 原子递减(性能最强,最常用)
- 方案三:数据库悲观锁(
FOR UPDATE)(简单,适合中小型项目) - 方案四:Redis + Lua 脚本(最终推荐方案)
- 关键注意事项(避免踩坑)
PHP 秒杀防止超卖的核心在于保证库存扣减的原子性,避免在高并发下多个请求同时读到相同的库存并成功扣减。
下面按从简单到复杂、从数据库到缓存的顺序,列出几种最有效的解决方案,并附带关键代码示例。
数据库乐观锁(推荐,最稳妥)
原理:在更新库存时,不仅检查 库存 > 0,还检查 版本号 或 当前库存 是否等于预期值,如果条件不满足,更新影响行数为0,则说明库存已被抢走。
核心SQL(关键):
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0 -- 防止超卖的第一道保险 AND version = ?; -- 乐观锁,防止并发覆盖
PHP + PDO 代码示例:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$productId = 1;
$userId = 100;
try {
$pdo->beginTransaction();
// 1. 读取当前版本号(模拟乐观锁)
$stmt = $pdo->prepare("SELECT stock, version FROM products WHERE id = ? FOR UPDATE");
$stmt->execute([$productId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && $row['stock'] > 0) {
// 2. 执行扣减(带上版本号条件)
$update = $pdo->prepare("UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ? AND stock > 0");
$update->execute([$productId, $row['version']]);
// 影响行数为1表示成功
if ($update->rowCount() === 1) {
// 3. 记录订单(业务逻辑)
$pdo->prepare("INSERT INTO orders (user_id, product_id) VALUES (?, ?)")->execute([$userId, $productId]);
$pdo->commit();
echo "秒杀成功!";
} else {
$pdo->rollBack();
echo "库存不足或已被抢光!";
}
} else {
$pdo->rollBack();
echo "商品不存在或库存为空!";
}
} catch (Exception $e) {
$pdo->rollBack();
echo "系统错误:" . $e->getMessage();
}
?>
优点:绝对保证一致性,无需额外组件。 缺点:数据库压力大,不适合极大规模并发(如双十一)。
Redis 原子递减(性能最强,最常用)
原理:利用 Redis 单线程的特性,DECR 或 INCR 操作天然是原子的。
流程:秒杀前将库存预先加载到 Redis,用户请求时先执行 DECR,如果返回值大于等于0则抢购成功,否则失败并恢复库存。
Redis 核心操作:
# 预加载库存 SET stock:product:1 100 # 扣减库存(原子) DECR stock:product:1
PHP 代码示例(使用 Redis + Lua 脚本保证原子性):
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$productId = 1;
$userId = 100;
// 预先设置库存,例如100个
$redis->set('stock:product:' . $productId, 100);
// Lua 脚本:保证检查库存和扣减库存是原子操作
$luaScript = <<<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;
// 使用 eval 执行
$result = $redis->eval($luaScript, ['stock:product:' . $productId], 1);
if ($result === 1) {
// 异步写入数据库或消息队列生成订单
echo "秒杀成功,库存剩余:" . $redis->get('stock:product:' . $productId);
} else {
echo "秒杀失败,库存已空";
}
?>
优点:每秒可处理十几万次请求,性能极高。
缺点:需要维护 Redis 与数据库的最终一致性(需结合消息队列或定时任务同步)。
数据库悲观锁(FOR UPDATE)(简单,适合中小型项目)
原理:在读取库存时直接对记录行加锁,直到事务提交才释放,期间其他事务无法修改该行。
PHP 代码:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$pdo->beginTransaction();
$productId = 1;
$userId = 100;
try {
// 锁定该行,直到事务结束
$stmt = $pdo->prepare("SELECT stock FROM products WHERE id = ? FOR UPDATE");
$stmt->execute([$productId]);
$row = $stmt->fetch();
if ($row && $row['stock'] > 0) {
// 扣减库存
$pdo->prepare("UPDATE products SET stock = stock - 1 WHERE id = ?")->execute([$productId]);
// 创建订单
$pdo->prepare("INSERT INTO orders ...")->execute([...]);
$pdo->commit();
echo "成功";
} else {
$pdo->rollBack();
echo "没货了";
}
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
?>
注意:必须使用存储引擎 InnoDB(MySQL默认)才支持行锁,此方案在高并发下会产生大量阻塞等待,性能低于乐观锁。
Redis + Lua 脚本(最终推荐方案)
如果要求既要高性能又要绝对不超卖,将 Redis递减 与 数据库异步扣库存 结合是行业标准做法。
完整流程:
- 预加载库存:秒杀开始前,将库存写入 Redis。
- 抢购命中:使用 Lua 脚本原子扣减 Redis 库存。
- 异步写库:扣减成功后,发送消息到 RabbitMQ/Kafka,或直接写入一个
订单队列表。 - 后台任务同步:消费队列,将库存更新到数据库,并生成真实订单。
关键注意事项(避免踩坑)
| 坑点 | 解决方案 |
|---|---|
| 数据库字段类型 | 库存字段建议用 INT UNSIGNED 且默认值0,防止出现负数。 |
使用行锁(FOR UPDATE)时 |
必须确保扫描索引,否则会锁全表(WHERE id=? 必须有索引)。 |
| Redis 与 DB 一致性问题 | 秒杀成功后,无需立即同步数据库,可延迟同步;失败则定时回滚 Redis 库存(加回)。 |
| 接口幂等性 | 同一个用户重复点击,需要在 Redis 用 SETNX 记录用户已购买,防止重复下单。 |
最终建议:
- 中小规模(日活 < 10万):直接使用 乐观锁(方案一),简单可靠。
- 大规模(双十一级别):使用 Redis + Lua(方案二/四),但需要额外开发数据同步逻辑。
确保库存不超卖的原理都是利用单点原子操作(数据库行锁/Redis单线程)来防止多个请求同时通过“库存检查”这一关。