第 16 章 性能优化与缓存 CDN 深化
副题:先度量,再优化——阶段 2 的首页保卫战
MVP 上线三个月,老周把产品介绍给了朋友,朋友又介绍给朋友。注册用户从几十涨到几百、几千、几万——第 4 章定的预算(首页 P95 < 2s)开始被突破:内容多了、图片多了、同时在线的人多了,首页从"秒开"变成"转圈",高峰期直接打不开。
第四部分我们关注"性能、交易、安全与质量":业务增长之后,系统开始面对"快、对、稳"三件事——本章管"快"(性能),第 17 章管"对"(交易正确),第 18-20 章管"稳"(测试/安全/质量)。
本章要建立的认知
- 先度量,再优化:性能优化的第一步,是找到"哪一站超支"
- 缓存是阶段 2 的主角:多级缓存体系(浏览器/CDN/服务端/数据库)把热数据逐级放近,但每一级缓存都有它的代价——失效、雪崩、穿透;
- 性能优化的终点是**"够用"**:预算内即止,优化是负债管理,不是无底洞。
16.1 首页打不开了
阶段 2 的第一场事故,从老周转来的一条消息开始。
"你们的网站是不是坏了?我这边转圈转了半分钟。"
MVP 上线三个月,老周把产品介绍给朋友,朋友又介绍给朋友:注册用户从几十涨到 10 万,日活 1 万,内容量百万级。增长是好事,坏消息跟着来了——晚高峰,2000 人同时在线刷首页,有人等 3 秒,有人等 5 秒,最倒霉的等半分钟。用户说"转圈",监控说"P95 2 秒+、峰值 5 秒"——同一个问题,用户的语言和数据的语言。
把数据摆出来:
| 指标 | 阶段 1(验收时) | 阶段 2(现在) |
|---|---|---|
| 首页 P95 | < 2 秒(达标) | 2 秒+,高峰 5 秒 |
| 列表接口 P95 | < 200 毫秒(达标) | 1.2 秒 |
| 图片/静态资源 | 40-60 毫秒 | 800 毫秒-2 秒 |
| 同时在线 | 个位数 | 峰值 2000+ |
同一个系统,同一套代码,三个月前达标,三个月后崩了。代码一行没改,变的是流量。预算被突破的那一刻,就是"该管了"的信号——第 14 章决策记录里写的演进条件("首页变慢再上缓存")正式触发。
为什么阶段 1 不上缓存、偏偏现在上?决策记录里写着答案:阶段 1 数据量小、查询毫秒级,缓存省不出收益,还得白付一致性的代价;现在信号出现,当时不上缓存的理由失效了——照着演进条件执行就行,不用重新吵一遍。这就是把决策写下来的回报。
不过,先别急着动手。这个时刻最容易做的两件事,恰好都是错的:
- **没度量就上缓存。**和没诊断就吃药一样——慢的原因都还不知道,药怎么开?
- **先加机器。**瓶颈没定位,加的机器可能根本不忙:下载慢是 CDN 的事,加应用服务器没用;查询慢是索引的事,加机器只是把慢查询多跑几遍。何况扩容的成本是持续的(买机器、要运维,第 23 章),优化的成本是一次性的(索引、缓存配好就不动)——先瘦身,再扩容,加机器是最后的手段(第 24 章会再看到它)。
两条错误路径有一个共同点:都跳过了同一个动作——度量。慢在哪一站都不知道,谈什么治。下一节,先量。
16.2 先度量,再优化:哪一站超支了
先解决"怎么量"。三个工具,对应三种慢法:
| 工具 | 场景 | 看什么 |
|---|---|---|
| 浏览器 DevTools | 单次请求慢 | Network 面板逐段耗时(第 15 章用过) |
| 压测工具(ab/wrk) | 高并发才慢 | 模拟峰值,不用真等晚高峰 |
| 监控日志 | 日常都慢 | P95/P99 曲线,不用人盯 |
量的时候记住一条:平均数是骗人的。"平均 800 毫秒"听着还行?要是 1% 的请求花了 10 秒、99% 只要 500 毫秒,平均下来正好也是 800 毫秒——大多数人很快,少数人卡死,平均数把真相藏起来了。所以性能度量用百分位:P95 是"大多数人的体验",P99 是"最倒霉用户的体验"。第 7 章把接口预算定成 P95,就是因为它比平均值诚实。
量出来的结果,两个热点高亮:
| 站点 | 预算 | 实际 | 差距 |
|---|---|---|---|
| 下载段(图片/静态资源) | 40-60 毫秒 | 800 毫秒-2 秒 | 超了几十倍 |
| TTFB(服务端处理) | 200 毫秒 | 1.2 秒 | 超了 6 倍 |
两个热点,各有各的来历:
- 下载段:图片从第 11 章的 40-60 毫秒涨到 800 毫秒-2 秒。网络没变慢,内容变多了——十万用户上传的图片,还都从源服务器直接拉。第 11 章讲 CDN 原理时埋的账,落地就在本章(16.4)。
- TTFB:第 14 章建了复合索引,但内容量从几百条涨到百万级,查询也变了(按标签筛、按热度排、联合分页),原来的索引覆盖不了新查询——慢查询日志(第 14 章开的)里躺着十几条全表扫描。第 14 章留的作业——索引按查询建——这里续上了:查询变了,索引要跟着变(16.5)。
量出来的各站耗时,顺手记成基线:优化前后各量一遍,才知道改动有没有用;下次再慢,基线告诉你慢了多少。没有基线,"变慢"就只是感觉。
最后是决策:两个热点都超预算,先动谁?先易后难,先大后小——下载段先动,成本低(改 CDN 配置、换图片格式,不动业务代码),收益大(首屏的大头);TTFB 后动,要动索引、上缓存,风险高一截。这个顺序也排定了本章后面几节的走法:16.3 先补一个容易被"下载慢"盖住的隐形热点(浏览器渲染),16.4 结下载段的账,16.5-16.6 结 TTFB 段的账。
16.3 浏览器站:渲染的账
度量表上还有一类热点,容易被"下载慢"盖住:资源到了,渲染卡住。浏览器拿到 HTML/CSS/JS 之后,还有一整段"变成像素"的路(第 8 章的关键渲染路径)——这段路也会慢,而且它的优化和第 8 章是同一张地图。
**第一,JS 执行是主线程上的大头。**第 8 章说过 JS 独占主线程——下载 1MB 的 JS,解析加执行的耗时可能比下载还多。对策是:拆分与懒加载——首屏只加载关键 JS,其余按需加载(滚动到评论区,才加载评论组件)。第 8 章把 CSS 拆了(2MB 事故),JS 同理:包越小,主线程越空。
**第二,减少 reflow/repaint。**性能优化时,批量改 DOM(合并成一次操作),读布局属性不要和写交错(每读一次,浏览器都强制先重新布局)。声明式框架绕开了手写操作,但"批量、避免交错"的纪律,是给所有前端代码的。
**第三,FCP 和 LCP 是渲染的体检指标:**FCP(第一个内容出现)度量"白屏结束",LCP(最大内容出现)度量"主要内容到位"。首屏优化的目标一句话:FCP 尽早(CSS 内联)、LCP 尽早(首屏大图走 CDN)——渲染的指标,一半的解法在网络段,这也是"逐站量"的意义:症状在浏览器,病根可能在前一站。
**第四,框架的渲染也要会手动干预。**第 9 章说过虚拟 DOM 不是天花板:列表不写 key,diff 按位置猜,该复用的节点被重建——性能 bug 的头号来源;框架的默认渲染时机也不是最优的,真到瓶颈时,"跳过 diff、直接操作节点"的手动干预是最后一招。
浏览器的账小结:下载是网络的活,渲染是浏览器的活(本节)——"页面慢"先分这两类,再往下拆。首页打不开的现场,两类症状一样(转圈),解法不同(资源优化 vs 渲染优化)——度量表把它们分开,优化才不会张冠李戴。
16.4 下载段:CDN 深化
先解决热点一——静态资源。这是软柿子:几乎不用改业务代码,收益却最大。
第一步:图片接进 CDN。
图片域名走 CNAME 接入 CDN,源站只保留原图。
CDN 生效后盯一个指标:命中率。90% 以上说明大部分请求在边缘节点就返回了;掉到 60% 以下,要检查 TTL 是不是太短、缓存键设计有没有问题。
缓存策略用"长 TTL + 版本号"。 静态资源可以设很长的 TTL(比如一个月),因为更新不靠清缓存,靠换名字——文件内容变了,文件名带上新的内容指纹(app.a1b2c3.js),浏览器和 CDN 把它当成全新资源拉取;旧 URL 自然没人再访问。
长 TTL 保命中率,版本号保新鲜度。 这是静态资源的最佳实践。
第二步:图片格式和尺寸优化。
同一张图,按内容选格式:照片用 JPEG,透明图用 PNG,能支持就用 WebP(体积小 30% 以上)。
还要出多档尺寸:缩略图 200px、中等 800px、原图 2000px。前端按屏幕大小拉对应档(srcset 一行属性),手机用户不下载原图。
第三步:协议升级。
HTTP/1.1 一个连接同时只能传一个资源,HTTP/2 的多路复用让一个连接同时传多个资源。首页几十个静态文件,这个提升直接作用在下载段。通常服务器上配一行就开启。
URL 本身就是缓存键。
CDN 和浏览器缓存的是"URL → 内容"的映射。URL 不变,就用旧缓存;URL 变了,才拉新内容。 所以版本号放进 URL,就是在设计缓存键。
反过来说:URL 不该变却变了,缓存全失效,白白回源;该变却不变,用户永远看旧内容。
CSS 和字体的小优化。
首屏 CSS 直接内联到 HTML 里,不等外部请求;字体按需加载,用到哪些字符才下载。这是关键渲染路径的日常应用——下载段优化,一半是 CDN,一半是少下载。
效果:这一节做完,首屏时间肉眼可见地回来。 成本几乎为零(配置 + 格式调整),收益最大。
动态接口的账,下一节再算。两类请求两类优化,账单分开记。
16.5 TTFB 段:数据库先动刀
热点二:服务端处理。
第 12 章给数据层留了 150 毫秒预算,现在列表接口 1.2 秒里,数据层占了多少?慢查询日志给出了答案:十几条全表扫描,都是新出现的查询模式——按标签筛(tag 关联表没索引)、按热度排序(ORDER BY hot_score 没索引)、深分页。
慢查询日志怎么读?
不要按"单次耗时"排序,要按**"出现频率 × 单次耗时"**排序。一个每天跑一万次、每次 200 毫秒的查询,比一个每月跑一次、每次 2 秒的查询值得优化一百倍。先修"高频又慢"的,再修"低频但慢"的——优化资源也是预算,花在常走的路上。
走一个 EXPLAIN 实战。
热度排序查询:EXPLAIN SELECT ... ORDER BY hot_score DESC LIMIT 20。type 列显示 Seq Scan——没索引,数据库把 100 万行全扫一遍再排序。补上 hot_score 索引后,type 变 Index Scan,rows 从"扫全表"变成"只读 20 行"。
索引按查询补:
- 标签查询 → 补 GIN 索引(专为数组/JSONB 设计)
- 热度排序 → 补
hot_score普通索引 - 深分页 → 改游标分页:
WHERE hot_score < 上次的值 ORDER BY hot_score DESC LIMIT 20。记住上次翻到哪,而不是每次从头数
查询变了,索引也要跟着变——建索引的目的,就是服务查询。
连接池也要查。 峰值 2000 人同时在线,数据库连接数告急。连接池不是越大越好,每个连接都是数据库的内存和进程负担,阶段 2 先按峰值调到合理值。
进阶解法:汇总表(预计算)。
"首页热度榜"每次实时算,要全表扫+排序。不如定时任务把榜单算好,存一张小表,查询直接读结果。用空间换时间,把"每次算"变成"算一次存着"。 阶段 2 先不急着做——热度榜如果成了核心需求再上,现在先上缓存。
数据库内部还有一层缓存:缓冲池(shared_buffers)。
热数据页会被数据库自动留在内存里,下次查询直接读内存,不用碰磁盘。数据库层的三件日常:索引优化 + 缓冲池 + 慢查询日志。缓存并不始于 Redis——数据库内部就有一层。
这一轮做完,TTFB 从 1.2 秒回到 300 毫秒以内,达到预算线内了。
但晚高峰的 5 秒还没解决。峰值时刻,同一个热门内容被几千人同时读,查询再快也是几千次重复查。索引管的是"单次查询快",缓存管的是"不用重复查"——这就是缓存登场的时刻。
顺序记住:先索引,再缓存。 两个手段,解决两个问题。
16.6 缓存决策:要不要缓存、缓存哪一级
上缓存之前,走一遍决策模型。
业务目标:首页 P95 回到 2 秒以内,高峰期不崩。 约束:10 万用户、1 万日活、内容百万级、热点集中(首页热门内容被读一百次,老内容读零次)、单库。 问题:要不要缓存?存哪一层?存什么?
候选:
- A 不加:索引优化已做完,但重复查询问题无解
- B 浏览器缓存:静态资源已做,动态接口不适合(数据要新鲜)
- C CDN 缓存:适合静态,动态接口回源还是要查库
- D 服务端缓存(Redis):动态数据的正解
评价:收益(省多少查询)、一致性风险(数据会不会旧)、复杂度(引入成本)。 选择:上 Redis,缓存首页聚合与热门内容,TTL 60 秒。
决定使用缓存后,缓存里面存哪些内容呢?
| 类别 | 例子 | 原因 |
|---|---|---|
| 可以缓存 | 首页聚合、热门内容、用户资料 | 读多写少,允许短暂旧 |
| 绝不缓存 | 订单、余额、支付状态 | 旧了就是"显示已支付但实际没到账" |
| 看情况 | 会话凭证 | 短生命周期、天然适合内存,且过期就是它的语义 |
缓存的边界由业务定:这份数据多旧算旧。 交易数据的"旧"不可接受,所以它被排除在缓存之外。
收益与代价
收益:命中率 90% 以上时,列表接口从"查数据库"变成"查内存",P95 从 2 秒+ 回到 200 毫秒以内。
代价:多了一份数据,就要多管一份一致性。Redis 挂了怎么办?缓存是加速层,不是数据层——Redis 挂了,请求直接查数据库,慢,但可用。写代码时就把"缓存取不到就走数据库"的路径留好,这就是降级。
Redis 部署三句话
- 独立进程,和应用分开部署,各自分配内存配额
- 设内存上限 + 淘汰策略(最久不用的先淘汰),防止吃光服务器
- 会话数据开持久化(丢了用户要重新登录),纯缓存可以不开
上线后验证
同一套监控对比上线前后:命中率(90% 以上才算真缓存)、P95、数据库 QPS(降了多少)。缓存加上只是开始,还要度量验证真的有效。
决策记录再添一行:缓存 = 服务端 Redis(首页聚合 + 热门内容,TTL 60 秒),演进条件 = 命中率掉或交易数据需要。
16.7 多级缓存体系
Redis 落地后,多级缓存的全貌终于拼齐了。
老周的请求
│
├─ ① 浏览器缓存(第 8 章):静态资源本地有?直接返回,不发请求
├─ ② CDN(第 11/16 章):静态资源节点有?就近返回,不回源站
├─ ③ 服务端 Redis(本章):动态接口的热数据有?内存直返,不查数据库
└─ ④ 数据库(第 14 章):索引 + 缓冲池,最后的防线
四级缓存,一个原则:命中就停。 请求从第一级开始往下找,哪一级有就直接返回,不再往下走。
为什么必须多级?
因为快和新是矛盾的。 浏览器缓存最快,但浏览器不知道服务器数据变了,只能猜一个过期时间;数据库最准,但每个请求都走到最后,慢。单级缓存解决不了这个矛盾——要么太远(慢),要么太近(旧)。
多级是梯度妥协:浏览器管静态资源(快、旧一点没关系),Redis 管动态热数据(较快、60 秒旧可接受),数据库管写和未命中(最慢、但一定准)。每一级服务它最擅长的数据。
三个日常细节:
第一,TTL 逐级变长。 浏览器本地缓存最长(它最难感知服务器变化),CDN 小时级,Redis 分钟级。越靠近用户,TTL 越长;越靠近数据,TTL 越短。
第二,写操作绕过缓存。 发布内容直接写数据库,缓存靠 TTL 自然过期后重新加载。写直达、读靠过期——阶段 2 的朴素模式。
第三,缓存键的粒度决定失效的精度。 hot:contents 是首页聚合,content:{id} 是单条内容。键越细,改一条内容只失效它自己的键,不影响其他。
命中率是缓存的体温计,每天看三个数:
- Redis 命中率:动态接口挡了多少?掉了说明 TTL 太短或热点分散
- CDN 命中率:静态资源挡了多少?掉了说明缓存策略失效
- 数据库 QPS:多少请求漏到了最后一层?涨了说明缓存没挡住
命中率 90% 以上的缓存是资产,30% 的缓存是负债。 缓存上线只是开始,之后要每天体检。
16.8 缓存的三场事故
缓存上了,三类经典事故跟着来了。它们症状不同,但根源一样:缓存是数据库的副本,副本和正本之间永远有缝隙。
事故一:失效——用户看到旧数据
内容标题改了,缓存里还是老的,用户刷新几次才看到新的。
对策按成本从低到高排:
- TTL 自然过期:60 秒内旧数据可接受,等它自己过期(最简单,不用改代码)
- 主动失效:更新数据库时,顺手删掉对应的缓存键
DEL content:123,下次读取自动重新加载(写直达升级版,实现就一行) - 版本号:和静态资源一样,动态数据也能用(改动大时才上)
一致性是成本的梯度——业务能忍 60 秒旧,就用 TTL;忍不了,就主动失效。
事故二:穿透——缓存成了摆设
有人请求 content/999999,这条内容根本不存在。缓存里没有,数据库里也没有,但每次请求都打到数据库——缓存没挡住不存在的查询。
对策:
- 空值也缓存:查不到时,在缓存里存一个"不存在"的标记,TTL 设短(比如 5 秒)。下次同样请求直接读缓存,不再碰数据库。
- 布隆过滤器(高级):一个极小的位图,先判断"这个 key 可能存在吗",确定不存在直接返回,根本不打缓存也不打数据库。阶段 2 用空值缓存就够了。
事故三:雪崩——数据库瞬间被打垮
所有热门内容的 TTL 同时到点,缓存集体失效,所有请求同时涌向数据库——数据库瞬间被压垮,全站不可用。这是"高峰期 5 秒"的另一种形态,也是最危险的一种。
对策三层防线:
- TTL 加随机抖动:基础 TTL 60 秒,实际每条缓存随机在 50-70 秒之间过期,让失效时间错开,不会整齐划一地集体死亡
- 互斥重建:同一个 key 过期后,只允许第一个请求去查数据库重建缓存,其他请求要么等着,要么直接读旧值。实现上就是一把"正在重建"的锁
- 限流:数据库前面加一道闸(第 12 章预告过,第 24 章展开),即使缓存失效,打到数据库的请求数也是可控的
雪崩是唯一会"杀人"的事故——穿透只是浪费资源,雪崩能让全站挂掉。所以随机 TTL 和互斥重建,缓存上线当天就该配好,不能等发生了再补。
怎么判断是哪场事故?看数据库 QPS 曲线:
| 症状 | QPS 表现 | 事故类型 |
|---|---|---|
| 用户投诉看到旧数据,但 QPS 不高 | 平稳 | 失效(数据问题) |
| QPS 平白升高,命中率没降 | 缓慢爬升 | 穿透(打不存在的 key) |
| QPS 瞬间尖峰,随后断崖 | 脉冲式暴涨 | 雪崩(集体失效) |
先判断是哪场,再动手调。给对的病吃对的药。
Session 入 Redis。
Session 表从 PostgreSQL 挪进 Redis:SET session_id user_id EX 604800。多台服务器共享同一个 Redis,登录态不再绑定单机。
同一份 Redis,两份工作:一是缓存热门内容,二是共享会话。会话也是缓存三问的完美答卷:读写比极高(每次请求都读)、TTL 就是业务语义(7 天登录态)、不存在"旧数据"问题(过期就是登出)。
16.9 性能优化的边界:够用
性能优化的终点是**"够用",不是"极致"**。
预算思维:达标即停。
P95 回到 2 秒以内,够了。为再快 300 毫秒去动架构,是拿稳定性和复杂度冒险——技术债就是这么欠下来的。每一次"更快"都标好了价格,复杂度、稳定性、维护成本,总要付一样。数据层如此,性能优化也如此。
预算要跟着业务滚动。 阶段 2 的 2 秒达标后,阶段 3 交易上线、页面更重,预算会被再次突破。所以预算不是一次定死:每个阶段重新定、验收时重新量、突破后再优化——性能优化的节奏就是一轮一轮的循环。
"快"与"对"在这里再次握手。 缓存加速的代价是数据旧一点(TTL 就是"旧"的度量),索引加速的代价是写入慢一点。所有性能手段都是交易,不是馈赠——度量时不只量"快了多少",也要量"付了什么"。
优化是负债管理。
每引入一个缓存层、一个索引、一种格式,都是为速度欠下的维护债。债要还:TTL 要调、命中率要盯、格式要更新。还不起的债就是技术债。 成熟的标志是知道什么时候停。
"够用"有三个可判断的信号:
- 预算内——P95 达标,不追求极致
- 代价可承受——缓存的一致性成本在业务容忍范围内
- 有余量——给下一波增长留了空间
三个信号齐了,就是当前阶段的最优。任何一个被突破,就是下一轮优化的起点。
性能巡检养成每周习惯:
- 看命中率——Redis/CDN 掉了就查策略
- 扫慢查询日志——有没有新增的全表扫描
- 对预算——P95 有没有悄悄涨
性能问题大多是慢慢爬出来的。 P95 从达标到超标用了三个月,每周三件事就是"在爬坡时发现"。
没有度量,优化是玄学;有了度量,优化是工程。
16.10 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 首页打不开(P95 2s+) | 先度量再优化(旅程逐站量) | 瓶颈没定位,加缓存/加机器都是瞎猜 | 度量要监控数据支撑(第 25 章) |
| 下载慢(图片 800ms-2s) | CDN 深化(接入图片/长 TTL+版本号/格式多档/HTTP2) | 静态资源是软柿子,收益最大成本最低 | 命中率要盯、格式要维护 |
| TTFB 慢(列表 1.2s) | 数据库先动刀(按查询补索引/慢查询/连接池) | 索引是"查询变快",缓存是"不用查" | 索引跟着查询变(维护) |
| 重复查询(热门被读万次) | 服务端缓存 Redis(决策模型走一遍) | 缓存"不用查了"——容量问题的解 | 一致性成本(16.8 三场事故) |
| 多级缓存怎么排 | 浏览器/CDN/Redis/数据库四级 | 越近越快越旧,越近数据越新——逐级下探 | 各级 TTL 不同、键粒度要设计 |
| 缓存与数据不一致 | 失效策略(TTL/主动失效/版本号) | 一致性是成本梯度不是开关 | 60s 旧 vs 主动失效的权衡 |
| 高峰期数据库被打垮 | 雪崩防护(随机 TTL/互斥重建/限流) | 过期时间不该整整齐齐 | 复杂度上升 |
| 优化到什么时候停 | 预算思维(够用即止) | 优化是负债管理,还不起就是技术债 | 留预算给下一波增长 |
本章小结
- 先度量,再优化:没有度量的优化是玄学——定位靠度量,停止也靠度量;
- 缓存是阶段 2 的主角:多级缓存体系把热数据逐级放近,但每一级都有代价——失效/穿透/雪崩三场事故;
- 性能优化的终点是**"够用"**:优化是负债管理,不是无底洞——预算留给下一波增长;
- 阶段 2 的性能危机化解:首页回到预算内——速度稳住了,下一件事:开始收钱。
下一章
首页快了,用户留住了,老周开始认真思考一件事:"能不能在社区里直接卖东西?"——第 4 章砍掉的交易功能,到了启用的时候。
第 17 章,交易系统:当业务开始涉及金钱——同步调用、事务、幂等、支付回调、重试、最终一致性,一切"不能出错"的开始。