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