1.1 需求拆解:瞬间流量与库存安全
场景:一场活动 30 秒涌进 20 万人
我们第一次做秒杀时的需求很朴素:每周三晚上八点,拿出一批热门手机做秒杀,价格比日常低 800 元,每人限购一台。产品同学拍脑袋说,预估同时在线 5 万人应该够了。结果开场那 30 秒,网关日志显示涌入请求 20 万次,CPU 直接拉满,数据库连接池被瞬时打空,随后就是超卖和雪崩,客服电话被打爆到第二天早上。
事后我们把秒杀和普通电商场景做了对比,差别非常直观,这张对比表后来贴在团队墙上天天看:
| 维度 | 普通电商场景 | 秒杀场景 |
|---|---|---|
| 流量形态 | 白天平稳,晚高峰略涨 | 开场 10~30 秒洪峰,随后断崖下跌 |
| 库存特征 | 深库存,动辄上万件 | 浅库存,往往只有几百上千件 |
| 请求成功率 | 大部分用户能顺利下单 | 99% 用户必然失败(库存不够) |
| 系统风险 | 偶发慢查询、个别超时 | 雪崩、超卖、支付掉单集中爆发 |
| 设计目标 | 提高转化率、提高客单价 | 保住库存、保住系统、输得起 |
这张表决定了后面所有设计:既然 99% 的请求注定失败,那么让它们尽早失败、优雅失败,就是整套系统的核心目标。普通电商优化的是漏斗转化,秒杀优化的是闸门。想明白这一点,很多设计决策就自然浮出水面了:为什么要把活动页静态化?因为首屏里 90% 的内容跟库存无关,根本不需要走到后端。为什么下单要走排队?因为库存只有一千件,排队两万个人和同时挤两万个人,扣减的库存结果一样,但系统压力天差地别。为什么支付要有幂等?因为支付回调重试是常态,秒杀链路任何一环都可能重试。整个系统的架构就是被这三条认知撑起来的。
方案:先把需求拆成三个确定性目标
复盘会上我们把需求拆解成三个必须同时满足的目标,顺序不能颠倒,因为库存安全是底线,系统稳定是前提,用户体验是结果。
目标一:库存绝对不能超卖。 超卖是资损,是客诉,是上新闻。我们定下死规矩:任何时刻库存都不允许出现负数,宁可少卖不可多卖。落到技术上,库存扣减必须有原子性保障,且每天必须有对账机制兜底,这两个要求在 2.2 和 2.3 节分别落地。
目标二:洪峰流量不能打穿系统。 挡在数据库前面的每一层都要有削峰能力:CDN 挡静态流量,限流挡异常流量,排队挡住剩下那部分真实下单请求。能前置失败的就别往后传,越靠后的失败代价越高。
目标三:失败的用户也要有确定结果。 用户点抢购按钮,要么秒中、要么明确提示售罄,绝不允许转圈三十秒最后告诉他排队失败。这对前端倒计时同步和后端库存预热都提出了要求,也是我们后来把活动页静态化挂 CDN 的动因。
围绕这三个目标,我们定下了流量与库存的整体链路,每个环节都对应后面一篇正文:
| 环节 | 手段 | 落点 | 对应章节 |
|---|---|---|---|
| 静态页面 | 活动页静态化 | CDN | 3.1 节 |
| 接入层 | 限流与熔断 | Nginx 与 PHP | 3.2 节 |
| 下单请求 | 排队削峰,异步处理 | 消息队列 | 3.3 节 |
| 库存扣减 | Redis 原子扣减 + 异步落库 | 云数据库 Redis 与 MySQL | 2.2 节 |
| 最终兜底 | 乐观锁 + 定时对账 | 云数据库 MySQL | 2.3 节 |
目标定下之后还有一件事必须落在数字上,那就是容量预估。我们总结了一个速算公式:峰值 QPS 等于预估参与人数除以洪峰持续秒数,再乘以安全系数。第二次活动我们预估 10 万人、洪峰按 30 秒算,得到峰值约 3333,乘以 3 倍安全系数后按 1 万 QPS 设计系统容量。按这个数字去反推各层规格,思路会清晰很多:
| 层级 | 容量依据 | 我们的计算 |
|---|---|---|
| CDN 命中 | 静态请求占 90% 以上 | 1 万 QPS 里约 9000 走静态 |
| 限流后 | 接入层放行约 10% | 约 1000 QPS 到应用层 |
| 应用处理 | PHP 单机约 2500 QPS | 4 台机器绰绰有余 |
| 库存扣减 | Redis 单实例足够 | 全部热点走 Lua 脚本 |
这张速算表后来成为每次大促前评审的第一页,数字对得上才允许上线,对不上就先扩容或者降预期。
配置:先把库存预热这件事做对
需求拆解完,第一个落地动作不是写下单接口,而是把秒杀活动的元数据维护好。我们设计了活动配置表,核心字段如下,注意 limit_per_user 字段是限购的关键:
// migration: 秒杀活动配置表
Schema::create('seckill_sessions', function (Blueprint $table) {
$table->id();
$table->string('name', 64)->comment('活动名称');
$table->unsignedInteger('sku_id')->index();
$table->unsignedInteger('total_stock');
$table->unsignedInteger('limit_per_user')->default(1);
$table->timestamp('start_at');
$table->timestamp('end_at');
$table->enum('status', ['pending', 'live', 'ended'])->default('pending');
$table->index(['status', 'start_at']);
});
活动开始前 10 分钟,定时任务把 total_stock 同步到 Redis 的 seckill:stock:{sku_id} 键里,这一步叫做库存预热。预热之后,下单链路全程只认 Redis 里的数字,MySQL 只承担最终扣减与对账。好处是洪峰期间数据库压力趋近于零,坏处是多了一致性成本,这部分在 2.3 节专门讲。
// 库存预热:活动开始前由定时任务执行
public function preheat(int $sessionId): void
{
$session = SeckillSession::findOrFail($sessionId);
$key = 'seckill:stock:' . $session->sku_id;
Redis::set($key, $session->total_stock);
Redis::del('seckill:bought:' . $session->id); // 清空上次残留的已购集合
}
踩坑:三个我们交过学费的坑
坑一:预估流量按平均值算。 第一次活动我们按 5 万人同时在线做压测,线下来的是 20 万次请求峰值。后来所有容量评估都按峰值的 3 到 5 倍来算,这也是 1.3 节弹性扩容与 5.1 节压测演练的依据。
坑二:库存预热只同步了数量,没预热已购集合。 第二场活动有用户换号重复下单,一个人抢了三台。后来我们用 Redis 的 sadd 记录已购用户,下单前先 sismember 判重,限购才算真正落地。
坑三:活动开始时间以各客户端时间为准。 有用户手机时间快了两分钟,提前提交订单成功,活动方反而被动。解决办法是接口层用服务器时间校验活动状态,前端倒计时一律从服务端拉取基准时间,详情见 3.1 节。
坑四:库存预热的时间点卡得太死。 一开始我们把预热定在活动开始前 1 分钟,结果有一次配置变更晚了一分钟传到运营手里,预热任务跑空,活动开场时 Redis 里根本没有库存键,第一批请求全部直击 MySQL。后来预热改成分批次执行:开始前 30 分钟预跑一次、前 5 分钟复核一次、开场前 10 秒做最后一次校验,任何一次发现 Redis 键缺失立即告警并回捞 MySQL 数据。
小结
需求拆解的核心是想清楚三件事:流量长什么样、库存怎么保护、失败用户看到什么。把 99% 注定失败的请求尽早挡在前面,是秒杀系统一切设计的出发点。库存预热、限流、排队、异步落库这四板斧,贯穿全书后面的每一篇。把这几件事想透了,哪怕团队只有五个人、用的还是 PHP,也能把秒杀做得稳稳当当;想不透,换什么语言都是白搭。最后分享一个我们用了一年才悟出的道理:需求拆解的产出物不是文档,而是一张容量算式与一份失败清单。容量算式让机器预算有据可依,失败清单写得越详尽,当晚的预案就越完整。这两样东西会在后续每一章里反复出现,成为整个项目最值钱的两份资产。有趣的是每次给新入职同学讲需求拆解,他们反馈最有用的也正是这两份清单,说起到了定心丸的作用:知道最坏的情况都被想到了,写代码时心里就有了底。这两份清单我们一直维护到今天,每场大促后更新一轮,成了项目最有生命力的两份文档。把它们钉在团队墙上,比任何 Presentation 都好用。
