秒杀这块东西被讲烂了,但真正落到 ThinkPHP 项目里,很多人写的还是「先查库存,再减库存」那一套。平时没事,一到大促就翻车。
这篇文章不走那种一上来就画架构图的路线,直接从一段会超卖的代码开始,一步步把它改成能扛住压力的样子。用到的技术栈是 ThinkPHP 8 + Redis + Redis 驱动的队列,代码都能直接跑。
先看那段会超卖的代码
很多人第一次写秒杀,写出来是这样:
<?php
namespace appcontroller;
use appmodelGoods;
use thinkfacadeDb;
class Seckill
{
public function order(int $goodsId)
{
$goods = Goods::find($goodsId);
if ($goods->stock <= 0) {
return json(['code' => 1, 'msg' => '库存不足']);
}
$goods->stock -= 1;
$goods->save();
Db::name('order')->insert([
'goods_id' => $goodsId,
'user_id' => 1,
'created_at' => time(),
]);
return json(['code' => 0, 'msg' => '下单成功']);
}
}
问题在哪儿?两处。
第一,find 之后到 save 之间是个空窗期。两个请求同时读到的库存都是 1,然后各自减 1 各自保存,最终数据库里库存变成 -1,却卖出去两件。这就是经典超卖。
第二,整个链路串行执行,一次请求要读两次数据库、写两次数据库。Redis 一秒能抗十万 QPS,MySQL 撑死几千,瓶颈全在数据库上。就算是库存充足的商品,几百个人同时点也够卡几秒的。
要解决这两个问题,方向很明确:库存操作从数据库挪到 Redis,重复购买判断也挪过去,并且整个过程必须是原子的。落库的部分改成异步。
表设计:一条不能省
先把表结构准备好。库存放 Redis 但不代表数据库那边就不用了,它得留着做对账兜底。
CREATE TABLE `goods` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`price` int unsigned NOT NULL COMMENT '单位分',
`stock` int NOT NULL COMMENT '数据库侧真实库存',
`version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (`id`)
);
CREATE TABLE `seckill_order` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`goods_id` int unsigned NOT NULL,
`user_id` int unsigned NOT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
`created_at` int unsigned NOT NULL,
`updated_at` int unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_goods_user` (`goods_id`, `user_id`)
);
uniq_goods_user 这个唯一索引很重要,是最后一道防线。就算 Redis 那边判重失手了,数据库这层也不会让同一个人对同一个商品下两单。数据库是唯一可信的那道门,Redis 只是前置的性能手段,这个定位不能反了。
Redis 库存预热
秒杀开始前,把库存从数据库搬到 Redis,同时清掉上一次遗留的购买记录。
<?php
namespace appservice;
use thinkfacadeCache;
use thinkfacadeDb;
class StockWarmup
{
public static function run(int $goodsId): void
{
$goods = Db::name('goods')->find($goodsId);
if (!$goods) {
throw new RuntimeException('商品不存在');
}
$redis = Cache::store('redis')->handler();
// SET 会在 Redis 上一键覆盖,用 pipeline 减少网络往返
$pipe = $redis->pipeline();
$pipe->set(StockService::stockKey($goodsId), (int) $goods['stock']);
$pipe->del(StockService::buyerKey($goodsId));
$pipe->exec();
}
}
没有用 INCR 去叠加,而是直接 SET 覆盖。原因很简单:预热这东西可能被误跑两次,用 INCR 那两次跑完库存就翻倍了。直接覆盖更安全,反正数据源始终是数据库。
核心就是这一段 Lua
把「判断有没有买过」「判断库存够不够」「扣库存」「记录买家」这四步捏成一个原子操作。分开写就会出问题:两个请求同时通过了库存检查,才轮到扣减,一样超卖。
<?php
namespace appservice;
use thinkfacadeCache;
class StockService
{
private const PREFIX_STOCK = 'seckill:stock:';
private const PREFIX_BUYER = 'seckill:buyer:';
public static function stockKey(int $goodsId): string
{
return self::PREFIX_STOCK . $goodsId;
}
public static function buyerKey(int $goodsId): string
{
return self::PREFIX_BUYER . $goodsId;
}
/**
* @return int 1成功 0库存不足 -1重复购买
*/
public static function tryDeduct(int $goodsId, int $userId, int $num = 1): int
{
$script = <<<'LUA'
local stockKey = KEYS[1]
local buyerKey = KEYS[2]
local userId = ARGV[1]
local num = tonumber(ARGV[2])
if redis.call('SISMEMBER', buyerKey, userId) == 1 then
return -1
end
local stock = tonumber(redis.call('GET', stockKey) or '-1')
if stock < num then
return 0
end
redis.call('DECRBY', stockKey, num)
redis.call('SADD', buyerKey, userId)
return 1
LUA;
$redis = Cache::store('redis')->handler();
$result = $redis->eval(
$script,
[
self::stockKey($goodsId),
self::buyerKey($goodsId),
(string) $userId,
(string) $num,
],
2 // 前两个参数是 KEYS,后面的都是 ARGV
);
return (int) $result;
}
public static function rollback(int $goodsId, int $userId, int $num = 1): void
{
$redis = Cache::store('redis')->handler();
$redis->pipeline()
->incrBy(self::stockKey($goodsId), $num)
->sRem(self::buyerKey($goodsId), (string) $userId)
->exec();
}
}
eval 的第三个参数 2 经常会漏。它的含义是「前面两个参数是 KEYS,后面剩下的都是 ARGV」。漏了这个参数,PHP 的 Redis 扩展会把所有参数都当成 KEYS,脚本读不到 userId,直接返回 0 库存不足,你会一脸懵逼地以为是自己库存没预热上。
另外脚本里返回 -1 表示重复购买,这个语义是自定义的,你也可以用别的数字,只要和调用方约定一致就行。
控制器里怎么用
<?php
namespace appcontroller;
use appjobCreateOrderJob;
use appserviceStockService;
use thinkfacadeQueue;
use thinkResponse;
class Seckill
{
public function order(int $goodsId): Response
{
$userId = (int) request()->userId;
$code = StockService::tryDeduct($goodsId, $userId, 1);
if ($code === -1) {
return json(['code' => 1, 'msg' => '你已经抢过了']);
}
if ($code === 0) {
return json(['code' => 1, 'msg' => '被抢光了']);
}
// 扔进队列,让后台慢慢落库
Queue::push(CreateOrderJob::class, [
'goods_id' => $goodsId,
'user_id' => $userId,
], 'seckill');
return json(['code' => 0, 'msg' => '抢到了,正在生成订单']);
}
}
返回给用户的消息是「正在生成订单」而不是「下单成功」。这是刻意的。因为队列是异步的,此刻数据库里还没有这条订单记录。用户刷新订单列表可能看不到。用「正在生成」这种说法更诚实,页面上配一个轮询订单状态的小逻辑就行。
队列任务:把数据库的东西落地
队列消费者负责把 Redis 里已经确认扣减的名额,变成数据库里真实的一行。
<?php
namespace appjob;
use appserviceStockService;
use thinkfacadeDb;
use thinkqueueJob;
class CreateOrderJob
{
public function fire(Job $job, array $data): void
{
$goodsId = (int) $data['goods_id'];
$userId = (int) $data['user_id'];
try {
Db::transaction(function () use ($goodsId, $userId) {
// 数据库库存再用乐观锁扣一次,双保险
$affected = Db::name('goods')
->where('id', $goodsId)
->where('stock', '>', 0)
->whereRaw('version = version')
->update([
'stock' => Db::raw('stock - 1'),
'version' => Db::raw('version + 1'),
]);
if ($affected === 0) {
throw new RuntimeException('数据库库存已空');
}
Db::name('seckill_order')->insert([
'goods_id' => $goodsId,
'user_id' => $userId,
'status' => 0,
'created_at' => time(),
'updated_at' => time(),
]);
});
$job->delete();
} catch (Throwable $e) {
// 数据库这边失败,把 Redis 那边预扣的还回去
StockService::rollback($goodsId, $userId, 1);
// 唯一索引冲突说明已经生成过了,直接确认,不用重试
if (str_contains($e->getMessage(), 'Duplicate entry')) {
$job->delete();
return;
}
if ($job->attempts() > 3) {
// 超过三次就放弃,记个日志人工处理
trace('秒杀落库失败: ' . $e->getMessage(), 'error');
$job->delete();
return;
}
// 抛出去让队列自己重试
throw $e;
}
}
}
数据库这层为什么还要再扣一次?因为 Redis 是内存,重启、主从切换、脚本误删都可能丢数据。数据库这一层是兜底的账本,它上面必须也是原子的。用 stock > 0 作为更新的条件,影响行数为 0 就说明真的没货了。
失败的时候一定要把 Redis 的库存还回去。这个操作叫「补偿」,是整条链路能自愈的关键。否则用户看着抢到了,最后订单没生成,钱也没付,客服电话直接打爆。
超时未支付也要还库存
用户抢到了名额但一直不付款,库存不能就这么锁死。写个定时任务扫一遍:
<?php
namespace appcommand;
use appserviceStockService;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
use thinkfacadeDb;
class ReleaseExpired extends Command
{
protected function configure(): void
{
$this->setName('seckill:release-expired')
->setDescription('释放超时未支付的秒杀订单');
}
protected function execute(Input $input, Output $output): int
{
$deadline = time() - 900; // 15 分钟
$orders = Db::name('seckill_order')
->where('status', 0)
->where('created_at', '<', $deadline)
->limit(200)
->select();
foreach ($orders as $order) {
Db::transaction(function () use ($order) {
$affected = Db::name('seckill_order')
->where('id', $order['id'])
->where('status', 0)
->update(['status' => 2, 'updated_at' => time()]);
if ($affected === 0) {
return; // 已经被别的地方处理了
}
Db::name('goods')
->where('id', $order['goods_id'])
->update([
'stock' => Db::raw('stock + 1'),
'version' => Db::raw('version + 1'),
]);
StockService::rollback($order['goods_id'], $order['user_id'], 1);
});
}
$output->writeln('处理了 ' . count($orders) . ' 条超时订单');
return 0;
}
}
在 config/console.php 里注册一下,然后交给系统的计划任务或者 Supervisor 定时触发。15 分钟这个时长要根据商品调整,虚拟商品可以短一点,实物商品可以长一点。
上线前压一把
光看代码没用,得上压力测试。推荐用 wrk 或者 ab,简单粗暴。
# 模拟 100 个用户抢 20 件库存,持续 10 秒
wrk -t4 -c100 -d10s -s post.lua http://your-host/seckill/order?goods_id=1
这里提醒一句:压测千万别直接怼线上环境。找一个跟线上机器配置差不多的测试机跑,否则 Redis 没准是共享的,你的压测会影响到其他业务。
压完之后必须做的事:
-- 数据库里卖出去的订单数
SELECT COUNT(*) FROM seckill_order WHERE goods_id = 1;
-- Redis 里还剩多少库存
GET seckill:stock:1;
-- 数据库里的商品库存
SELECT stock FROM goods WHERE id = 1;
三个数字必须对得上:卖出的 + 数据库库存 = 初始库存;Redis 剩余库存 = 数据库库存(前提是队列已经消费完)。对不上就查日志,大概率是队列积压或者补偿逻辑有遗漏。
踩过的坑,能少走一步是一步
Lua 脚本里的 GET 一定要容错。 如果库存 key 不存在,redis.call('GET', key) 返回的是 false,直接 tonumber(false) 会报错。脚本里用 or '-1' 兜一下,程序才不会崩。
队列重试次数别设太大。 三次足够了。重试太多次的意义不大,真出问题都是逻辑错,重试只会让内存越积越多。
Lua 脚本用 redis-cli --eval 单独调试过再上。 ThinkPHP 那边报的错很模糊,直接在命令行 redis-cli --eval script.lua key1 key2 , arg1 arg2 试一遍,问题一目了然。
写脚本的机器和跑脚本的机器要保持时区一致。 我们有一次是任务在午夜整点跑,结果跑了两遍,因为容器里的时区是 UTC 但日志时间戳是本地时间,监控告警绕晕了。
下单接口加防抖。 用户手指快一点真的会连点两下。前端按钮加个 disabled 是最简单的,后端就靠 Redis 的买家记录去重。两手都要有。
别把库存 key 设过期时间。 有些同学为了「清理」加个 TTL,结果活动还没结束 key 就没了,脚本读不到库存返回 -1,全部变成「库存不足」。这个 key 只能靠活动结束后手动清理,或者换成带活动 ID 的命名空间。
Redis 挂了怎么办。 你的秒杀系统此时应该整体降级,直接返回「活动过于火爆」。别硬撑着让请求打到 MySQL 上,那只会把数据库一起拖垮。降级开关提前做好,别等真挂了才加。
再往深一层能做什么
上面这套方案撑住几十万人的秒杀问题不大。如果想继续优化,还有几个方向:
把用户信息、商品信息也缓存到 Redis,减少数据库查询;用 Lua 脚本一次性完成「扣库存 + 生成预订单号 + 推入队列」,减少网络往返;库存扣减拆成多 key 做分段扣减,避免单个 key 成为热点。
但这些都是量级到了再说的事。先把这篇里的东西跑通、压测过、跑一个月平稳,再考虑下一步。很多项目死在过度设计上,不是死在性能瓶颈上。

