3.1 接入层:CDN 与静态化首屏
场景:首屏加载 8 秒,用户以为活动没开始
第一版秒杀活动页是纯动态渲染:每次打开都要查商品、查库存、查活动状态,后端跑十几个查询拼成一个页面。洪峰那 30 秒,活动页接口的 P99 耗时冲到 8 秒,大量用户盯着菊花转,还以为活动没开始,不断刷新又进一步叠加流量,形成恶性循环。事后我们算了一笔账:活动页 90% 的内容在活动开始后根本不变,每次请求都重新渲染一遍,是纯粹的浪费。
方案:静态化之后挂 CDN
改造方案分三步。第一步把活动页拆成静态骨架与动态数据:标题、图片、规则文案、倒计时基准时间全部静态化,库存余量与按钮状态走一个极轻量的动态接口。第一步拆分本身有个方法论可以分享:把页面上所有字段写在白板上,逐个问一句它在活动期间会不会变,会变的写进动态接口清单,不会的留在静态页。我们第一次做这个练习时发现二十几个字段里只有三个是真的需要动态获取,其余全部可以静态化。这个比例是反直觉的,多数人没意识到一个页面里真正动态的内容那么少。第二步把静态骨架推到 CDN,用户打开活动页时直接从边缘节点取,连我们的服务器都不用碰。第三步才是动态接口的优化,这属于后面几节的内容。第三步动态接口轻量化的关键,是把验证库存状态的逻辑用一次 Redis 调用完成,能合并的命令合并,能缓存的中间结果缓存。拆分的时候有一个原则特别重要:静态部分与动态部分的边界必须清晰,且边界一旦确定就不再变动。理由是每次边界变动都涉及前后端联调与缓存策略调整,代价不小。我们把活动页的动态部分压缩到只剩三个字段:当前库存状态、用户是否已参与过本场活动、下单令牌。少一个字段就要多一次渲染,多一个字段就要多一次接口,三个字段刚好覆盖所有真实需求。这种克制在后续每次加需求时都会被拿出来复习一遍:要加新字段可以,先证明它不是静态的。证明不了的我们就把它做成后端可配的静态内容,而不是让接口动态返回。
改造前后的对比:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 首屏耗时 P99 | 8 秒 | 0.3 秒 |
| 源站 QPS | 全部打满 | 不足峰值一成 |
| 用户体感 | 卡死,反复刷新 | 秒开 |
除了表格里的三个指标,改造还带来了一个意外收获:页面稳定性。改造前每逢洪峰就有用户反馈页面打不开、样式错乱,甚至出现页面加载到一半商品价格没渲染出来被截图投诉的情况。静态化之后这类反馈基本消失,客服那边的工单量直接少了一半。技术改造的价值往往在压测报告之外,用户与客服最先感受到,这些软性收益值得我们写进季报里让老板看到。
CDN 这里用的是腾讯云 CDN,文档入口 cloud.tencent.com/document/product/228。选择它的直接原因是我们其他产品都在腾讯云,CDN 回源到 CVM 走内网链路,延迟和成本都更优。
静态化的收益还体现在一个容易被忽视的指标上:源站的 CPU 负载。改造前洪峰期 PHP 机器的 CPU 长时间跑在 90% 以上,处理的全是重复的活动页渲染;改造后这部分负载归零,同样的机器可以安心处理真正有价值的下单请求。相当于没花一分钱扩容,就把有效容量翻了一倍。我们还顺手把商品图片也全部搬到 CDN,一方面图片流量不再占用源站带宽,另一方面 CDN 自带的图片压缩能力让移动端首屏又快了不少。小团队做架构改造,这种一举多得的事情值得优先做。
| 改造项 | 实施成本 | 收益 |
|---|---|---|
| 活动页静态化 | 一周 | 首屏提速九成 |
| 图片走 CDN | 两天 | 带宽与存储双减负 |
| 静态资源强缓存 | 一天 | 回源量再降一半 |
| 错误页短缓存 | 半天 | 容错不放大故障 |
配置:静态化与回源策略
静态页面的生成由定时任务完成,生成后推送到 CDN,同时保留一份在 CVM 本地作为回源兜底:
class RenderActivityPage
{
public function __invoke(SeckillSession $session): void
{
$html = view('activity.show', [
'session' => $session,
'now' => now()->timestamp, // 倒计时基准从服务端生成
'imageUrl' => cdn_url('assets/activity-bg.png'),
])->render();
Storage::disk('cdn')->put("activity/{$session->id}.html", $html);
Storage::disk('local')->put("activity/{$session->id}.html", $html);
Cache::put("seckill:page_version:{$session->id}", uniqid(), 3600);
}
}
CDN 的缓存策略需要按文件类型区分,静态 HTML 缓存时间短一些,静态资源长一些。这是我们在控制台配的策略:
| 资源类型 | 缓存时长 | 回源策略 |
|---|---|---|
| 活动页 HTML | 60 秒 | 过期回源 |
| 图片与 CSS 与 JS | 30 天 | 强缓存 |
| 动态接口 | 不缓存 | 直回源 |
| 错误页 | 5 秒 | 防止错误被缓存 |
最后一行容易被忽略:如果源站短暂故障,CDN 可能会把 502 错误页缓存住,用户恢复后还看着错误页。设置短 TTL 的错误页缓存可以兜住这个场景。除了错误页,还要注意 CDN 的回源超时时间:默认超时如果比较长,源站抖动时大量请求会挂在回源上,造成比故障本身更严重的体验问题。我们把回源超时调到 5 秒,超过就直接返回兜底页,用户看到的是友好的提示而不是无尽等待。这些细节在 CDN 控制台的边缘配置里都能找到,文档入口 cloud.tencent.com/document/product/228。上线前把每个超时与缓存相关的旋钮都过一遍,是接入层改造的隐藏 checklist。
踩坑:静态化路上的三个坑
坑一:库存余量也静态化进去了。 刚上线时图省事把库存数字写进了静态页,用户看到库存 100 件,点进去发现早已售罄,投诉量暴增。修正方案是静态页里只放一个占位元素,具体数字由轻量动态接口注入,动态接口本身也做了缓存降级,售罄时直接返回兜底文案。还有一个细节是静态页里千万不要写倒计时结束时间这种精确到秒的动态信息,服务端时间与用户本地时间的差异会让倒计时在不同用户之间出现偏差,统一用服务端下发基准时间加客户端本地计数的做法,所有用户看到的活动节奏才一致。这个细节在 1.1 节的需求拆解里也提到过。
坑二:CDN 节点用户获取到旧价格。 运营临时调整秒杀价,刷新命令执行了但节点缓存未过期,部分用户看到旧价。解决办法是大版本号机制:内容变更时页面 URL 带上版本号,强制绕过缓存;同时重要变更走 CDN 的目录刷新接口,文档入口 cloud.tencent.com/document/product/228。这个机制跑通之后,临时调价的操作从战战兢兢变成常规动作,运营同学终于敢在离开场只有十分钟的时候改价格了。
坑三:回源量预估不足。 静态化之后以为高枕无忧,结果 CVM 的回源带宽在活动开场时依然飙满,因为倒计时基准接口每次请求都回源。把基准时间缓存到 CDN 边缘 10 秒,回源量立刻降下来。任何动态内容都应该问一句:它真的每次都需要回源吗。
坑四:CDN 刷新接口被误触发。 有一天晚上运维同学在写自动化脚本时误调了全目录刷新,缓存一瞬间全部失效,数万用户同时回源,源站 CPU 直接拉满。事后给刷新接口加了权限隔离与操作确认,全目录刷新只允许在大促后执行,并且必须有双人复核。CDN 的刷新能力是核武器级别的操作,便利与风险永远并存。
小结
接入层的核心思路是分而治之:不变的交给 CDN边缘,多变的留在动态接口,库存数据留在 Redis。静态化之后,CDN 挡住了九成以上的请求,到达源站的洪峰只有原来的零头,这就是后面所有限流措施能生效的前提。没有 CDN 挡在前面,任何应用层的限流配置都会在真正的洪峰面前显得捉襟见肘,先分流再限流这个顺序不能颠倒。还有一个容易忽略的细节:静态化之后页面里的动态内容并没有消失,而是搬到了异步接口里,所以首屏的可用性其实变成了两个指标,CDN 命中率和异步接口成功率,两者要分开监控、分开定级。我们有一次大促复盘,CDN 命中率在百分之九十九以上,但异步库存接口抖动了几秒,前端页面看着完全正常,下单却在悄悄失败,定位原因花了半个多小时。从那以后我们把静态内容与动态接口的可用性目标分开承诺,页面崩了和接口崩了是两种不同等级的事故,应急预案和通知流程也完全不一样。给接入层的最后一句忠告:静态化改造一定要预留回退开关,我们曾经上了一次激进的缓存策略,发现某些地区的运营商缓存行为异常,靠开关秒级切回动态源站,这个开关救了整场活动。
