聊到秒杀,不少 Laravel 同学的第一个反应都是:这有什么好写的,不是一条 update 语句判断库存大于 0 的事情吗?说实话我最早也这么想,直到在某次活动里用“先查库存再扣减”的写法,直接见证了同一个商品库存从正数变成负数、后台订单多出好几条之后,才老老实实去研究 Laravel 秒杀系统的完整实现链路。这篇文章不是教你写漂亮的控制器,而是把一个基础但完整的Laravel 秒杀 Demo从零搭到能扛压测:包括数据表怎么设计、为什么用 Redis 预扣库存而不用数据库行锁、订单为什么要丢到队列里异步落库、怎么防用户连点、怎么限流防刷,以及最后怎么用压测工具验证库存不超卖。读完你至少能复现一个“在高并发下库存数据依然正确”的秒杀接口,适合已经会 Laravel 基础增删改查、但没正经做过高并发场景的开发者。1. 为什么一个库存判断写不好,秒杀就一定会超卖秒杀系统最难的不是写代码,而是想清楚几万个人同时点按钮时会发生什么。先看最典型的“朴素写法”,再逐步拆解它为什么必挂。1.1 最常见的“查库存再扣减”代码,错在哪很多 Laravel 教程里会出现这样的代码:$product = Product::find($id); if ($product->stock > 0) { $product->decrement('stock'); // 创建订单 }看着挺合理:先判断有货,再扣库存,再下单。问题在于“判断”和“扣减”中间隔着一道网络请求和代码执行的间隙。假设 Redis 或数据库里当前库存是 1,两个用户同时发起请求。A 请求和 B 请求几乎同一瞬间读到 stock = 1,两个都通过了 if 判断,然后各自执行 decrement。最终库存变成 -1,订单却生成了两条。这就是经典的“检查-更新”竞态,学过数据库的人都知道它叫 race condition,但写业务代码时还是会随手写出来。这个事故的本质不是 decrement 不原子,而是“库存大于 0 的判断”和“库存扣减动作”被拆成了两步,中间留下了时间窗。高并发下这个时间窗会被无限放大。1.2 用数据库硬扛高并发的两个方案,为什么都不够舒服既然拆开不行,那就合并。先看看数据库层面能怎么救。第一种方案是悲观锁,Laravel 里用lockForUpdate():$product = Product::where('id', $id)->lockForUpdate()->first(); if ($product->stock > 0) { $product->decrement('stock'); // 创建订单 }lockForUpdate()会执行select ... for update,把这一行锁住,直到事务提交才释放。并发请求会串行排队,库存不会超卖,但代价是性能上限很低。秒杀场景动辄每秒几千上万的请求量,全挤在一个行锁上排队,数据库连接很快被打满。第二种方案是乐观锁,用“条件更新”把判断和扣减合成一条 SQL:$affected = DB::table('products') ->where('id', $id) ->where('stock', '>', 0) ->decrement('stock'); if ($affected === 0) { return '已售罄'; }UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0这条 SQL 在数据库内部是原子的,它也解决超卖问题,性能比悲观锁好很多,很多教程会把这当作标准答案。但真正的秒杀场景里,问题不止“扣库存”。数据库单表在极端 QPS 下依然会成为瓶颈,而且秒杀链路里往往还有用户校验、风控、订单生成、支付单创建等一堆动作,如果全部同步做完,接口响应时间会很长,用户的重复点击又反过来加剧数据库压力。所以稍微有点规模的项目,最终都走向同一个架构思路:Redis 扛流量,队列慢慢落库。提示:如果你只是想快速验证一个不超卖的接口,乐观锁确实够用。但要做到“完整秒杀 Demo”,Redis 预扣 + 异步落库这套逻辑才是更接近生产环境的做法。2. Demo 的数据模型设计:商品、秒杀商品、订单三张表怎么拆数据表设计往往决定后面代码写起来顺不顺手。秒杀场景里商品普通信息和秒杀规则最好不要混在一张表里,我下面给出的是实践下来比较舒服的一套结构。2.1 环境版本与依赖准备这个 Demo 我用的环境如下,大家可以直接参考:PHP 8.2+Laravel 10 / 11 均可Redis 6+,用于缓存、库存预扣、队列predis/predis扩展包,如果你装了 phpredis 扩展也可以把Redis::connection()->client()换成 phpredis 客户端安装依赖:composer require predis/predis.env关键配置:QUEUE_CONNECTION=redis CACHE_STORE=redis REDIS_CLIENT=predis REDIS_HOST=127.0.0.1 REDIS_PORT=6379队列驱动用 Redis 是因为后面要跑异步订单落库,如果本地没装 Redis,建议先用 Docker 拉一个,别偷懒。2.2 数据表结构与迁移文件秒杀 Demo 涉及三张核心表:商品表、秒杀商品表、订单表。商品表是基础数据,秒杀商品表记录某次活动的秒杀价和秒杀库存,订单表记录用户抢到的结果。商品表迁移:Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->decimal('price', 10, 2); $table->unsignedInteger('stock')->default(0); $table->timestamps(); });秒杀商品表:Schema::create('seckill_products', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained(); $table->decimal('seckill_price', 10, 2); $table->unsignedInteger('seckill_stock')->default(0); $table->unsignedInteger('limit_per_user')->default(1); $table->timestamp('start_time'); $table->timestamp('end_time'); $table->timestamps(); });订单表:Schema::create('orders', function (Blueprint $table) { $table->id(); $table->string('order_no', 64)->unique(); $table->foreignId('user_id')->constrained(); $table->foreignId('seckill_product_id')->constrained(); $table->decimal('amount', 10, 2); $table->unsignedTinyInteger('status')->default(0); $table->json('product_snapshot'); $table->timestamp('expired_at')->nullable(); $table->timestamps(); $table->unique(['user_id', 'seckill_product_id'], 'uk_user_seckill'); });订单表里这个联合唯一索引很重要。它保证同一个用户对同一个秒杀商品只能生成一条订单记录,是后面幂等消费的兜底。2.3 为什么单独拆一张秒杀商品表有同学可能会问,为什么不在 products 表里直接加seckill_price、seckill_stock、start_time这几个字段?那样确实简单,但有几个问题:普通商品列表、购物车、订单详情都依赖 products 表,加一堆秒杀字段会让这张表的缓存体积变大,命中率下降。秒杀活动往往有多个批次,同一个商品这次秒杀结束,下次还要再开,把活动字段塞在商品表里只能记一组状态。秒杀库存和普通库存的扣减逻辑完全不同,拆开后可以分别预热缓存,互不干扰。拆表之后,products 表只存“静态”的商品信息,seckill_products 表管活动维度数据,orders 表管结果。这个分层在后续写 Redis 缓存预热和库存扣减时非常顺手。提示:下单时记得把商品名称、价格快照写入 orders 表的product_snapshot字段。秒杀活动一旦结束,商品表和秒杀表随时可能改价、改名,历史订单不能跟着变。很多新手在这里踩坑,订单详情页显示的是商品当前价格,而不是下单时价格。3. 下单核心链路:Redis 预扣库存,订单交给队列这一节是整个 Demo 的心脏。理解了这里的流程,秒杀系统的大半个骨架就清楚了。3.1 一次秒杀的完整流程我设计的接口调用流程如下,每个步骤的职责都很单一:校验秒杀活动时间是否在 start_time 和 end_time 之间。检查用户维度的幂等键,防止同一个用户连点或脚本刷接口。用 Redis 原子扣减秒杀库存,扣减失败直接返回“已抢光”。扣减成功后,把“创建订单”的任务丢进队列。接口立刻返回“排队中”,前端稍后轮询订单状态。控制器核心逻辑大致长这样:public function start(Request $request, $id) { $userId = $request->user()->id; // 1. 活动时间与商品状态校验 $seckill = SeckillProduct::with('product')->where('id', $id)->firstOrFail(); $now = now(); abort_if($now < $seckill->start_time, 403, '活动未开始'); abort_if($now > $seckill->end_time, 403, '活动已结束'); // 2. 用户幂等校验,防止重复下单 $lockKey = "seckill:user:{$seckill->product_id}:{$userId}"; $locked = Redis::set($lockKey, 1, 'EX', 600, 'NX'); if (!$locked) { return response()->json(['message' => '请勿重复提交']); } // 3. Redis 原子预扣库存 $stockKey = "seckill:stock:{$seckill->product_id}"; $result = Redis::eval( <<<'LUA' local stock = tonumber(redis.call('GET', KEYS[1])) if stock == false or stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1 LUA, 1, $stockKey ); if (!$result) { return response()->json(['message' => '手慢了,已售罄']); } // 4. 丢进队列异步落库 dispatch(new CreateSeckillOrder($userId, $seckill->id)); // 5. 返回排队中 return response()->json(['message' => '排队中,请稍后查看订单']); }注意这个流程里所有容易出错的、耗时的操作都在 Redis 和队列之间完成,数据库连接只在 worker 消费队列时短暂使用,这是秒杀接口能扛高并发的关键。3.2 库存预扣的两种写法与原子性说明刚才控制器里用的是 Lua 脚本。为了让没有写过 Redis Lua 的同学也能看懂,我把两种常见写法都列出来。第一种是 DECR 后判断负数再回补:$stock = Redis::decr("seckill:stock:{$productId}"); if ($stock < 0) { Redis::incr("seckill:stock:{$productId}"); return false; }这种做法非常常见,思路是先把库存减 1,发现减成负数就加回来,等于“借了再还”。它能防止超卖,但有隐患:一旦 DECR 成功而接下来的业务逻辑挂了,或者网络超时导致 INCR 执行失败,Redis 里的库存就永久少了一件。而且从 DECR 到 INCR 之间有一段延迟,这期间库存是负数,前端看到的数据会异常。第二种是 Lua 脚本原子完成“检查 + 扣减”:local stock = tonumber(redis.call('GET', KEYS[1])) if stock == false or stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1Redis 的 Lua 脚本在执行期间不会被其他命令插入,相当于整个检查-扣减流程是原子的。这样既没有负数窗口期,也不存在“忘了回补”的问题。这也是我在 Demo 里推荐的方式。注意:执行 Lua 脚本前,务必先确保GET能取到值,否则库存秒杀完了再请求,脚本会返回 0 并安全退出。生产环境里库存预热就是把秒杀库存量提前 SET 进 Redis。3.3 队列 Job:订单落库和数据库库存扣减库存预扣成功之后,订单还没有创建。这时需要把任务交给队列 worker,让后台慢慢把订单写进 MySQL。CreateSeckillOrder Job:class CreateSeckillOrder implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( public int $userId, public int $seckillId ) {} public function handle(): void { DB::beginTransaction(); try { $seckill = SeckillProduct::with('product')->lockForUpdate()->find($this->seckillId); // 幂等兜底:如果订单已存在,直接返回 $exists = Order::where('user_id', $this->userId) ->where('seckill_product_id', $this->seckillId) ->exists(); if ($exists) { DB::commit(); return; } Order::create([ 'order_no' => 'SK' . date('YmdHis') . random_int(1000, 9999), 'user_id' => $this->userId, 'seckill_product_id' => $seckill->id, 'amount' => $seckill->seckill_price, 'product_snapshot' => [ 'name' => $seckill->product->name, 'price' => $seckill->seckill_price, ], 'status' => 0, 'expired_at' => now()->addMinutes(15), ]); $seckill->decrement('seckill_stock'); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); // 库存回补,避免 Redis 库存与真实可售库存不一致 Redis::incr("seckill:stock:{$seckill->product_id}"); throw $e; } } }这里有几个地方值得解释一下。Job 里又对秒杀商品做了lockForUpdate(),是为了防止多个 worker 同时处理同一个秒杀商品时,数据库里的seckill_stock扣减也出现竞态。虽然 Redis 层已经挡掉了大部分流量,但并发消费队列时 worker 之间依然可能同时读到相同库存,所以数据库侧的兜底锁不能省。exists判断加联合唯一索引,是保证“同一个用户同一场秒杀只生成一条订单”的最后一道防线。队列消费失败会自动重试,如果重试时订单已经创建,直接返回即可,不能再次生成订单。3.4 失败补偿:库存回补的时机与实现库存回补是很多人容易漏掉的一环。Redis 库存扣了,但订单最终没建成,那库存就凭空消失了。我遇到过不少次,活动结束一核对 Redis 和 MySQL,两边数字对不上。需要回补的场景主要有三种:队列消费失败,Job 抛出异常后系统自动重试仍失败,订单没创建成功。用户 15 分钟内未支付,订单超时取消。用户主动取消订单。第一种场景就在 Job 的 catch 块里处理,回滚后立刻Redis::incr。这里要注意别重复回补,所以需要给 Job 加上幂等标记。我的做法是把订单创建动作和回补动作做成配套,只有订单确实没写入系统时才回补,靠exists判断兜底。超时未支付的回补,我用一个定时任务扫描expired_at已过的未支付订单:$expiredOrders = Order::where('status', 0) ->where('expired_at', '<', now()) ->get(); foreach ($expiredOrders as $order) { DB::transaction(function () use ($order) { $order->update(['status' => 2]); $seckill = SeckillProduct::where('id', $order->seckill_product_id)->lockForUpdate()->first(); $seckill->increment('seckill_stock'); Redis::incr("seckill:stock:{$seckill->product_id}"); }); }这里 Redis 库存和数据库库存同时回补,两边保持一致。如果用户是主动取消订单,走的也是同一套逻辑,把两个地方的数字都加回去。3.5 客户端怎么知道抢没抢到接口只返回了一个“排队中”,用户肯定不愿意干等。前端需要轮询订单查询接口:public function order(Request $request, $seckillId) { $order = Order::where('user_id', $request->user()->id) ->where('seckill_product_id', $seckillId) ->first(); if (!$order) { return response()->json(['status' => 'pending', 'message' => '抢购处理中']); } return response()->json([ 'status' => $order->status === 0 ? 'created' : 'paid', 'order_no' => $order->order_no, ]); }用户看到“排队中”后,前端每隔 1 到 2 秒查一次订单接口。如果 Redis 库存抢光了,Order表里不会有记录,查询接口会一直返回 pending。这里无妨,因为用户最终通过活动页“已售罄”状态感知结果。更精细的做法是保存一个“排队结果”缓存,订单创建成功或失败都往里写值,查询接口优先读缓存。4. 防超卖与防刷的实战组合拳库存不超卖只是底线,秒杀系统还要挡住连点、脚本、恶意流量。这一节讲我实际用到的几层防护。4.1 用户维度幂等键:让连点不再产生并发请求用户手速再快,一秒钟也就能点几次;但脚本可以瞬间发几十个请求。所以接口第一步要拦“同一个用户对同一个商品的重复请求”。我用的方案是 RedisSET NX EX:$key = "seckill:user:{$seckillId}:{$userId}"; $locked = Redis::set($key, 1, 'EX', 600, 'NX'); if (!$locked) { return response()->json(['message' => '请勿重复提交']); }NX参数表示只有 key 不存在时才设置成功,EX 600表示这个幂等键 10 分钟后自动过期。它的作用有两个:一是 10 分钟内同一个用户无法重复提交,二是即使 Redis 预扣库存之前用户就退出了,10 分钟后还能再次尝试。这个过期时间要根据业务调整。如果秒杀活动持续 2 小时,用户 10 分钟不能重复下单问题不大。如果你希望用户“付款失败后立刻能重新抢”,可以把过期时间缩到 60 秒,甚至监听订单支付失败时主动删除幂等键。4.2 Laravel throttle 中间件与自定义 Redis 限流Laravel 自带throttle中间件,配置很方便:Route::post('/seckill/{id}', [SeckillController::class, 'start']) ->middleware('throttle:10,1');意思是每个 IP 每分钟最多 10 次请求。它的好处是开箱即用,坏处是粒度太粗。秒杀活动里同一个出口 IP 可能承载成千上万个真实用户,用 IP 限流容易误伤,而且 Laravel 默认的 throttle 是写在应用层缓存里的,本身就扛不住太大压力。更贴近生产环境的做法是自己在 Redis 里做计数器限流,按用户维度限制频率:$key = "seckill:rate:{$userId}:{$seckillId}"; $count = Redis::incr($key); if ($count === 1) { Redis::expire($key, 5); } if ($count > 5) { return response()->json(['message' => '操作过于频繁'], 429); }这个方案允许每个用户每 5 秒最多发起 5 次请求,粒度更合理,而且 Redis 的 INCR 是原子的,不需要额外加锁。真实风控系统还会接设备指纹、行为轨迹、人机验证,Demo 做到用户维度限流已经比裸接口安全很多了。4.3 活动时间与库存预热,避免缓存穿透把数据库打穿秒杀开始前,会有大量请求提前打到活动接口。如果每次请求都查 MySQL,数据库必挂。所以我通常会写一个预热脚本,在活动开始前把秒杀库存提前加载进 Redis:php artisan seckill:preheat --seckillId=1脚本逻辑:class PreheatSeckillStock { public function handle($seckillId) { $seckill = SeckillProduct::with('product')->find($seckillId); Redis::set("seckill:stock:{$seckill->product_id}", $seckill->seckill_stock); Redis::set("seckill:start:{$seckill->product_id}", $seckill->start_time->timestamp); Redis::set("seckill:end:{$seckill->product_id}", $seckill->end_time->timestamp); } }接口侧先判断 Redis 里的活动时间,再查数据库:$start = Redis::get("seckill:start:{$seckill->product_id}"); $end = Redis::get("seckill:end:{$seckill->product_id}"); $now = time(); if ($now < $start || $now > $end) { return response()->json(['message' => '不在活动时间内']); }预热之后,秒杀接口完全不依赖 MySQL 时间,也不会因为活动开始的一瞬间高并发读库把数据库打瘫。这个细节在老项目里很容易被忽略,我见过不止一次活动开始后 MySQL 连接数瞬间打满,最后查出来是活动时间每次实时查表。4.4 压测中最容易暴露的 Laravel 瓶颈高并发下 Laravel 本身的坑比业务逻辑更隐蔽。下面这几个是我每次压测都必然要检查的点:配置缓存:没执行php artisan config:cache时,每个请求都会重新读取 .env 并解析配置文件,压测一上来 CPU 直接飙高。路由缓存:php artisan route:cache也能减少路由解析开销,Demo 阶段可以顺手打上。数据库连接数:Laravel 没有内置连接池,每个请求都会新建 PDO 连接。压测时 MySQL 的max_connections很容易被打满。队列 worker 数量:订单落库依赖 queue worker,默认只开一个 worker 时,消费速度根本跟不上 Redis 预扣速度。本地压测至少开 5 个 worker,生产环境建议用专业队列管理工具。队列开启方式:php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 & php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 & php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 &注意--tries=3表示失败最多重试 3 次,--backoff=3表示重试间隔 3 秒。这样即使订单创建偶发失败,也不会无限重试把日志刷爆。5. 压测验证与从 Demo 到生产还差什么写了这么多,到底对不对,最终要看数据。我习惯用 Apache ab 做简单压测,它虽然不是最专业的工具,但足够验证库存一致性问题。5.1 用 ab 压出的真实数据长什么样把请求参数写入文件 post_data.json:{ "product_id": 1 }执行压测命令:ab -n 10000 -c 200 -p post_data.json -T application/json \ -H "Authorization: Bearer 测试用户的token" \ http://localhost:8000/api/seckill/1这个命令表示总共发 10000 个请求,并发 200。压测结束后重点看两处:一是请求的失败率。正常配置下,大部分请求会返回“排队中”或“已售罄”,两者都属于正常业务响应,non-2xx responses里如果出现 500 才是真的有问题。二是核对订单表和 Redis 库存。活动结束后,查一下订单表总订单数,对比 Redis 里剩余库存加已扣库存是否等于最初预热库存。我压测时订单一共创建了 X 条,Redis 剩余库存 Y,X 加 Y 恰好等于预热库存,说明超卖被挡住了。压测结果里最直观的是 Requests per second。在本地开发机上,不加任何防护的秒杀接口可能 RPS 只有几百,加了 Redis 预扣和队列异步后,接口响应很快,RPS 能大幅提升。5.2 我踩过的几个坑,按严重程度排序第一个坑是忘了预热直接压测。压测一开始请求全打到 MySQL,数据库连接数瞬间飙满,导致所有请求都超时。后来我才反应过来,活动开始前必须把秒杀商品、库存、活动时间全部预热到 Redis,接口只从 Redis 读,才能扛住流量。第二个坑是队列 worker 数量不够。库存预扣很快,但 worker 只有 1 个,订单表插入速度完全跟不上,结果出现“Redis 库存已经扣完,但订单表只有一半记录”的假象。这不是超卖,是消费积压。把 worker 开到 5 个以上就恢复正常了。这里也引出一个注意点:Redis 预扣库存只是“占坑”,真正提交订单还要靠队列及时消化。第三个坑是订单重复生成。第一次实现时没加联合唯一索引,队列重试导致同一用户同一个商品生成了多条订单。后来加了uk_user_seckill唯一索引,并在 Job 里做 exists 判断,才彻底解决。第四个坑是 Redis 里的幂等键过早过期。最早我把过期时间设成 30 秒,结果用户从点击按钮到支付完成需要一分钟,支付成功后想再确认订单详情,却被幂等键挡住,提示“请勿重复提交”。后来把过期时间调整到 15 分钟,和订单的expired_at对齐,体验就好了。5.3 从 Demo 到生产还需要补什么东西Demo 能跑通、能扛住本地压测,但离生产还有一段距离。我把主要差距列一下,方便后续扩展:补充项说明库存分片把同一商品库存拆成多个 Redis key,每个 key 服务一部分流量,降低单 key 热点压力多队列隔离秒杀订单队列和普通业务队列分开,普通队列积压不影响秒杀落库消息确认与死信队列消费失败后进入死信队列,人工补偿库存和订单监控告警对 Redis 内存、队列长度、订单积压数量做监控,活动异常时能第一时间发现降级方案Redis 不可用时切换数据库乐观锁方案,保证活动不死但性能下降库存分片这块,简单做法是加一个后缀索引,比如seckill:stock:1:0到seckill:stock:1:9十个分片,用户请求按 hash 取模落到某个分片。每个分片独立扣减,总量就是所有分片之和,能显著降低单点热点。但要注意订单创建后的库存回补也要按分片回补。生产级别的复杂度主要体现在这些边界细节上。最后分享一个我个人的体会:秒杀 Demo 看起来代码量不大,真正难的是理解每个环节为什么这么设计。为什么用 Redis 预扣库存,不是为了炫技,而是为了把数据库从高并发流量中解放出来;为什么订单要异步落库,是为了让用户请求快速结束,避免长时间占用连接;为什么每个环节都要做幂等,是因为分布式系统里“重试是常态”。如果让我重新做一个 Laravel 秒杀 Demo,我会先把数据表设计画清楚,再写一条最简链路:预热库存、Lua 扣减、队列落库、库存回补。链路跑通之后,再把限流、幂等、防刷一层层加上去。按照这个顺序,排查问题会轻松很多,不至于一开始就被一堆概念绕昏头。
聊到秒杀,不少 Laravel 同学的第一个反应都是:这有什么好写的,不是一条 update 语句判断库存大于 0 的事情吗?说实话我最早也这么想,直到在某次活动里用“先查库存再扣减”的写法,直接见证了同一个商品库存从正数变成负数、后台订单多出好几条之后,才老老实实去研究 Laravel 秒杀系统的完整实现链路。这篇文章不是教你写漂亮的控制器,而是把一个基础但完整的Laravel 秒杀 Demo从零搭到能扛压测:包括数据表怎么设计、为什么用 Redis 预扣库存而不用数据库行锁、订单为什么要丢到队列里异步落库、怎么防用户连点、怎么限流防刷,以及最后怎么用压测工具验证库存不超卖。读完你至少能复现一个“在高并发下库存数据依然正确”的秒杀接口,适合已经会 Laravel 基础增删改查、但没正经做过高并发场景的开发者。1. 为什么一个库存判断写不好,秒杀就一定会超卖秒杀系统最难的不是写代码,而是想清楚几万个人同时点按钮时会发生什么。先看最典型的“朴素写法”,再逐步拆解它为什么必挂。1.1 最常见的“查库存再扣减”代码,错在哪很多 Laravel 教程里会出现这样的代码:$product = Product::find($id); if ($product->stock > 0) { $product->decrement('stock'); // 创建订单 }看着挺合理:先判断有货,再扣库存,再下单。问题在于“判断”和“扣减”中间隔着一道网络请求和代码执行的间隙。假设 Redis 或数据库里当前库存是 1,两个用户同时发起请求。A 请求和 B 请求几乎同一瞬间读到 stock = 1,两个都通过了 if 判断,然后各自执行 decrement。最终库存变成 -1,订单却生成了两条。这就是经典的“检查-更新”竞态,学过数据库的人都知道它叫 race condition,但写业务代码时还是会随手写出来。这个事故的本质不是 decrement 不原子,而是“库存大于 0 的判断”和“库存扣减动作”被拆成了两步,中间留下了时间窗。高并发下这个时间窗会被无限放大。1.2 用数据库硬扛高并发的两个方案,为什么都不够舒服既然拆开不行,那就合并。先看看数据库层面能怎么救。第一种方案是悲观锁,Laravel 里用lockForUpdate():$product = Product::where('id', $id)->lockForUpdate()->first(); if ($product->stock > 0) { $product->decrement('stock'); // 创建订单 }lockForUpdate()会执行select ... for update,把这一行锁住,直到事务提交才释放。并发请求会串行排队,库存不会超卖,但代价是性能上限很低。秒杀场景动辄每秒几千上万的请求量,全挤在一个行锁上排队,数据库连接很快被打满。第二种方案是乐观锁,用“条件更新”把判断和扣减合成一条 SQL:$affected = DB::table('products') ->where('id', $id) ->where('stock', '>', 0) ->decrement('stock'); if ($affected === 0) { return '已售罄'; }UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0这条 SQL 在数据库内部是原子的,它也解决超卖问题,性能比悲观锁好很多,很多教程会把这当作标准答案。但真正的秒杀场景里,问题不止“扣库存”。数据库单表在极端 QPS 下依然会成为瓶颈,而且秒杀链路里往往还有用户校验、风控、订单生成、支付单创建等一堆动作,如果全部同步做完,接口响应时间会很长,用户的重复点击又反过来加剧数据库压力。所以稍微有点规模的项目,最终都走向同一个架构思路:Redis 扛流量,队列慢慢落库。提示:如果你只是想快速验证一个不超卖的接口,乐观锁确实够用。但要做到“完整秒杀 Demo”,Redis 预扣 + 异步落库这套逻辑才是更接近生产环境的做法。2. Demo 的数据模型设计:商品、秒杀商品、订单三张表怎么拆数据表设计往往决定后面代码写起来顺不顺手。秒杀场景里商品普通信息和秒杀规则最好不要混在一张表里,我下面给出的是实践下来比较舒服的一套结构。2.1 环境版本与依赖准备这个 Demo 我用的环境如下,大家可以直接参考:PHP 8.2+Laravel 10 / 11 均可Redis 6+,用于缓存、库存预扣、队列predis/predis扩展包,如果你装了 phpredis 扩展也可以把Redis::connection()->client()换成 phpredis 客户端安装依赖:composer require predis/predis.env关键配置:QUEUE_CONNECTION=redis CACHE_STORE=redis REDIS_CLIENT=predis REDIS_HOST=127.0.0.1 REDIS_PORT=6379队列驱动用 Redis 是因为后面要跑异步订单落库,如果本地没装 Redis,建议先用 Docker 拉一个,别偷懒。2.2 数据表结构与迁移文件秒杀 Demo 涉及三张核心表:商品表、秒杀商品表、订单表。商品表是基础数据,秒杀商品表记录某次活动的秒杀价和秒杀库存,订单表记录用户抢到的结果。商品表迁移:Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->decimal('price', 10, 2); $table->unsignedInteger('stock')->default(0); $table->timestamps(); });秒杀商品表:Schema::create('seckill_products', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained(); $table->decimal('seckill_price', 10, 2); $table->unsignedInteger('seckill_stock')->default(0); $table->unsignedInteger('limit_per_user')->default(1); $table->timestamp('start_time'); $table->timestamp('end_time'); $table->timestamps(); });订单表:Schema::create('orders', function (Blueprint $table) { $table->id(); $table->string('order_no', 64)->unique(); $table->foreignId('user_id')->constrained(); $table->foreignId('seckill_product_id')->constrained(); $table->decimal('amount', 10, 2); $table->unsignedTinyInteger('status')->default(0); $table->json('product_snapshot'); $table->timestamp('expired_at')->nullable(); $table->timestamps(); $table->unique(['user_id', 'seckill_product_id'], 'uk_user_seckill'); });订单表里这个联合唯一索引很重要。它保证同一个用户对同一个秒杀商品只能生成一条订单记录,是后面幂等消费的兜底。2.3 为什么单独拆一张秒杀商品表有同学可能会问,为什么不在 products 表里直接加seckill_price、seckill_stock、start_time这几个字段?那样确实简单,但有几个问题:普通商品列表、购物车、订单详情都依赖 products 表,加一堆秒杀字段会让这张表的缓存体积变大,命中率下降。秒杀活动往往有多个批次,同一个商品这次秒杀结束,下次还要再开,把活动字段塞在商品表里只能记一组状态。秒杀库存和普通库存的扣减逻辑完全不同,拆开后可以分别预热缓存,互不干扰。拆表之后,products 表只存“静态”的商品信息,seckill_products 表管活动维度数据,orders 表管结果。这个分层在后续写 Redis 缓存预热和库存扣减时非常顺手。提示:下单时记得把商品名称、价格快照写入 orders 表的product_snapshot字段。秒杀活动一旦结束,商品表和秒杀表随时可能改价、改名,历史订单不能跟着变。很多新手在这里踩坑,订单详情页显示的是商品当前价格,而不是下单时价格。3. 下单核心链路:Redis 预扣库存,订单交给队列这一节是整个 Demo 的心脏。理解了这里的流程,秒杀系统的大半个骨架就清楚了。3.1 一次秒杀的完整流程我设计的接口调用流程如下,每个步骤的职责都很单一:校验秒杀活动时间是否在 start_time 和 end_time 之间。检查用户维度的幂等键,防止同一个用户连点或脚本刷接口。用 Redis 原子扣减秒杀库存,扣减失败直接返回“已抢光”。扣减成功后,把“创建订单”的任务丢进队列。接口立刻返回“排队中”,前端稍后轮询订单状态。控制器核心逻辑大致长这样:public function start(Request $request, $id) { $userId = $request->user()->id; // 1. 活动时间与商品状态校验 $seckill = SeckillProduct::with('product')->where('id', $id)->firstOrFail(); $now = now(); abort_if($now < $seckill->start_time, 403, '活动未开始'); abort_if($now > $seckill->end_time, 403, '活动已结束'); // 2. 用户幂等校验,防止重复下单 $lockKey = "seckill:user:{$seckill->product_id}:{$userId}"; $locked = Redis::set($lockKey, 1, 'EX', 600, 'NX'); if (!$locked) { return response()->json(['message' => '请勿重复提交']); } // 3. Redis 原子预扣库存 $stockKey = "seckill:stock:{$seckill->product_id}"; $result = Redis::eval( <<<'LUA' local stock = tonumber(redis.call('GET', KEYS[1])) if stock == false or stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1 LUA, 1, $stockKey ); if (!$result) { return response()->json(['message' => '手慢了,已售罄']); } // 4. 丢进队列异步落库 dispatch(new CreateSeckillOrder($userId, $seckill->id)); // 5. 返回排队中 return response()->json(['message' => '排队中,请稍后查看订单']); }注意这个流程里所有容易出错的、耗时的操作都在 Redis 和队列之间完成,数据库连接只在 worker 消费队列时短暂使用,这是秒杀接口能扛高并发的关键。3.2 库存预扣的两种写法与原子性说明刚才控制器里用的是 Lua 脚本。为了让没有写过 Redis Lua 的同学也能看懂,我把两种常见写法都列出来。第一种是 DECR 后判断负数再回补:$stock = Redis::decr("seckill:stock:{$productId}"); if ($stock < 0) { Redis::incr("seckill:stock:{$productId}"); return false; }这种做法非常常见,思路是先把库存减 1,发现减成负数就加回来,等于“借了再还”。它能防止超卖,但有隐患:一旦 DECR 成功而接下来的业务逻辑挂了,或者网络超时导致 INCR 执行失败,Redis 里的库存就永久少了一件。而且从 DECR 到 INCR 之间有一段延迟,这期间库存是负数,前端看到的数据会异常。第二种是 Lua 脚本原子完成“检查 + 扣减”:local stock = tonumber(redis.call('GET', KEYS[1])) if stock == false or stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) return 1Redis 的 Lua 脚本在执行期间不会被其他命令插入,相当于整个检查-扣减流程是原子的。这样既没有负数窗口期,也不存在“忘了回补”的问题。这也是我在 Demo 里推荐的方式。注意:执行 Lua 脚本前,务必先确保GET能取到值,否则库存秒杀完了再请求,脚本会返回 0 并安全退出。生产环境里库存预热就是把秒杀库存量提前 SET 进 Redis。3.3 队列 Job:订单落库和数据库库存扣减库存预扣成功之后,订单还没有创建。这时需要把任务交给队列 worker,让后台慢慢把订单写进 MySQL。CreateSeckillOrder Job:class CreateSeckillOrder implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( public int $userId, public int $seckillId ) {} public function handle(): void { DB::beginTransaction(); try { $seckill = SeckillProduct::with('product')->lockForUpdate()->find($this->seckillId); // 幂等兜底:如果订单已存在,直接返回 $exists = Order::where('user_id', $this->userId) ->where('seckill_product_id', $this->seckillId) ->exists(); if ($exists) { DB::commit(); return; } Order::create([ 'order_no' => 'SK' . date('YmdHis') . random_int(1000, 9999), 'user_id' => $this->userId, 'seckill_product_id' => $seckill->id, 'amount' => $seckill->seckill_price, 'product_snapshot' => [ 'name' => $seckill->product->name, 'price' => $seckill->seckill_price, ], 'status' => 0, 'expired_at' => now()->addMinutes(15), ]); $seckill->decrement('seckill_stock'); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); // 库存回补,避免 Redis 库存与真实可售库存不一致 Redis::incr("seckill:stock:{$seckill->product_id}"); throw $e; } } }这里有几个地方值得解释一下。Job 里又对秒杀商品做了lockForUpdate(),是为了防止多个 worker 同时处理同一个秒杀商品时,数据库里的seckill_stock扣减也出现竞态。虽然 Redis 层已经挡掉了大部分流量,但并发消费队列时 worker 之间依然可能同时读到相同库存,所以数据库侧的兜底锁不能省。exists判断加联合唯一索引,是保证“同一个用户同一场秒杀只生成一条订单”的最后一道防线。队列消费失败会自动重试,如果重试时订单已经创建,直接返回即可,不能再次生成订单。3.4 失败补偿:库存回补的时机与实现库存回补是很多人容易漏掉的一环。Redis 库存扣了,但订单最终没建成,那库存就凭空消失了。我遇到过不少次,活动结束一核对 Redis 和 MySQL,两边数字对不上。需要回补的场景主要有三种:队列消费失败,Job 抛出异常后系统自动重试仍失败,订单没创建成功。用户 15 分钟内未支付,订单超时取消。用户主动取消订单。第一种场景就在 Job 的 catch 块里处理,回滚后立刻Redis::incr。这里要注意别重复回补,所以需要给 Job 加上幂等标记。我的做法是把订单创建动作和回补动作做成配套,只有订单确实没写入系统时才回补,靠exists判断兜底。超时未支付的回补,我用一个定时任务扫描expired_at已过的未支付订单:$expiredOrders = Order::where('status', 0) ->where('expired_at', '<', now()) ->get(); foreach ($expiredOrders as $order) { DB::transaction(function () use ($order) { $order->update(['status' => 2]); $seckill = SeckillProduct::where('id', $order->seckill_product_id)->lockForUpdate()->first(); $seckill->increment('seckill_stock'); Redis::incr("seckill:stock:{$seckill->product_id}"); }); }这里 Redis 库存和数据库库存同时回补,两边保持一致。如果用户是主动取消订单,走的也是同一套逻辑,把两个地方的数字都加回去。3.5 客户端怎么知道抢没抢到接口只返回了一个“排队中”,用户肯定不愿意干等。前端需要轮询订单查询接口:public function order(Request $request, $seckillId) { $order = Order::where('user_id', $request->user()->id) ->where('seckill_product_id', $seckillId) ->first(); if (!$order) { return response()->json(['status' => 'pending', 'message' => '抢购处理中']); } return response()->json([ 'status' => $order->status === 0 ? 'created' : 'paid', 'order_no' => $order->order_no, ]); }用户看到“排队中”后,前端每隔 1 到 2 秒查一次订单接口。如果 Redis 库存抢光了,Order表里不会有记录,查询接口会一直返回 pending。这里无妨,因为用户最终通过活动页“已售罄”状态感知结果。更精细的做法是保存一个“排队结果”缓存,订单创建成功或失败都往里写值,查询接口优先读缓存。4. 防超卖与防刷的实战组合拳库存不超卖只是底线,秒杀系统还要挡住连点、脚本、恶意流量。这一节讲我实际用到的几层防护。4.1 用户维度幂等键:让连点不再产生并发请求用户手速再快,一秒钟也就能点几次;但脚本可以瞬间发几十个请求。所以接口第一步要拦“同一个用户对同一个商品的重复请求”。我用的方案是 RedisSET NX EX:$key = "seckill:user:{$seckillId}:{$userId}"; $locked = Redis::set($key, 1, 'EX', 600, 'NX'); if (!$locked) { return response()->json(['message' => '请勿重复提交']); }NX参数表示只有 key 不存在时才设置成功,EX 600表示这个幂等键 10 分钟后自动过期。它的作用有两个:一是 10 分钟内同一个用户无法重复提交,二是即使 Redis 预扣库存之前用户就退出了,10 分钟后还能再次尝试。这个过期时间要根据业务调整。如果秒杀活动持续 2 小时,用户 10 分钟不能重复下单问题不大。如果你希望用户“付款失败后立刻能重新抢”,可以把过期时间缩到 60 秒,甚至监听订单支付失败时主动删除幂等键。4.2 Laravel throttle 中间件与自定义 Redis 限流Laravel 自带throttle中间件,配置很方便:Route::post('/seckill/{id}', [SeckillController::class, 'start']) ->middleware('throttle:10,1');意思是每个 IP 每分钟最多 10 次请求。它的好处是开箱即用,坏处是粒度太粗。秒杀活动里同一个出口 IP 可能承载成千上万个真实用户,用 IP 限流容易误伤,而且 Laravel 默认的 throttle 是写在应用层缓存里的,本身就扛不住太大压力。更贴近生产环境的做法是自己在 Redis 里做计数器限流,按用户维度限制频率:$key = "seckill:rate:{$userId}:{$seckillId}"; $count = Redis::incr($key); if ($count === 1) { Redis::expire($key, 5); } if ($count > 5) { return response()->json(['message' => '操作过于频繁'], 429); }这个方案允许每个用户每 5 秒最多发起 5 次请求,粒度更合理,而且 Redis 的 INCR 是原子的,不需要额外加锁。真实风控系统还会接设备指纹、行为轨迹、人机验证,Demo 做到用户维度限流已经比裸接口安全很多了。4.3 活动时间与库存预热,避免缓存穿透把数据库打穿秒杀开始前,会有大量请求提前打到活动接口。如果每次请求都查 MySQL,数据库必挂。所以我通常会写一个预热脚本,在活动开始前把秒杀库存提前加载进 Redis:php artisan seckill:preheat --seckillId=1脚本逻辑:class PreheatSeckillStock { public function handle($seckillId) { $seckill = SeckillProduct::with('product')->find($seckillId); Redis::set("seckill:stock:{$seckill->product_id}", $seckill->seckill_stock); Redis::set("seckill:start:{$seckill->product_id}", $seckill->start_time->timestamp); Redis::set("seckill:end:{$seckill->product_id}", $seckill->end_time->timestamp); } }接口侧先判断 Redis 里的活动时间,再查数据库:$start = Redis::get("seckill:start:{$seckill->product_id}"); $end = Redis::get("seckill:end:{$seckill->product_id}"); $now = time(); if ($now < $start || $now > $end) { return response()->json(['message' => '不在活动时间内']); }预热之后,秒杀接口完全不依赖 MySQL 时间,也不会因为活动开始的一瞬间高并发读库把数据库打瘫。这个细节在老项目里很容易被忽略,我见过不止一次活动开始后 MySQL 连接数瞬间打满,最后查出来是活动时间每次实时查表。4.4 压测中最容易暴露的 Laravel 瓶颈高并发下 Laravel 本身的坑比业务逻辑更隐蔽。下面这几个是我每次压测都必然要检查的点:配置缓存:没执行php artisan config:cache时,每个请求都会重新读取 .env 并解析配置文件,压测一上来 CPU 直接飙高。路由缓存:php artisan route:cache也能减少路由解析开销,Demo 阶段可以顺手打上。数据库连接数:Laravel 没有内置连接池,每个请求都会新建 PDO 连接。压测时 MySQL 的max_connections很容易被打满。队列 worker 数量:订单落库依赖 queue worker,默认只开一个 worker 时,消费速度根本跟不上 Redis 预扣速度。本地压测至少开 5 个 worker,生产环境建议用专业队列管理工具。队列开启方式:php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 & php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 & php artisan queue:work redis --tries=3 --backoff=3 --sleep=1 --timeout=60 &注意--tries=3表示失败最多重试 3 次,--backoff=3表示重试间隔 3 秒。这样即使订单创建偶发失败,也不会无限重试把日志刷爆。5. 压测验证与从 Demo 到生产还差什么写了这么多,到底对不对,最终要看数据。我习惯用 Apache ab 做简单压测,它虽然不是最专业的工具,但足够验证库存一致性问题。5.1 用 ab 压出的真实数据长什么样把请求参数写入文件 post_data.json:{ "product_id": 1 }执行压测命令:ab -n 10000 -c 200 -p post_data.json -T application/json \ -H "Authorization: Bearer 测试用户的token" \ http://localhost:8000/api/seckill/1这个命令表示总共发 10000 个请求,并发 200。压测结束后重点看两处:一是请求的失败率。正常配置下,大部分请求会返回“排队中”或“已售罄”,两者都属于正常业务响应,non-2xx responses里如果出现 500 才是真的有问题。二是核对订单表和 Redis 库存。活动结束后,查一下订单表总订单数,对比 Redis 里剩余库存加已扣库存是否等于最初预热库存。我压测时订单一共创建了 X 条,Redis 剩余库存 Y,X 加 Y 恰好等于预热库存,说明超卖被挡住了。压测结果里最直观的是 Requests per second。在本地开发机上,不加任何防护的秒杀接口可能 RPS 只有几百,加了 Redis 预扣和队列异步后,接口响应很快,RPS 能大幅提升。5.2 我踩过的几个坑,按严重程度排序第一个坑是忘了预热直接压测。压测一开始请求全打到 MySQL,数据库连接数瞬间飙满,导致所有请求都超时。后来我才反应过来,活动开始前必须把秒杀商品、库存、活动时间全部预热到 Redis,接口只从 Redis 读,才能扛住流量。第二个坑是队列 worker 数量不够。库存预扣很快,但 worker 只有 1 个,订单表插入速度完全跟不上,结果出现“Redis 库存已经扣完,但订单表只有一半记录”的假象。这不是超卖,是消费积压。把 worker 开到 5 个以上就恢复正常了。这里也引出一个注意点:Redis 预扣库存只是“占坑”,真正提交订单还要靠队列及时消化。第三个坑是订单重复生成。第一次实现时没加联合唯一索引,队列重试导致同一用户同一个商品生成了多条订单。后来加了uk_user_seckill唯一索引,并在 Job 里做 exists 判断,才彻底解决。第四个坑是 Redis 里的幂等键过早过期。最早我把过期时间设成 30 秒,结果用户从点击按钮到支付完成需要一分钟,支付成功后想再确认订单详情,却被幂等键挡住,提示“请勿重复提交”。后来把过期时间调整到 15 分钟,和订单的expired_at对齐,体验就好了。5.3 从 Demo 到生产还需要补什么东西Demo 能跑通、能扛住本地压测,但离生产还有一段距离。我把主要差距列一下,方便后续扩展:补充项说明库存分片把同一商品库存拆成多个 Redis key,每个 key 服务一部分流量,降低单 key 热点压力多队列隔离秒杀订单队列和普通业务队列分开,普通队列积压不影响秒杀落库消息确认与死信队列消费失败后进入死信队列,人工补偿库存和订单监控告警对 Redis 内存、队列长度、订单积压数量做监控,活动异常时能第一时间发现降级方案Redis 不可用时切换数据库乐观锁方案,保证活动不死但性能下降库存分片这块,简单做法是加一个后缀索引,比如seckill:stock:1:0到seckill:stock:1:9十个分片,用户请求按 hash 取模落到某个分片。每个分片独立扣减,总量就是所有分片之和,能显著降低单点热点。但要注意订单创建后的库存回补也要按分片回补。生产级别的复杂度主要体现在这些边界细节上。最后分享一个我个人的体会:秒杀 Demo 看起来代码量不大,真正难的是理解每个环节为什么这么设计。为什么用 Redis 预扣库存,不是为了炫技,而是为了把数据库从高并发流量中解放出来;为什么订单要异步落库,是为了让用户请求快速结束,避免长时间占用连接;为什么每个环节都要做幂等,是因为分布式系统里“重试是常态”。如果让我重新做一个 Laravel 秒杀 Demo,我会先把数据表设计画清楚,再写一条最简链路:预热库存、Lua 扣减、队列落库、库存回补。链路跑通之后,再把限流、幂等、防刷一层层加上去。按照这个顺序,排查问题会轻松很多,不至于一开始就被一堆概念绕昏头。