刚接手秒杀项目的时候,我看了一眼原来的库存扣减代码,差点没缓过来。就是一个简单的 update 语句,把库存字段减一,没有任何并发保护。结果可想而知,活动上线还没分钟,运营那边就炸了,商品卖出了好几倍库存。后来花了三天时间重写了整个库存扣减逻辑,才把问题彻底按下去。今天这篇不说空话,直接给你看我踩过的坑和最终的完整方案,后端是 ThinkPHP 8,数据库是 MySQL,缓存用的 Redis。
一、最原始的写法:查出来再减
最早的实现大概是这样的:
$product = Product::find($id);
if ($product->stock > 0) {
$product->stock -= 1;
$product->save();
// 创建订单...
}
这段代码在单用户访问时问题不大,但并发一旦上来,两个请求同时读到库存为 1,一个减了,另一个也减了,数据库里库存变成 0,但是两个订单都创建成功。超卖就是从这里开始的。
我当时也上网查资料,很多文章说用事务,改成这样:
Db::transaction(function () use ($productId) {
$product = Product::lock(true)->find($productId);
if ($product->stock > 0) {
$product->stock -= 1;
$product->save();
// 创建订单...
}
});
加了个 lock(true) 悲观锁,确实能拦住并发,让每个请求排队读。但是牺牲了性能,秒杀场景下所有请求都挤在一个行锁上,数据库CPU立刻飙高。而且如果事务里还有外部调用,锁持有时间一长,后面直接排队到超时。这方案只适合低并发的后台系统,不适合秒杀。
二、用 UPDATE 条件判断:一个语句解决
后来我改成了一条 SQL 原子操作:
$affected = Product::where('id', $productId)
->where('stock', '>', 0)
->dec('stock', 1)
->update();
if ($affected) {
// 减库存成功,创建订单
} else {
// 库存不足或商品不存在
}
这个方法干净多了。MySQL 在执行 UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0 时,会锁住那一行,并且条件判断是原子性的。同一时刻只有一个请求能匹配到 stock > 0,其余请求 affected_rows 为 0,直接返回失败。
这个方案在性能上比悲观锁好很多,但它仍然是直接操作 MySQL。如果秒杀瞬间的请求量太大,每秒几万次直接打数据库,MySQL 还是会成为瓶颈。另外,库存操作和后续的下单逻辑不在一个事务里,如果创建订单失败,库存已经扣了,还得想办法回补。所以我的最终方案选择了“Redis 预扣库存 + 异步订单处理”的路子。
三、Redis 扣减:把压力挡在数据库外面
Redis 的 DECR 和 EVAL 是原子操作,非常适合做库存扣减。我的设计思路很简单:
- 活动开始前把商品总库存写入 Redis,比如
SET product:stock:1001 500。 - 用户发起秒杀请求时,先调用 Redis 的 Lua 脚本原子地扣减库存并判断剩余量。
- 扣减成功才允许创建订单,订单创建走后端队列异步处理。
- 如果后续订单创建失败,再回去将 Redis 库存加回来(需要保证幂等)。
先看核心的 Lua 脚本:
$lua = <<<LUA
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1 -- 库存不存在
end
stock = tonumber(stock)
if stock <= 0 then
return 0 -- 已经卖完
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
LUA;
$result = Redis::eval($lua, ['product:stock:' . $productId], 1);
在 ThinkPHP8 中,Redis 门面是默认注册好的。如果你用了 thinkcachedriverRedis 做缓存,那直接 cache('product_stock_' . $id) 也能实现原子操作,但 eval 能自定义业务逻辑,更灵活。上面的脚本里我故意没有用某个键来保存具体剩余库存,而是直接对 key 做 DECR。有人会担心多客户端并发时 DECR 超卖,实际上 DECR 自带原子性,只要库存键的初始值正确,并且我们在脚本里判断了当前值大于 0,是安全的。
但 Lua 脚本里判断和 DECR 之间没有别的客户端能插入操作,因为 Redis 是单线程模型,脚本执行期间所有命令都会被阻塞。所以这个方案是绝对线程安全的。
四、完整流程:从商品详情到秒杀成功
我写了一个相对简洁但能跑通的秒杀接口,代码放在一个控制器里方便你看整体流程:
<?php
namespace appcontroller;
use thinkfacadeDb;
use thinkfacadeRedis;
use thinkResponse;
use appmodelOrder;
use appmodelProduct;
class Seckill
{
// 秒杀入口
public function index()
{
$productId = request()->param('product_id');
$userId = request()->param('user_id');
$skuId = request()->param('sku_id', 0); // 如果有规格
if (!$productId || !$userId) {
return json(['code' => 1, 'msg' => '参数错误']);
}
// 1. 检查用户是否已经买过(防止重复下单)
$orderExists = Db::name('order')
->where('user_id', $userId)
->where('product_id', $productId)
->find();
if ($orderExists) {
return json(['code' => 2, 'msg' => '你已参与过该活动']);
}
// 2. Redis 原子扣减库存
$lua = <<<LUA
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
stock = tonumber(stock)
if stock <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return 1
LUA;
$stockKey = 'seckill:stock:' . $productId;
$ok = Redis::eval($lua, [$stockKey], 1);
if ($ok === -1) {
return json(['code' => 3, 'msg' => '商品未初始化库存']);
}
if ($ok === 0) {
return json(['code' => 4, 'msg' => '已经抢光了']);
}
// 3. 扣减 MySQL 真实库存(这里直接用乐观锁或者条件更新,防止Redis数据与DB不一致)
// 我这里选择使用 where + dec 条件更新
$affected = Db::name('product')
->where('id', $productId)
->where('stock', '>', 0)
->dec('stock', 1)
->update();
if (!$affected) {
// 数据库库存不足,回补Redis
Redis::incr($stockKey);
return json(['code' => 5, 'msg' => '数据库库存同步失败']);
}
// 4. 创建订单(这里简化,实际用队列异步处理)
try {
$orderNo = date('YmdHis') . mt_rand(1000, 9999);
Order::create([
'order_no' => $orderNo,
'user_id' => $userId,
'product_id' => $productId,
'status' => 0,
'created_at' => time(),
]);
} catch (Exception $e) {
// 订单失败,回滚Redis库存,记录日志
Redis::incr($stockKey);
return json(['code' => 6, 'msg' => '订单创建失败,请重试']);
}
return json(['code' => 0, 'msg' => '秒杀成功']);
}
}
这个流程已经可以应付大多数秒杀场景。我特意在 Redis 扣减之后又去更新数据库库存,双保险。为什么不在 Redis 扣完就直接返回?因为后续创建订单可能失败,如果不更新数据库,库存账实不符。如果订单成功,数据库库存减一,Redis 减一,两边一致。如果订单失败,Redis 加回来,数据库没动,等价于没发生过。
五、压测结果让人意外
我用了 PHP 内置的 workerman 压测工具,模拟 2000 个并发请求,商品库存设置 1000。原来的查改方案,数据库直接超卖,订单生成了 2000 多个。现在的 Redis 方案,成功创建的订单恰好 1000 个,多出来的请求全部返回“已抢光”。数据库库存最终为 0,Redis 库存也变为 0,两端数据完全一致。
压测时 Watch 内存和 CPU,Redis 的 CPU 占用率不高,MySQL 几乎没有压力。因为所有线程安全判断都在 Redis 完成了,数据库只是执行一条条件更新,绝大多数请求在 Redis 层就挡回去了。
六、几个容易被忽视的坑
虽然方案写完了,但实际落地时还有不少细节不处理会出大事。
1. 库存键过期问题
活动结束后,我一开始忘了给 Redis 库存键设置过期时间,导致这个 key 一直占用内存。后来在活动初始化时,设置了一个 24 小时的过期。但要注意:如果 key 过期后还有请求进来,Lua 脚本会返回 -1,然后提示“商品未初始化”,这没问题。可在活动未结束但 key 因为网络闪断丢失了,就会误判。我的解决方式是在秒杀接口里加一层兜底:如果 Redis 返回 -1,再查数据库中的库存并重新填充 Redis。
2. 数据库更新失败要补回
上面代码已经有补回操作,但要注意补回的原子性。如果 Redis 的 incr 也失败了,那就麻烦了。实际项目里我会用日志记录,并且定期跑对账脚本,把 Redis 库存和数据库库存做比较,不一致就修正。
3. 用户重复点击问题
我在代码里查了订单表判断重复,但高并发下这个查询也是不安全的。更好的办法是在 Redis 里放一个用户去重集合,比如 SADD seckill:buyers:1001 userId,返回值不为 0 就代表已抢过。这个判断可以合在 Lua 脚本里一起完成,避免多次请求同时创建订单。不过我这套改造已经在订单表加了联合唯一索引,也能挡住大多数重复,但性能上还是 Redis 老练。
4. 事务边界要清晰
如果你在创建订单的同时还要扣减其他库存,比如多商品组合下单,上面这个简单方案就不够了。建议先把 Redis 扣减一次性做完,然后 MySQL 事务统一处理订单和扣减。但 Redis 的操作无法回滚,只能做补偿。所以更稳妥的做法是:先本地消息表记录扣减动作,异步处理订单,失败后执行补偿。
七、能直接用吗?
上面这段代码是一个通用骨架,你直接复制到 ThinkPHP8 项目中,改一下模型名和字段就能跑起来。但是真实业务中肯定还有限购、价格变动、活动时间等逻辑,需要你在 Redis 扣减之前再加判断。而且我建议把 Redis 的 key 设计成可以批量管理的结构,比如 seckill:info:活动ID 和 seckill:stock:活动ID 分开,方便定时脚本统一处理。
关键在于懂了原理,后面自己改就顺手了。
八、反思与总结
经过这次重构,我最大的感受是:别把 MySQL 当水缸,能挡在前面的尽量用缓存挡。数据库只做最终一致性的持久化。
方案简单粗暴,但很实用。没有引入消息队列,没有分布式锁,全靠 Redis 单线程原子特性就解决了高并发秒杀超卖。如果你在 ThinkPHP 项目里也遇到类似问题,可以参考这个思路。以后如果再遇到库存扣减,至少你不会再被超卖问题追着跑了。
当然,这只是我自己的实践经验,不一定是最优方案。如果你有更好的办法,欢迎讨论。

