秒杀这个场景,做后端的朋友迟早都会碰上。库存就一百件,瞬间涌进来几万请求,既要保证不超卖,又不能让数据库被打崩,还得让用户觉得流程顺畅——这三个需求往那一摆,技术方案就没那么好拍了。这篇文章记录我自己用 Laravel 从零写的一套完整秒杀 Demo,核心思路是 Redis 预扣减 + 队列异步落库,把每个环节的选型逻辑和背后原理都拆开讲,适合已经掌握 PHP 和 Laravel 基础、想深入了解高并发处理方案的开发者参考。
1. 秒杀系统的核心矛盾与整体方案选型
1.1 一上来就要面对的三个核心问题
先不急着写代码,我习惯先把问题摆清楚。秒杀系统之所以难,不是因为业务复杂,而是因为它在极短时间里把三个矛盾同时放大了。
第一个是超卖问题。库存只有 100 件,如果来了 5000 个请求,系统必须保证最终最多只有 100 个人下单成功。这个逻辑听起来简单,但在高并发下,常规的“先查库存再扣库存”两步操作一定会出问题。两个请求同时读到库存为 1,都认为还有货,同时执行扣减,最后库存变成 -1,这就是典型的超卖。
第二个是性能问题。秒杀的核心操作是库存扣减和订单创建,如果所有请求都直接打到 MySQL 上,数据库会瞬间成为瓶颈。100 件库存的商品,几万人同时抢,数据库连接池很快被打满,行锁竞争激烈,响应时间急剧上升,最终必定雪崩。
第三个是用户体验问题。如果一个请求需要同步等待数据库写入完成再返回结果,下单接口的平均响应时间会非常高。用户点了半天没反应,就会疯狂重试,这又进一步加剧了系统压力。
针对这三个矛盾,我最终确定的设计思路是:流量漏斗 + 异步削峰。请求进来之后,先经过一层层拦截过滤,真正能走到下单逻辑的请求数量会被控制在一个很小的范围内,然后通过队列把创建订单这种耗时操作异步化。
1.2 库存扣减方案对比:为什么最终选 Redis
库存扣减是整个秒杀系统的命脉,我把市面上常见的方案都过了一遍,做了个对比。
| 方案 | 防超卖 | 并发能力 | 一致性 | 实现复杂度 |
|---|---|---|---|---|
| 数据库直接 UPDATE 扣减 | 可以防,需要配合条件判断 | 低,行锁竞争严重 | 强一致 | 低 |
| 数据库乐观锁版本号 | 可以防 | 低,高竞争下失败率高 | 强一致 | 低 |
| 数据库悲观锁 SELECT FOR UPDATE | 可以防 | 很低,大量请求排队等待锁 | 强一致 | 低 |
| Redis 原子扣减 + 异步落库 | 可以防(配合 Lua 脚本) | 高,内存操作,单线程原子性 | 最终一致 | 中 |
| Redis 队列串行扣减 | 可以防 | 高,但吞吐受队列消费速度限制 | 最终一致 | 中 |
直接操作数据库的方案,逻辑最简单,但性能天花板太明显。秒杀场景下,同一行库存记录的更新请求会全部串行排队,数据库的连接数和锁等待时间根本扛不住。
我最终选择了 Redis 原子扣减 + 异步落库的组合方案。这个方案的核心思路是:用 Redis 承载高并发的库存扣减操作,扣减成功后把创建订单的任务丢进消息队列异步执行。Redis 是单线程模型,基于内存操作,配合 Lua 脚本可以做到严格的原子性,同时它的并发能力远高于关系型数据库。
提示:这里说的最终一致性,是指 Redis 里的扣减操作和 MySQL 里的订单创建不是同一时刻完成的。正常情况下差值只存在于毫秒级,但需要有一套补偿机制来保证两边最终对齐。这一点后面专门讲。
1.3 请求漏斗模型:每一层各司其职
一个秒杀请求从进来到真正下单,我的 Demo 里划分了五层漏斗:
第一层是前端限制。按钮点击后立即置灰,短时间内不允许重复提交,避免用户在页面端就产生大量重复请求。
第二层是应用层限流。用令牌桶或滑动窗口限制每个用户对秒杀接口的访问频率。即使有人绕过前端用脚本刷接口,也能在这里被拦截。
第三层是业务校验。检查活动是否存在、是否在有效期内、当前用户是否已经抢购过。
第四层是库存扣减。通过 Lua 脚本在 Redis 里完成库存的原子扣减,这也是整个系统的核心防线。
第五层是异步下单。扣减成功的请求进入队列,由消费者进程在数据库中创建订单并写入秒杀记录,失败则触发补偿。
每一层都在主动丢弃非法流量,最终真正进入数据库的请求量基本上就和库存量持平,数据库的压力自然就降下来了。
2. 表结构设计与库存预热
2.1 数据库表结构详解
数据库设计这块,我坚持一个原则:秒杀活动相关的字段不要直接堆在商品表里,活动是活动、商品是商品,分开建模。这样可以避免秒杀活动修改商品数据,也方便一个商品在不同时间参加多次活动。
我设计了四张核心表:商品表、秒杀活动表、订单表、秒杀记录表。
CREATE TABLE products ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL COMMENT '商品名称', stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '总库存', price DECIMAL(10,2) NOT NULL COMMENT '原价', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE seckill_activities ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', seckill_price DECIMAL(10,2) NOT NULL COMMENT '秒杀价', stock_limit INT UNSIGNED NOT NULL COMMENT '秒杀库存', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, KEY idx_product_id (product_id), KEY idx_start_time (start_time), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动表'; CREATE TABLE seckill_orders ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单号', user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', activity_id BIGINT UNSIGNED NOT NULL COMMENT '秒杀活动ID', seckill_price DECIMAL(10,2) NOT NULL COMMENT '实际支付价', status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_user_activity (user_id, activity_id), KEY idx_activity_id (activity_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表'; CREATE TABLE seckill_records ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, activity_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0排队中 1成功 2失败', created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, UNIQUE KEY uk_user_activity (user_id, activity_id), KEY idx_activity_id (activity_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀记录表';有两个细节值得重点说。
第二个是 sec_kill_orders 表里的唯一索引 uk_user_activity,约束了同一个用户对同一个活动只能创建一条订单。这是数据库层面防重复下单的最后一道保险。
第四个是 order_no 订单号用了 VARCHAR(32),在高并发场景下可以用 Redis INCR 生成自增序号,再和用户ID、时间戳组合成全局唯一订单号,避免走数据库自增主键来做业务标识。
2.2 Laravel 项目初始化与迁移文件
我用的是 Laravel 11,整体步骤还算简单。先创建项目,然后配置数据库和 Redis 连接。
composer create-project laravel/laravel seckill-demo cd seckill-demo.env 文件里需要重点关注几个配置项:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=seckill DB_USERNAME=root DB_PASSWORD=your_password QUEUE_CONNECTION=redis CACHE_STORE=redisQUEUE_CONNECTION 设置成 redis,Laravel 队列系统就会用 Redis 作为队列驱动,这样异步下单任务才能真正跑起来。CACHE_STORE 也用 redis,方便在业务代码里直接操作缓存和实现原子计数。
迁移文件直接用 artisan 命令生成,然后把上面 SQL 里的建表语句转成 Laravel 的 Schema 定义就行。我个人的习惯是迁移文件里必须加上索引和唯一约束的定义,这对后续的并发正确性和查询性能都非常重要。
2.3 库存预热:为什么要在活动开始前把库存放进 Redis
秒杀接口里的每一个请求都会读取和扣减库存,如果这个操作发生在 MySQL 上,数据库扛不住。所以我的方案是提前把 seckill_activities 表里的 stock_limit 写入 Redis,后续所有库存操作都走 Redis。
预热有两个关键点。
第一个是同步什么内容。我直接以活动 ID 作为 Redis 的 key,结构设计如下:
- seckill:stock:{activity_id}:剩余库存计数器,整数值
- seckill:order_users:{activity_id}:已抢购用户 ID 的集合
- seckill:result:{user_id}:{activity_id}:每个用户的秒杀结果状态,缓存给前端轮询
预热代码放在一个 Artisan 命令里,典型的做法是活动上线前手动执行,或者监听某个内部事件后触发。我在 Demo 里直接写了一个命令,传入活动 ID 就完成预热。
php artisan seckill:preheat 1命令内部的逻辑很简单:从数据库读取活动信息,判断是否在有效期内,然后取出 stock_limit 值写入 Redis,同时把 activity 的信息也缓存一份,设置过期时间为活动结束时间。
注意:预热之后,Redis 里的库存才是“真库存”,数据库里的 stock_limit 只是基准值。后续的一切判断都基于 Redis 的实时计数。所以预热动作必须在活动真正开始之前执行完,活动开始后再改库存配置就是事故现场。
过期时间这一点容易忽略,我踩过坑。如果不给库存缓存设置过期时间,活动结束后 Redis 里残留的 key 会一直占用内存。正确的做法是把 TTL 设置为活动结束时间加一段缓冲时间,让 key 在活动结束后自动清理,同时这段缓冲时间也足够让对账脚本完成数据核对。
3. 秒杀接口核心逻辑与异步下单实现
3.1 路由与控制器骨架
接口设计成标准的 POST 请求,用户必须登录。Laravel 里用内置的 auth 中间件做用户认证,业务中间件做限流和其他校验。
Route::middleware(['auth:api', 'throttle:60,1'])->group(function () { Route::post('/seckill/{activityId}', [SeckillController::class, 'handle']); Route::get('/seckill/result/{activityId}', [SeckillController::class, 'result']); });throttle:60,1 表示这个接口每个 IP 每分钟最多请求 60 次,这是应用层的粗粒度限流。更细粒度的,每个用户每秒最多 1 个请求,我在控制器里通过 Redis 实现,后面细讲。
控制器的 handle 方法里,整体流程是这样的:
public function handle(Request $request, int $activityId) { $user = $request->user(); // 1. 活动是否存在、是否在有效期内 $activity = $this->getActivityFromCache($activityId); if (!$activity || $this->activityNotStarted($activity)) { return response()->json(['code' => 40001, 'message' => '活动未开始']); } if ($this->activityEnded($activity)) { return response()->json(['code' => 40002, 'message' => '活动已结束']); } // 2. 用户级限流:同一个用户单位时间内只能请求一次 if (!$this->allowRequest($user->id, $activityId)) { return response()->json(['code' => 40003, 'message' => '请求过于频繁']); } // 3. 通过 Lua 脚本原子扣减库存 $result = $this->seckillWithLua($user->id, $activityId); if ($result['success']) { // 4. 扣减成功,投递到异步队列创建订单 SeckillOrderJob::dispatch($user->id, $activityId) ->onQueue('seckill_order'); return response()->json([ 'code' => 0, 'message' => '排队中', 'data' => ['queue_token' => $result['token']] ]); } if ($result['code'] === 'repeat') { return response()->json(['code' => 40004, 'message' => '您已参与过该活动']); } return response()->json(['code' => 40005, 'message' => '商品已抢完']); }这段代码把所有业务检查都放在最前面,目的很明确:能挡掉的请求绝不放行到库存扣减环节。活动未开始、活动已结束、用户重复参与、请求频率过高,这些都是成本极低的过滤条件,可以让大量无效请求在早期就被拦截掉。
3.2 Redis 限流:令牌桶的简易实现
前面 Laravel 自带的 throttle 中间件是基于 IP 的粗粒度限流,但秒杀场景还需要更细粒度的用户级限流,保证同一个用户不会在极短时间里刷出大量请求。
我用 Redis 实现了一个基于时间的滑动窗口限流。核心思路是记录用户首次请求的时间,在一个固定的时间窗口内,如果请求次数超过阈值就直接拒绝。
private function allowRequest(int $userId, int $activityId): bool { $key = 'seckill:limit:' . $userId . ':' . $activityId; $window = 1; // 窗口时间,单位秒 $maxRequests = 1; // 窗口内允许的最大请求数 $current = Redis::get($key); if (!$current) { Redis::setex($key, $window, 1); return true; } if ((int)$current >= $maxRequests) { return false; } Redis::incr($key); return true; }这个实现胜在简单:每个用户对每个活动每秒最多只能发一个请求。窗口过期后 key 自动消失,用户又可以继续抢。它的作用不是阻止刷单,而是把单用户的请求频率限制在合理范围内,防止用户端脚本疯狂重试压垮系统。
令牌桶算法会更平滑,但在这个场景里,滑动窗口的简单实现已经足够满足需求,代码可读性也更好。我建议你把限流参数做成可配置的,上线前根据压测结果调整。
3.3 核心防线:用 Lua 脚本保证库存扣减原子性
现在到了整个秒杀系统最关键的部分。为什么非得用 Lua 脚本?因为 Redis 是单线程处理命令的,Lua 脚本在 Redis 里执行时是原子性的,也就是说脚本中间不会被其他命令插入,整个脚本作为一个整体执行完。这就避免了“先 GET 再 DECR”两步操作之间被其他请求插入导致超卖的问题。
我设计的 Lua 脚本做了三件事:检查剩余库存、检查用户是否已经抢过、扣减库存并记录用户 ID。这三个操作在一个脚本里完成,保证原子性。
-- KEYS[1] 库存 key -- KEYS[2] 已抢用户集合 key -- ARGV[1] 用户 ID -- ARGV[2] 扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[2]) then return -1 -- 库存不足 end local isMember = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if isMember == 1 then return -2 -- 重复下单 end redis.call('DECRBY', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return 1 -- 扣减成功Laravel 里通过 Redis::eval 调用脚本:
private function seckillWithLua(int $userId, int $activityId): array { $stockKey = 'seckill:stock:' . $activityId; $userSetKey = 'seckill:order_users:' . $activityId; $script = <<<'LUA' local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[2]) then return -1 end local isMember = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if isMember == 1 then return -2 end redis.call('DECRBY', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return 1 LUA; $result = Redis::eval($script, 2, $stockKey, $userSetKey, (string)$userId, 1); if ($result == 1) { $token = md5($userId . '_' . $activityId . '_' . microtime(true)); Redis::setex('seckill:token:' . $userId . ':' . $activityId, 300, $token); return ['success' => true, 'token' => $token]; } if ($result == -2) { return ['success' => false, 'code' => 'repeat']; } return ['success' => false, 'code' => 'sold_out']; }Redis::eval 的第一个参数是脚本内容,第二个参数是 KEYS 的数量,后面依次传 KEYS 和 ARGV。这个顺序很容易搞错,我刚开始写的时候就没注意,结果脚本一直拿不到正确的参数值,调试了半天。Laravel 的 eval 方法参数是 Redis::eval($script, $numberOfKeys, $key1, $key2, $argv1, $argv2),务必按这个顺序传。
提示:Lua 脚本里通过 SISMEMBER 检查用户是否已经在集合里,这一步相当于把“一人一单”的判断前置到了 Redis 层。即使数据库的唯一索引出了意外,Redis 也会先挡住重复请求,双重保险。
这里有一个很多人会问的问题:为什么库存扣减成功不直接创建订单,而是丢到队列里异步执行?
答案很简单:如果你的接口在扣减库存后还要执行插入订单、操作数据库、更新缓存这一堆动作,每个请求的耗时可能从几毫秒涨到几十毫秒甚至上百毫秒,接口的吞吐量直接下降一个数量级。秒杀场景下,用户点一下按钮,系统能快速告诉用户“你在排队了”,远比让用户傻等接口执行完所有操作体验要好得多。异步化把“抢购资格确认”和“订单处理”解耦了,前者必须快,后者可以慢一点,但不能丢。
3.4 队列消费者:异步创建订单
扣减成功只是拿到了“下单资格”,真正把订单写进数据库的是队列消费者。我在 Demo 里定义了一个 Job,负责在数据库事务中创建订单和写入秒杀记录。
class SeckillOrderJob implements ShouldQueue { public function __construct( public int $userId, public int $activityId ) {} public function handle(): void { DB::transaction(function () { $activity = SeckillActivity::lockForUpdate()->find($this->activityId); if (!$activity) { throw new \Exception('活动不存在'); } // 数据库层面防重 $exists = SeckillOrder::where('user_id', $this->userId) ->where('activity_id', $this->activityId) ->exists(); if ($exists) { return; } $order = SeckillOrder::create([ 'order_no' => generateOrderNo($this->userId), 'user_id' => $this->userId, 'product_id' => $activity->product_id, 'activity_id' => $this->activityId, 'seckill_price' => $activity->seckill_price, 'status' => 1, ]); SeckillRecord::updateOrCreate( ['user_id' => $this->userId, 'activity_id' => $this->activityId], ['status' => 1] ); // 秒杀结果写入缓存,供前端轮询 Redis::setex( 'seckill:result:' . $this->userId . ':' . $this->activityId, 600, json_encode(['success' => true, 'order_id' => $order->id]) ); }); } }这个 Job 里有个容易忽略的点:我在事务里用了 lockForUpdate,也就是行级悲观锁。为什么 Redis 里已经做了原子扣减,数据库这里还要加锁?因为数据库是最终一致性的落地点,必须保证多个并发的队列任务在处理同一个活动时不会产生脏数据。虽然 Redis 已经确保抢购资格唯一,但数据库层仍然可能出现重复创建订单的极端情况,比如队列任务被重试了两次、或者消费者进程处理同一个消息时发生重复消费。行级锁配合唯一索引,把这种重复消灭在数据库层面。
还有一个设计细节值得注意:秒杀记录表和订单表是分开的。秒杀记录表在 Redis 扣减时就该有初始状态,只是我简化为在 Job 成功后才写入。如果你的场景需要用户在排队阶段就能看到状态,就应当在 Lua 脚本扣减后先写一条“排队中”状态的记录,再在 Job 里更新为“成功”或“失败”。
队列消费者如果失败,Laravel 默认会重试三次,每次间隔递增。如果三次都失败,任务会进入 failed 队列。我的建议是给 failed 队列加上告警通知,因为失败的订单任务业务上通常需要人工介入,否则用户抢到了资格但订单没生成,第二天投诉就来了。
3.5 秒杀结果轮询接口
用户拿着 queue_token 在前端轮询,其实本质上是在查 Redis 里缓存的结果。我单独写了一个 result 接口,逻辑非常轻:根据当前用户和活动 ID 从 Redis 读结果缓存,有值就返回具体结果,没值就告诉前端“还在排队中”。
public function result(Request $request, int $activityId) { $userId = $request->user()->id; $key = 'seckill:result:' . $userId . ':' . $activityId; $cached = Redis::get($key); if (!$cached) { return response()->json(['code' => 0, 'message' => '排队中']); } $data = json_decode($cached, true); return response()->json([ 'code' => $data['success'] ? 1 : 2, 'message' => $data['success'] ? '抢购成功' : '抢购失败', 'data' => $data ]); }前端轮询的间隔我建议设计成递增式:前三次每 500ms 查一次,之后每 2 秒查一次,最多轮询 30 秒。轮询本身就是一种请求压力,如果几万个用户同时以 500ms 的频率轮询,接口同样会被打爆。递增间隔可以在不影响用户体验的前提下,大幅降低接口压力。
这里还需要补充一种情况:用户抢购成功后因为订单创建失败导致结果状态为失败。这种情况下,Redis 里的库存已经扣掉了,但真正的订单并没有生成。处理方式是在补偿流程里做库存回补,后面我在数据一致性章节里统一说明。
4. 防超卖、数据一致性与缓存异常处理
4.1 层层设防的超卖防线
很多人觉得防超卖只要 Redis 扣减做对了就够了,这种想法很危险。任何一个环节都有可能出问题,真正靠谱的方案是每个环节都有一道防线,独立运作、互不依赖。
第一道防线是 Redis Lua 脚本的原子扣减。前面已经讲了,SISMEMBER 挡重复、DECRBY 扣库存、SADD 记用户,一气呵成,这是性能最高的一道防线,也是正常情况下唯一会触发库存扣减的地方。
第二道防线是 Redis 里记录已抢用户集合。就算脚本因为某些原因漏掉了 SISMEMBER 检查,后面 SADD 命令如果发现用户已经存在,返回值是 0,我们同样可以基于这个判断来识别重复请求。实际代码里我没有单独判断 SADD 的返回值,因为 SISMEMBER 的检查已经足够,但如果你想做得更严谨,可以把 SADD 返回值作为第二次验证。
第三道防线是数据库唯一索引。seckill_orders 表里的 (user_id, activity_id) 唯一索引,保证了即使队列消息被重复消费,数据库也只会创建一条订单记录。Job 里的 updateOrCreate 配合唯一索引,天然防重。
注意:数据库唯一索引在生产环境是绝对必须的。它看起来和数据表设计有关,但在分布式场景下,这是最后一道绝对可靠的防线,任何代码逻辑都可能因为 bug、网络超时、消息重复投递而失效,唯一索引不会。
4.2 Redis 与 MySQL 的数据一致性:补偿与对账
Redis 扣减和 MySQL 落库之间存在时间差,这就产生了数据一致性问题。正常的流量下,Job 消费完成后两边就对齐了,但异常情况仍然会发生,比如 Job 重试三次都失败、Redis 扣减成功但用户在下单过程中取消了请求。
我的处理方案分两层。
第一层是队列重试机制。Laravel 队列默认对失败任务会重试,通过 retry_after 和 attempts 参数控制。对秒杀订单这种不能丢的任务,我会把 attempts 设到 5 次,每次重试间隔逐渐拉长。重试完成后如果还是失败,任务进入 failed 队列,通过监听队列失败事件发送告警,然后人工介入。
第二层是定时对账。活动结束后,跑一个对账脚本,统计 Redis 库存扣减数量和 MySQL 订单数量,两边一对比就能发现差额。如果 Redis 扣了 90,订单只生成了 85,说明有 5 个用户拿到了抢购资格但订单失败,这时需要执行库存回补,把这 5 件库存释放回数据库的 stock_limit 中。
这里补充一个细节:Redis 扣减成功但用户主动放弃支付,这种场景怎么处理。秒杀订单通常有支付时效,比如 15 分钟未支付自动取消。订单取消后,释放的库存是否回到 Redis 的实时库存中?我的做法是不回,秒杀库存和普通库存的区别就在于此,秒杀活动就是要在极短时间内清空库存,释放出来的库存如果重新放回 Redis,可能会引发又一轮抢购风暴,对系统不利。
4.3 缓存穿透、击穿与雪崩在秒杀场景的表现
秒杀系统的核心是 Redis,Redis 本身也会面临一些经典问题,这里必须提前处理。
缓存穿透是指请求的数据在 Redis 和 MySQL 里都不存在,比如攻击者拿着一个不存在的活动 ID 疯狂请求,每次都会穿透到数据库。处理的办法有两个思路:一是接口层校验活动 ID 是否合法,二是对查询结果做空值缓存,把空值也缓存 60 秒,防止同样的无效请求反复打到数据库。秒杀场景里,我建议两个都做,第一道校验过滤大部分无效请求,空值缓存兜底。
缓存击穿是指一个热点 key 在过期瞬间,大量请求同时发现缓存失效,然后全部打到数据库。秒杀活动的库存 key、活动信息 key 都是典型的热点 key 候选。解决办法是在业务允许的情况下不给这类 key 设置过期时间,或者只设置一个很长的过期时间,活动结束后手动清理。很多人担心 Redis key 不设置过期时间会一直占内存,但秒杀活动 key 数量有限,一个 key 占几字节,活动结束后定期清理就可以了,这种代价是完全可以接受的。
缓存雪崩是指大量 key 在同一时间过期,导致流量全部落向数据库。秒杀场景下,同时有多个活动上线时,多个 key 的过期时间如果完全一致,就存在雪崩风险。解决办法很简单:设置过期时间时加上一个随机偏移量,比如固定的活动结束时间加上 0 到 300 秒的随机数,让 key 的过期时间错开。
5. 压测、问题排查与上线前检查清单
5.1 本地压测思路与预期指标
Demo 写完,一定要跑一轮压测验证并发正确性。我的压测方案是同时模拟 200 个并发用户,对同一个活动发起秒杀请求,看最终生成的订单数和 Redis 里的库存余量是否对齐。
压测工具我用过 ab 和 wrk,简单起见选 ab 就够了。压测命令如下:
ab -n 10000 -c 200 -p post_data.txt -T application/json http://localhost:8000/api/seckill/1压测过程中重点关注三个指标:接口返回 5xx 的比例是不是为 0、Redis 库存是否为 0、数据库订单总数是否和库存初始值一致。这三个指标全绿,说明核心逻辑在并发压力下仍然正确。
我实测下来的数据,Laravel 配合 Redis 队列,单机压测 QPS 稳定在 2000 左右没什么问题,接口响应时间 P99 在 50ms 以内。数据库层面,由于真正写入订单的任务被队列削峰了,MySQL 的负载很低,连接池基本没有压力。
潜伏的风险点是 Laravel 默认的队列消费进程数量太少。本地调试时跑 queue:work 一个进程就够了,但压测或者生产环境必须用 queue:work --sleep=1 --tries=5 --max-time=3600 加上多个进程。用 Supervisor 管理队列进程时,我建议普通机器上起 4 到 8 个进程,每秒能消费的订单数就是几百单,足够应对大多数秒杀活动。
5.2 常见问题排查速查表
我在开发这个 Demo 时踩了不少坑,整理成一张表,方便后面直接对照排查。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 库存扣减后订单没生成 | 队列消费失败 | 查看 failed_jobs 表,确认是否重试耗尽;检查 Job 里是否抛异常。 |
| 用户重复下单 | Lua 脚本未生效或 Redis 集合被清 | 确认 Redis key 还在,SISMEMBER 是否能查到用户;检查数据库唯一索引是否生效。 |
| 接口返回“活动未开始”但仍然扣库存 | 活动时间判断逻辑有误 | 确认活动 start_time 是否早于当前时间;检查缓存里活动信息的 TTL。 |
| 压测时 Redis 连接超时 | 连接池太小或 Redis 达到瓶颈 | 调大 redis 连接池 max_connections;检查 Redis 实例规格。 |
| 队列消息积压 | 消费者进程太少 | 增加 queue:work 进程数;检查是否有慢查询阻塞消费。 |
| 秒杀结果一直“排队中” | 轮询接口读不到结果缓存 | 检查 Job 完成后是否正确写入 result key;确认 key 过期时间是否太短。 |
| 活动结束后 Redis key 残留 | 没有设置过期时间或 TTL 过长 | 统一在预热时按活动结束时间 + 缓冲时间设置 TTL。 |
| 数据库行锁等待时间长 | 订单创建事务里锁范围过大 | 检查 Job 里事务是否包含不必要的查询和更新;尽量缩短事务执行时间。 |
5.3 上线之前必须检查的几件事
Demo 能在本地跑通,距离真正上线还有一段路。我整理了一份自己的检查清单,每次做的项目上线前都过一遍。
第一,库存预热要在活动开始前完成,并且要有监控确认 Redis 里库存值已经就位。预热这个操作可以做成定时任务,但必须考虑时钟漂移和任务失败的情况,预留 10 分钟缓冲。
第二,确认队列消费者进程数量足够了。很多团队上线前忘记检查 Supervisor 配置,结果活动一开始,队列积压成百上千条消息,用户看到的结果全是“排队中”,体验直接拉垮。
第三,全局日志和监控要配好。给每个秒杀请求分配一个 request_id,从入口到队列消费全程携带,出问题的时候才能快速定位是网络、应用还是 Redis 的故障。监控至少要覆盖 QPS、接口响应时间、Redis 内存和命中率、队列积压数量、失败任务数量这五个指标。
第四,准备应急预案。秒杀活动最容易出现的情况是流量远超预期,导致 Redis 集群 CPU 飙升。我习惯提前配置好限流阈值和手动降级开关,一旦发现 Redis 压力过大,可以快速把限流阈值下调,甚至直接关闭秒杀入口,保系统不死,活动还有机会再上。
一些实操中的个人体会
前前后后写过几版秒杀 Demo,我最想提醒后来者的一件事是:不要过早优化,先把核心链路跑通。当初我第一版用了分布式锁、接口签名、风控模型,半天过去了一行代码没写对。后来静下心,先把 Redis 扣减和队列落库这条主线打通,再一步步往上叠加防护,反而快得多。
另外还有一个值得记住的思路:秒杀系统拼的从来不是某一个点上的花哨技术,而是整条链路上每一层都做到位。前端拦住大量重复点击,应用层限流拦住脚本请求,Redis 用原子操作挡住超卖,队列把数据库压力削峰,补偿机制兜底异常。每一层管好自己这一摊事,合在一起才能稳。
这个 Demo 后续还有几个方向可以扩展,比如引入 WebSocket 推送秒杀结果、增加优惠券发放、对接支付流程。骨架不变,往里面填业务就够了。希望这篇文章能给你一个足够清晰的起点。