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 都好用。

延伸阅读