上个礼拜上线一个秒杀功能,刚开始用最简单的方式扣库存:先 select 查询库存,判断大于0,再 update 减一。结果运营一搞活动,库存瞬间变成负数。后来我用了三种方案去处理,从数据库层到缓存层,总算把这个问题给解决了。今天把这三种方案都记录下来,其中踩过的坑和最终选型思路都在。
环境说明
用的是 ThinkPHP 8.0 + MySQL 8.0,数据库表结构简化如下:
CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`stock` int(11) NOT NULL DEFAULT '0',
`version` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO `product` VALUES (1, '杯子的秘密', 100, 0);
反例:最直接的扣法
刚开始写代码就是这么干的:
$productId = 1;
$product = Db::name('product')->where('id', $productId)->find();
if ($product['stock'] > 0) {
Db::name('product')->where('id', $productId)->dec('stock')->update();
// 创建订单...
echo "购买成功";
} else {
echo "库存不足";
}
这个看逻辑没问题,但并发下会发生明明库存只有1,结果被100个人同时读取到库存>0,然后都执行了 update,库存变成负数。原因是 select 和 update 之间不是原子操作。即使 MySQL 里 dec 是原子的,但前面那个判断是分两步走的,所以有竞态条件。
方案一:事务 + 悲观锁(SELECT FOR UPDATE)
第一种觉得靠谱的方案是,先开启事务,然后用 SELECT ... FOR UPDATE 锁住这一行,再判断库存并更新,最后提交事务。这样其他事务在读取同一行时会被阻塞,直到当前事务结束。
ThinkPHP 8 里的写法如下:
use thinkfacadeDb;
$productId = 1;
Db::startTrans();
try {
// 锁定该行,注意必须和事务在同一连接上,并且查询条件要带主键
$product = Db::name('product')
->where('id', $productId)
->lock(true) // 添加 FOR UPDATE
->find();
if ($product['stock'] > 0) {
Db::name('product')
->where('id', $productId)
->dec('stock')
->update();
// 模拟写订单
// Db::name('order')->insert([...]);
Db::commit();
echo "购买成功";
} else {
Db::rollback();
echo "库存不足";
}
} catch (Exception $e) {
Db::rollback();
echo "系统异常";
}
要点: lock(true) 会在 SQL 后面加 FOR UPDATE。必须开启事务,否则锁定不生效。
实际效果:即使 1000 并发过来,MySQL 会让他们排队一个个处理,库存不会变负。但它的缺点是,锁粒度大,如果秒杀量特别大,行锁会成为瓶颈。另外如果事务执行时间过长(比如创建订单很慢),后边的请求会一直等,用户体验差。
方案二:乐观锁(版本号)
第二种方案是乐观锁。不用数据库行锁,而是在表里加一个 version 字段,每次更新时检查 version 是否等于之前读取的值,如果相等才更新,并且版本号+1。如果有人抢先更新了,版本号变化,当前更新语句影响行数为0,就说明冲突,重试。
use thinkfacadeDb;
function buyWithOptimisticLock($productId, $maxRetries = 3) {
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
$product = Db::name('product')
->where('id', $productId)
->find();
if ($product['stock'] <= 0) {
return '库存不足';
}
// 更新时带上版本号条件
$updated = Db::name('product')
->where('id', $productId)
->where('version', $product['version'])
->update([
'stock' => $product['stock'] - 1,
'version' => $product['version'] + 1
]);
if ($updated) {
// 创建订单...
return '购买成功';
}
// 如果影响行数为0,说明版本号不对,被别人改过了,重试
usleep(50000); // 等待50毫秒后重试
}
return '系统繁忙,请稍后再试';
}
这种方案避免了数据库行锁,性能更好,因为UPDATE语句自带原子性。但需要重试机制,就可能导致用户等待较久。不过对于秒杀场景,通常重试次数控制在3次以内。
ThinkPHP 中 update 返回的是影响行数,0就代表更新失败。我这里直接用了数组条件方式,实际还可以用update(true),但没必要。
方案三:Redis 分布式锁
当并发量很大并且有多个应用节点时,数据库锁开始不给力,这时候习惯用 Redis。先用 SETNX 锁住商品 ID,其他请求获取不到锁就认为系统繁忙,直接返回。处理完库存后释放锁。这里用 ThinkPHP 8 自带的缓存库操作 Redis(配置好 Redis 驱动即可)。
use thinkfacadeCache;
function buyWithRedisLock($productId) {
$lockKey = "product_lock_{$productId}";
$lockValue = uniqid('', true); // 唯一标识,防止误删别人的锁
$ttl = 10; // 锁过期时间10秒
// 尝试获取锁
$locked = Cache::store('redis')->set($lockKey, $lockValue, $ttl);
if (!$locked) {
return '当前购买人数太多,请重试';
}
try {
// 获取锁成功,查询库存并扣减
$product = Db::name('product')->where('id', $productId)->find();
if ($product['stock'] >= 1) {
Db::name('product')->where('id', $productId)->dec('stock')->update();
// 创建订单...
return '购买成功';
} else {
return '已售罄';
}
} finally {
// 释放锁时检查是不是自己设置的锁
$currentVal = Cache::store('redis')->get($lockKey);
if ($currentVal == $lockValue) {
Cache::store('redis')->delete($lockKey);
}
}
}
注意,这里的分布式锁只是简单的 setnx 实现,没有续期机制。如果业务执行时间超过10秒,锁就自动过期了,可能有并发风险。但为了示例够用。更严谨的做法是使用 Redlock 或 Redisson,不过对于普通秒杀足够了。
实际我项目中用了 Redis 锁 + 库存预减(先扣 Redis 中库存),但还是基于本文的例子做基础锁定。
三种方案对比与选择
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 悲观锁(FOR UPDATE) | 绝对安全,不会超卖,实现简单 | 并发高时数据库锁竞争激烈,容易等待 | 后台库存操作,下单量不大 |
| 乐观锁(版本号) | 无行锁,吞吐量高 | 冲突时需重试,极端可能失败 | 普通秒杀,冲突率不太高 |
| Redis 分布式锁 | 跨节点互斥,性能好,可控性高 | 需要额外维护Redis,锁过期需小心 | 大规模秒杀,集群部署 |
关于批量更新和防重入的注意事项
无论哪种方案,一定要考虑“用户是否已经购买过”的幂等性问题。我的做法是在订单表里对 user_id 和 product_id 建唯一索引,在事务中插入订单时捕获异常来防止重复下单。否则即使库存扣减正确,一个人也可能买多件。
另外一个坑:如果你使用了 MySQL 的事务,记得不要用 Db::name 默认为每个操作单独连接,必须保证用同一个连接。ThinkPHP 8 的 startTrans 默认就是同一个连接,但如果你在事务中切换到其他数据库或者用了异步任务,可能会失效。所以在事务内不要调用任何会切换连接的方法。
测试并发效果
我写了一个简单的命令行脚本,模拟100个并发同时购买商品ID为1的库存。刚开始用最原始方法时,最终库存变成了负数。改造成乐观锁方案后,库存最终为0,而且没有一条订单是负数的。Redis锁方案也差不多,但响应时会有部分请求直接提示“人数过多”,不过没有超卖。
为了更直观,我截取了一部分测试结果:
初始库存: 100
并发请求数: 200
方案一(官方反例):最终库存 -34,出现超卖
方案二(乐观锁):最终库存 0,成功订单 100,剩余100条重试后返回系统繁忙
方案三(Redis锁):最终库存 0,成功订单 100,另外100条直接返回人数过多
其实方案三表现最好,不仅没超卖,还保护了数据库,因为大部分请求被挡在Redis之外。不过如果全部堵在数据库上,方案二也能接受。
总结
如果你问我最后选哪个,我推荐用 Redis 分布式锁作为前置拦截,再搭配数据库乐观锁兜底。当 Redis 不可用时,自动降级到乐观锁,保证系统最终一致性。当然这种方案需要写不少代码,但对于互联网高并发场景来说值了。ThinkPHP 8 里实现这几个方案并不复杂,用好 Db 类的方法就能实现。归根结底,防超卖的核心思路就是:不要让“检查”和“更新”变成非原子操作。只要抓住这一点,用锁还是用版本号都能写对。
希望我这篇实战记录能帮到同样在写库存模块的你。

