3.15 缓存与内容分发
概述与动机
缓存是把数据的副本保存在比原始位置更快或更近的地方,这样就能在不重复执行完整的昂贵工作的情况下回应请求。几乎每一个让人感觉快的系统之所以快,都是因为使用了缓存。原本需要 40 毫秒的数据库查询,如果结果已经保存在内存中,返回时间可以缩短到 1 毫秒以内。原本需要跨越大洋传输的图片,改由同一座城市的机器提供。缓存是你所拥有的杠杆效应最高的单一性能优化手段,同时也是最容易给你带来隐蔽而令人抓狂的 bug 的手段。
本章深入探讨缓存策略。第 3.4 章(数据架构与存储)把缓存和内容分发网络作为众多存储议题之一加以介绍,第 3.13 章(网络与连接)则讨论了它们所依赖的网络路径。而在本章,你将看到具体的决策:把缓存放在哪里、如何为其设置键、何时使其失效、如何在高负载下保护它,以及如何权衡你用陈旧度换取速度这笔交易。缓存涉及性能工程(第 2.16 章)、可扩展性与弹性(第 3.5 章)、分布式系统中部分故障的现实(第 3.3 章),并且()由于被污染的缓存可能把攻击提供给成千上万的用户()还涉及应用安全(第 4.2 章)。
其动机可以归结为三个杠杆。缓存能降低延迟,让用户等待更少。它能降低负载,让你的源站用同样的硬件服务更多的流量。它还能降低成本,因为在边缘被回应的请求永远不会触及你的数据库、计算资源或出站流量账单。对于大型团队而言,共享的缓存策略决定了平台是可预测地扩展,还是每个服务都重新发明失效机制并各自出错。在企业和政府系统中,流量往往在申报截止日和上线日出现高峰,此时设计良好的缓存常常是正常运作的门户和公开事故之间的分界线。
关键原则
- 用缓存降低延迟、负载和成本,并清楚自己在买的是哪一项。
- 把缓存放在层级结构中恰当的位置,尽量靠近最能发挥作用的地方。
- 把失效视为最难的部分;在缓存之前先设计好键和生命周期。
- 用请求合并(coalescing)、抖动(jitter)和防击穿手段在高负载下保护缓存。
- 有意识地选择写入模式:一致性与速度是相互拉扯的。
- 度量命中率、陈旧度和源站负载;未被度量的缓存是一种负担。
- 把被缓存的内容视为攻击面;被污染的缓存会服务给所有人。
建议
理解缓存层级结构
缓存并非单一位置的单一事物。它是一系列副本构成的层级结构,每一层都比前一层更靠近用户,你需要跨整个层级进行设计。离用户最近的是客户端缓存:浏览器的 HTTP 缓存、移动应用的本地存储、进程内的内存缓存。接下来是内容分发网络(CDN),这是分布在全球各地的一批服务器,在靠近用户的网络边缘保存你的内容副本。再往后是反向代理或网关缓存,即位于你的服务器前面的共享缓存。然后是应用缓存:一种快速的键值存储,例如内存数据网格,保存计算结果、会话和渲染好的片段。最后是数据库自身的查询缓存与缓冲区缓存,它把热数据页保存在内存中,从而减少对磁盘的访问。
每一层都承担着不同的职责:客户端缓存彻底消除了请求,CDN 吸收全球范围的读取流量,反向代理保护你的源站免受重复的相同工作的冲击,应用缓存节省重新计算的开销,而数据库缓存则保持存储层的响应能力。一个穿过每一层都未命中、最终到达数据库的请求,是你所拥有的最慢、最昂贵的路径,所以层级结构存在的意义就是尽可能在更靠前、更靠外的地方安全地作答。要把它当作一个系统来设计,因为应用层的缓存片段与它上层过期的 CDN 副本之间可能出现互相矛盾的情况,从而让用户感到困惑。
把失效当作最难的问题
有一个流传已久的笑话:计算机科学中最难的两个问题是命名、缓存失效,以及差一错误。这个笑话之所以经久不衰,是因为失效确实很难:缓存是一份副本,而原始数据一旦发生变化,每一份副本都可能变成谎言。你有三种大的策略可选。基于时间的过期,即设置生存时间(TTL,条目在被视为陈旧之前保持有效的时长),是最简单的做法:你接受有界的陈旧度,让条目自然老化失效。显式失效在底层数据发生变化时清除或更新条目,这种做法很精确,但要求你知道每一处副本所在的位置。事件驱动的失效让缓存订阅变更事件,从而自行刷新,这在缓存数量众多时扩展性更好,但会增加对消息系统的依赖。
大多数实际系统会把这些手段混合使用:对经常变化、能容忍数秒陈旧度的数据使用短 TTL;对很少变化、但一旦变化就必须正确的数据使用更长的 TTL 加显式清除;对一旦发布就不再改变的内容使用带版本号的缓存键。带版本号的键这一技巧值得牢记:与其去让缓存失效,不如去改变键本身。以 app.v187.css 提供的样式表永远不需要清除,因为新版本对应的是一个新键,旧的那个只是不再被请求而已。只要能把一个失效问题转化为命名问题,就应该这样做。
有意识地设计缓存键和 TTL
缓存的好坏取决于它的键。缓存键是一个值被存储和查找所依据的标识符,如果设计不当会导致两种相反的失败。键过于粗放,就会把一个用户的数据服务给另一个用户:如果个性化页面被以忽略用户身份的 URL 为键进行缓存,那就是数据泄露。键过于精细,命中率就会崩溃,因为没有两个请求能共用同一个键。要有意识地决定键中应该包含什么:资源本身的标识,加上任何合理地会使响应产生差异的因素(语言、货币、设备类型),此外别无其他。要对键做归一化处理,使诸如查询参数顺序之类的琐碎差异不会把缓存拆分得支离破碎。
TTL 同样值得认真对待。TTL 是你对将要提供的最大陈旧度所做的承诺,因此应当依据数据的真实容忍度来设定,而不是随手写下一个整数:股票行情能容忍几秒,商品目录能容忍几分钟,已发布的法规可以容忍几小时,或者干脆使用带版本号的键、完全不设过期。要加入一小段随机偏移,称为抖动(jitter),这样一批同时写入的条目就不会在同一瞬间全部过期,从而对源站造成冲击。把这些决定记录下来,因为一个没有理由的 TTL,是下一个工程师不敢去改动的数字。
防止击穿并合并请求
当一个热门的缓存条目过期时,所有想要它的请求会同时未命中,并一起涌向源站。这就是所谓的缓存击穿,也叫惊群效应(thundering herd),它可能把缓存原本要保护的那个数据库直接打垮。这些防御手段应当一次性建好并在各处复用。请求合并(单飞,single-flight)只让第一个针对某个缺失键的请求去重新计算值,其余请求则等待其结果,这样一千个同时发生的未命中只会向源站发出一次调用。概率性提前重算会在一个热门条目即将过期前,以随机的方式提前刷新它,这样一次后台请求就能在人群看到未命中之前把它续期。过期后异步再验证(stale-while-revalidate)策略会立即提供略微陈旧的副本,同时异步刷新它,这样用户就完全不需要等待一次未命中。
这些模式恰恰在你最需要缓存、也就是负载达到峰值的时候才最重要,所以要在贴近真实规模的条件下验证它们:一种在十个用户下有效的防御手段,在一万个用户下仍可能失效。要把它们与第 3.5 章的弹性模式(尤其是超时和断路器)结合起来,这样当源站真的变慢时,缓存层能保护它,而不是雪上加霜。目标是一个在压力下表现最好的缓存,而不是一个把峰值放大成事故的缓存。
有意识地选择写入模式
你如何处理写入,决定了缓存能保持多新,以及在失败时你要承担多大的风险。常见的模式有四种。在旁路缓存(cache-aside,惰性加载)中,应用先检查缓存,未命中时读取源站、填充缓存并返回值;写入直接进入源站并使该条目失效。它之所以成为默认选择是有原因的:简单,而且缓存中只保存被请求过的内容。在写穿透(write-through)中,每次写入都同时写入缓存和源站,因此缓存始终是最新的,代价是写入延迟更高,并且可能缓存了永远不会被读取的数据。在写回(write-back,write-behind)中,写入先命中缓存,之后异步刷新到源站,这让写入变快,但如果缓存在刷新前失效,就存在数据丢失的风险。在写绕过(write-around)中,写入直接进入源站并跳过缓存,从而避免写多读少的数据造成的缓存搅动,代价是首次读取必然未命中。
要按工作负载分别选择,而不是为整个系统只选一次。读多的目录类数据适合旁路缓存或写穿透。写多的日志或指标流适合写绕过,这样缓存就不会被没人重读的数据搅动。写回适合高吞吐写入、且可以接受一定程度可理解的丢失风险、耐久性由别处保障的场景。要为每一个缓存明确说明所用的模式,因为如果读者以为代码用的是旁路缓存,而实际是写回,就会对新鲜度和失败行为都产生误判。
让淘汰策略匹配你的访问模式
缓存的容量是有限的,所以当它满了,就必须淘汰一些内容。淘汰策略决定淘汰什么。最近最少使用(LRU)淘汰最长时间未被访问的条目,赌的是最近的使用能预测未来的使用,这是一个合理的默认选择。最不经常使用(LFU)淘汰命中次数最少的条目,适合少数条目长期热门的稳定热点集,但也可能死守那些曾经热门、之后再也没有适应变化的条目。诸如分段 LRU 之类的变体以及自适应策略会把最近性和频率结合起来;先进先出以及简单的基于时间的过期则更便宜,但也更粗糙。
要把策略匹配到数据的访问方式:对于很少变化的小型热点集,使用 LFU 或频率感知型策略;对于像新闻或热门内容那样随时间变化的热度,使用 LRU。不论选择哪种策略,都要把缓存的大小设置到能容纳热点集的程度,因为一个小到装不下工作集的缓存会出现抖动,在条目刚被淘汰后又马上需要它。要把淘汰率作为一等指标来关注,因为淘汰率突然上升通常意味着缓存容量不足,或者出现了键爆炸导致缓存被碎片化。
正确使用 HTTP 缓存语义
Web 内置了一套成熟的、标准化的缓存模型,构建在 HTTP 之上,善用它可以免费获得客户端和 CDN 层的缓存能力。Cache-Control 头是控制面:max-age 设置新鲜期,public 和 private 说明共享缓存是否可以存储该响应,no-store 禁止缓存,stale-while-revalidate 允许在刷新的同时提供陈旧副本。验证机制让缓存能以低成本检查新鲜度,而无需重新获取响应体。ETag(实体标签)是服务器附加在响应上的一个不透明的版本标识符;客户端在 If-None-Match 头中把它回传给服务器,如果内容没有变化,服务器会回复不带响应体的 304 Not Modified。Last-Modified 配合 If-Modified-Since 用时间戳完成同样的事情。
实践中的纪律是明确表达意图。在每一个响应上都设置 Cache-Control,而不是让缓存靠启发式去猜测。把私有的、面向单个用户的响应标记为 private 或 no-store,以免共享代理存储它们()这是一个常见且危险的错误。对静态资源使用带版本号的 URL,配合长 max-age 和 immutable 指令;对变化难以预测的内容使用带 ETag 的验证机制。把这些响应头设置正确,就能把整个客户端和 CDN 层变成一个正确的、基于标准的缓存,而无需自己动手构建。
用 CDN 和边缘计算把工作推向边缘
CDN 起初是一种在靠近用户的地方缓存静态文件的方式,如今它依然在这方面表现出色:图片、脚本、视频和下载内容由几毫秒之外的边缘节点提供,而不是遥远的源站。现代 CDN 更进一步。它们用精细的键来缓存动态和个性化内容,在边缘终止 TLS,吸收流量高峰和分布式拒绝服务攻击,并且越来越多地直接运行你的代码。边缘计算在边缘节点本身执行逻辑,这样你就可以在不经过一次往返中心区域的情况下,个性化一个响应、检查授权,或者拼装一个页面片段。
对于大多数系统占主导地位的读取场景,应当充分利用这一点。把静态资源放到 CDN 后面,使用长生命周期的带版本号 URL;在新鲜度允许的情况下在边缘缓存 API 响应(要谨慎设置键,避免个性化信息泄露);对延迟敏感的轻量逻辑使用贴近用户的边缘计算。这里的权衡是覆盖面与控制力之间的取舍:边缘快且近,但远离你的数据,也更难调试,所以要把任何需要强一致性或最新权威状态的东西留在源站,把庞大、可缓存的读取流量交给边缘处理。
把缓存视为攻击面
缓存把同一份存储的响应提供给许多用户,这使它成为一个攻击目标。缓存投毒是一种攻击方式:精心构造一个请求,使缓存存储一个有害的、或由攻击者控制的响应,然后把它提供给随后的每一个人。它通常利用的是未被纳入键的输入:应用会把某个请求头反映到响应中,但缓存在构建键时却忽略了它。与之相关的 Web 缓存欺骗攻击则诱使缓存把某个受害者的私有响应存储在一个公共 URL 之下。这两者都是键设计失误和对输入过度信任所导致的问题,第 4.2 章有更全面的讨论。
要有意识地进行防御。把每一个可能改变响应内容的输入都纳入缓存键,拒绝把未被纳入键的请求头反映到被缓存的响应体中。绝不允许共享缓存以共享键存储经过身份验证的、面向单个用户的响应。在缓存之前对请求路径和参数进行归一化和校验。正确设置 Vary,使缓存按真正起作用的请求头(例如内容编码或语言)对响应进行分区。由于一个被污染的条目会伤害下游的每一个用户,应当把缓存配置当作安全敏感代码来对待和评审。
让缓存行为可观测
你无法管理一个看不见的缓存。首要指标是命中率:由缓存而非源站提供服务的请求所占的比例。命中率从 95% 悄悄降到 70%,可能会使源站负载成倍增加,并预示着一场事故,只有持续关注它,你才能提前发现。要分层单独进行度量,因为一个健康的 CDN 命中率可能掩盖了其下方正在崩溃的应用缓存命中率。这是第 9.2 章可观测性实践在缓存领域的具体体现。
除命中率之外,还要追踪更多指标:淘汰率和内存压力,用以发现容量不足;每一层的延迟,用以确认缓存确实更快;源站请求速率,用以了解缓存吸收了多少负载;以及陈旧度(被提供的条目有多旧),用以确认你兑现了新鲜度承诺。要对能够预示麻烦的比率设置告警,尤其是命中率下降或淘汰率上升,这样你就能从仪表盘上发现缓存正在退化,而不是从用户那里发现。被观测的缓存是一项可以调优的资产;未被观测的缓存则是一个随时可能让你措手不及的隐藏依赖。
权衡:优点与缺点
缓存用新鲜度和复杂度作为代价来换取速度和规模。每一个缓存都是一次赌注,赌的是对于这份特定的数据而言,陈旧但快比新鲜但慢更划算,而这门艺术在于有意识地下这个注,而不是默认地下注。下表总结了主要的选择。
| 选择 | 优点 | 缺点 |
|---|---|---|
| Cache-aside | 简单;只缓存被读取过的内容 | 首次读取总是未命中;写入后存在短暂陈旧的风险 |
| Write-through | 写入时缓存始终是最新的 | 写入更慢;可能缓存永远不会被读取的数据 |
| Write-back | 写入非常快;能吸收突发写入 | 若缓存在刷新前失效,存在数据丢失风险 |
| Write-around | 避免写多读少的数据搅动缓存 | 首次读取必然未命中 |
| 短 TTL | 陈旧度有界且较小 | 命中率较低;源站负载更高 |
| 长 TTL / 带版本号的键 | 命中率高;源站负载低 | 除非主动失效,否则存在陈旧数据;需要严谨的键管理 |
| CDN 与边缘 | 全球低延迟;吸收流量峰值 | 远离数据;更难调试和失效 |
| LRU 淘汰 | 适应变化中的热度 | 在扫描密集型负载下可能淘汰稳定的热点集 |
| LFU 淘汰 | 保护稳定的热点集 | 适应速度慢;固守曾经热门的条目 |
反复出现的张力是一致性与性能之间的取舍。TTL 长、命中率高的缓存速度快、成本低,但可能提供陈旧数据;TTL 短、失效积极的缓存新鲜且正确,但会让源站承受更大压力。这里没有普适的正确答案,只有针对每一份数据、依据其真实陈旧度容忍度而定的正确答案。第二种张力是简单性与覆盖面之间的取舍:应用缓存离你的数据很近,易于推理,而边缘则远、快,也更难失效。解决这两种张力的办法,是按新鲜度需求和读取量对数据进行分类,然后有意识地为每一类数据布局和配置缓存。
与团队讨论的问题
我们所缓存的每一类数据,其真实的陈旧度容忍度是多少,我们设置的 TTL 和失效策略是依据这个容忍度,还是依据习惯? 大多数团队使用的是某个人曾经选定、之后再也没有复核过的 TTL,结果一些数据被提供得比业务能接受的更陈旧,而另一些数据则过期得如此激进,以至于缓存几乎没起到作用。带上你排名前十的被缓存资源,逐一向拥有该数据的人询问:它可以安全地陈旧多久()几秒、几分钟、几小时,还是一旦发布就永不过期?你通常会发现答案差异很大,而你目前的 TTL 并不与之匹配。理想的结果是一份简短的新鲜度分类,每一类对应一种方法(短 TTL、长 TTL 加清除,或带版本号的不可变键),使缓存决策源于数据语义,而不是凭猜测。
如果我们最热门的缓存条目现在在流量高峰期过期,源站会发生什么? 这个问题揭示了你拥有的是真正的防击穿保护,还是仅仅心存侥幸。许多系统平时运行良好,直到一个热点键在流量高峰期过期,所有请求同时冲击数据库,把缓存从一道保护屏障变成了一个触发器。请具体地为你流量最大的端点梳理这条路径:是否有请求合并机制,确保只有一次未命中会到达源站?是否有抖动机制,避免条目在同一时刻集中过期?是否有过期后异步再验证策略,让用户永远不必等待一次重新填充?请带上负载测试的证据,而不是直觉,因为一个在十个用户下能扛住的防击穿手段,在一万个用户下仍可能崩溃。如果你无法自信地回答,那么你下一项弹性投资的方向就找到了。
我们能确定没有任何共享缓存会把一个用户的私有数据存储在另一个用户能够命中的键之下吗? 这是那种会演变成安全事故和头条新闻的缓存错误。它发生在个性化或经过身份验证的响应被以一个不包含用户身份的键进行缓存时,或者一个本应使响应保持私有的
Cache-Control头缺失时,于是共享代理或 CDN 把它存储下来,并提供给下一个人。请审计哪些响应在共享层是可缓存的,确认每一个面向单个用户的响应都被标记为private或no-store,并确认每一个缓存键都包含了每一个会改变响应内容的输入。要把这当作安全评审来对待,因为其影响范围是每一个下游用户,并把它与第 4.2 章的实践联系起来。我们每一个缓存实际使用的是哪种写入模式,是否有人有意识地做过这个选择? Cache-aside、write-through、write-back 和 write-around 在新鲜度、以及缓存失败时你会失去什么方面,做出的是相互矛盾的承诺,然而在大多数代码库中,实际采用的模式往往只是最初的作者恰好照搬来的那一种。对于大型团队而言,这一点尤其重要,因为如果一个服务假设的是 cache-aside 的新鲜度,而另一个服务悄悄运行的是 write-back,就可能产生看起来像是损坏、实则只是陈旧的数据,值班工程师会耗费数小时追查一个根本不存在的幽灵。请带上一份按缓存逐一列出的清单:写入模式、它所保证的新鲜度,以及如果进程崩溃,尚未刷新的写入会发生什么。凡是使用 write-back 的缓存,都要带上支撑它的耐久性方案。在处理财务或记录类数据的企业和政府系统中,一个没有耐久性保障支撑的 write-back 缓存,是一项等待被发现的审计问题,因此讨论应当以为每一个缓存明确指出、论证并记录其所用的模式而结束。
当我们部署或变更数据时,每一个相关的缓存层是否都能正确失效,还是依赖有人记得去清除? 失效是最难的部分,而它的失败模式是无声的:一个已被更正的值,因为层级结构中的某一层()CDN、反向代理,或应用缓存()从未收到通知,而持续错误数小时。大型组织会放大这一风险,因为一次逻辑上的变更可能需要传播到由不同团队拥有的、分布在多个区域的许多缓存。请带上一次近期数据变更的具体追踪记录,跟随它走过每一个缓存层,在每一层问:这里的失效是由什么触发的,花了多长时间?相较于人工操作手册,更应当偏好把失效转化为命名(带版本号的键)或转化为事件(一次变更发布一次清除)的设计。在错误的已发布数字()税率或福利金额()可能带来法律责任的公共部门系统中,失效缺口不是一个小麻烦,而是一项合规风险敞口,因此结果应当是为每一类被缓存的数据绘制出一条明确的失效路径。
我们是把缓存当作共享的平台基础设施来对待,还是每个团队都在各自重新发明键、失效和防击穿保护? 做得好的缓存,是把一小组困难的问题一次性解决:归一化的键、事件驱动的失效、请求合并、正确的 HTTP 语义,以及分层的可观测性。当每个团队都各自摸索这些问题时,大型组织会为同样的错误反复买单,而在一个服务中修复的投毒漏洞或私有数据泄露问题,可能在另外十个服务中悄悄延续。请带上一份诚实的现状图,说明当前由谁负责缓存规范,以及各服务之间存在多少重复的缓存代码。与之相抗衡的考量是自主性:团队会抵触被强制使用的共享库,因此要权衡一条易于采纳的铺好的路(paved road)默认方案,与一个被强制执行的硬性标准之间的取舍。对于企业或政府平台团队而言,一套共享的、经过充分测试的缓存能力,也是让安全与审计要求统一落实的最便宜方式,因此讨论应当决定哪些内容将成为共享基础设施,以及由谁出资。
行业视角
初创企业。 缓存是你在尚无力承担扩容成本时应对流量高峰最廉价的手段,所以要把有限的时间花在少数几个杠杆效应最高的部署上:为静态资源配置带版本号 URL 的 CDN,并在你最热门的查询前面配置一个使用短 TTL 加抖动的单一 cache-aside 层。多依赖托管的 CDN 和缓存服务,而不是自己运营;尽早加入请求合并机制,因为上线当天针对一个小型数据库发生的击穿,是最有可能把美好的一天搞砸的失败情形。在有数据表明确有必要之前,先不要搭建复杂的失效方案。
小型企业。 由于没有缓存方面的专职人员且预算紧张,应优先使用已经内置在你所使用工具中的、无需额外付费的缓存能力:托管方案自带的 CDN、Web 框架响应上的 HTTP Cache-Control 头,以及数据库自带的查询缓存。在这里,自建还是购买的选择几乎总是倾向于购买,因为一个键设计有误、把一个客户的数据泄露给另一个客户的缓存,其代价远高于你为避免使用托管服务而省下的钱。把两项低成本的收益做对:正确的 HTTP 响应头,以及绝不在共享层缓存经过身份验证的页面,其余的花哨模式暂且不要碰。
企业。 在跨越多个团队的规模下,风险从任何单一缓存转移到了缓存之间的不一致上:不同的键方案、参差不齐的失效机制,以及在一个服务中出现、在另一个服务中却没有的私有数据泄露。要把缓存作为共享平台基础设施提供,为键、失效、防击穿保护和分层可观测性提供铺好的路默认方案,这样命中率、淘汰情况和陈旧度就能在一处统一可见、统一治理。要把缓存配置当作安全敏感代码来评审,并把跨区域的失效当作一流的设计问题来对待,而不是每个团队各自的操作手册。
政府。 采购和透明度方面的约束,决定了你能缓存什么,以及如何证明它是安全的。要在 CDN 后面积极缓存公共内容()指南、表格和费率表()使用较长的 TTL,这样申报截止日的激增流量就能远离源站被吸收,并为该配置留存文档以备审计。展示公民自身记录的经过身份验证的页面绝不能触及共享缓存,而这条规则应当是可验证的,而不仅仅是口头声明。当 CDN 或缓存服务是从供应商处采购而来时,应要求合同开放出你所需要的控制项(键设置、清除和日志记录),并避免陷入把公共数据锁死在专有缓存格式之内的供应商锁定。
示例
初创企业。 一家小型消费类应用通过一个由内存存储支撑的 cache-aside 层来处理其商品目录,TTL 设为 60 秒并加入抖动,避免条目同时过期。静态资源被放到 CDN 上,使用带版本号的文件名和一年的 max-age,因此一次修改了样式表的部署会提供一个新的 URL,永远不需要清除。当在一档热门播客上的推广带来流量高峰时,单飞式的请求合并意味着首页上成千上万次同时发生的未命中只会引发一次数据库读取,而不是成千上万次。这家初创公司在缓存上几乎没有花费任何成本,却应对了一次原本足以压垮他们那台小型数据库的流量高峰,原因就在于他们有意识地布置了少数几个精心挑选的缓存。
企业。 一家全球零售商通过分层缓存为数百万购物者提供服务:为图片和可缓存的 API 响应设置的 CDN、每个区域的共享反向代理缓存,以及用于计算好的定价和库存片段的应用缓存。缓存键经过归一化处理,并包含货币、语言和设备类型,因此个性化信息永远不会泄露,命中率始终保持很高。商品数据使用短 TTL 配合事件驱动的失效机制,因此一次价格变更会发布到消息总线,在数秒之内清除各区域中受影响的键。每一层都向第 9.2 章所述的可观测性平台报告命中率、淘汰率和陈旧度,一次针对命中率下降的告警,曾经在一个容量不足的缓存演变成结账故障之前就将其捕获。
政府。 某国家税务机关运营着一个申报门户网站,该网站在一年中的大部分时间里流量平静,但在截止日附近却不堪重负。团队在安全的地方积极缓存,在不安全的地方则绝不缓存。公共内容(指南页面、表格、费率表)由 CDN 提供,使用较长的 TTL 和带版本号的 URL,从而在远离源站的地方吸收截止日当天激增的读取流量。展示公民自己申报信息的经过身份验证的页面被标记为 no-store,绝不触及共享缓存,因此不会有任何纳税人被提供给别人的数据。缓存配置被依照第 4.2 章的实践作为安全敏感代码进行评审,防击穿保护也提前数月按截止日的规模进行了负载测试,因此这个曾经在一年中最繁忙的一天会陷入瘫痪的门户网站,如今能够扛得住。
商业论证:动机、投资回报率与总拥有成本
缓存的回报异常直接,也容易量化。把命中率从 80% 提升到 95% 的缓存,可以把源站流量削减四分之三,这可能意味着推迟一次数据库升级、减少应用服务器的数量,或者扛过一次原本需要紧急扩容才能应对的流量高峰。延迟的改善在商业场景中转化为收入,在公共服务场景中转化为满意度和办理完成率,而长期以来的研究一直表明,更快的页面与更高的转化率和更低的放弃率相关。出站流量和计算成本会下降,因为由边缘提供的请求永远不需要为源站带宽或处理付费。对于大多数属于读多写少的系统而言,缓存往往是你能买到的最便宜的性能。
要诚实地权衡总拥有成本。直接成本是有限的:相较于它所节省下来的源站容量,CDN 和缓存基础设施是廉价的。真正的成本在于工程纪律,因为一个出错的缓存比根本没有缓存更糟。陈旧度 bug、失效错误和缓存投毒漏洞都带有真实的代价,而当缓存由各团队各自摸索而不是作为共享的、经过充分测试的能力提供时,这些代价还会进一步增长。最有力的商业论证,是拨出一小笔资金用于共享的缓存基础设施和规范(标准化的键、失效机制、防击穿保护和可观测性),使每个团队都能受益,而不必重蹈覆辙。向管理层陈述时,可以把缓存与他们已经在跟踪的指标联系起来:基础设施成本、页面延迟、转化率和完成率,以及高峰事件期间的事故频率。
反模式与陷阱
- 没有失效机制的缓存: 设置了很长的 TTL 却没有清除手段,导致一个已被更正的值持续数小时保持错误。
- 键过于粗放: 以共享键缓存个性化响应,把一个用户的数据泄露给另一个用户。
- 键过于精细: 把易变的输入纳入键中,导致没有两个请求能够匹配,命中率随之崩溃。
- 没有防击穿保护: 一个热点键在高负载下过期,所有请求同时冲向源站。
- 同步过期: 一批同时写入的条目在同一瞬间全部过期,没有抖动,导致周期性的惊群效应。
- 在共享层缓存私有数据: 缺少
Cache-Control: private或no-store,导致代理或 CDN 存储了经过身份验证的响应。 - 忽略未被纳入键的输入: 把某个请求头反映到响应体中,却没有把它纳入键,从而为缓存投毒打开了大门。
- 容量不足的缓存: 缓存小到无法容纳工作集,出现抖动,在条目刚被淘汰后又马上需要它。
- 未被度量的缓存: 没有命中率或淘汰率指标,导致一个正在退化的缓存直到演变成事故之前都不可见。
- 没有耐久性保障的写回: 快速写入在缓存于刷新前失效时消失,且没有任何底层保障。
成熟度模型
- 第 1 级,启动(Initiate): 缓存是临时性的、因人而异的,只有在感觉某处变慢时才被动加入。TTL 靠猜测,键不一致,失效手段是人工操作或干脆没有,陈旧数据和莫名其妙的 bug 屡见不鲜。没有人追踪命中率,一次本应被缓存吸收的流量高峰反而引发了事故。
- 第 2 级,发展(Develop): 团队在显而易见的地方使用缓存,并用 CDN 处理静态资源。基本的 TTL 和 cache-aside 已经出现,但各服务之间的规范各不相同,失效机制不一致,防击穿保护缺失,私有与共享的缓存规则是非正式的,可观测性也仅限于偶尔的抽查。
- 第 3 级,标准化(Standardize): 缓存策略被记录下来,并在整个组织内强制执行。缓存键经过归一化,TTL 遵循共享的新鲜度分类,在关键场景下失效是事件驱动的,防击穿保护和正确的 HTTP 语义已成为标准,私有数据绝不会在共享层被缓存,每一层都向统一的可观测性管道报告命中率和淘汰情况。
- 第 4 级,管理(Manage): 缓存被依据基线进行度量和控制。每一层都设有目标命中率、陈旧度预算和淘汰阈值,仪表盘会在命中率跌破基线或淘汰率超过基线时告警。防击穿防御在峰值规模下经过负载测试,每个缓存所减少的源站负载都被量化,TTL 和淘汰策略依据实测的访问模式进行调优,缓存配置在发布前作为安全敏感代码被评审。
- 第 5 级,编排(Orchestrate): 缓存被持续改进,并在整个组织内实现集成。布局、键和 TTL 会随流量变化而自适应,边缘计算被用在其能够物有所值的地方,容量规划和成本模型以缓存指标为依据,一个团队从事故中汲取的经验会反哺共享规范。整个组织把缓存视为一种经过设计、被度量、能自适应的能力,而不是一堆各自为政的权宜之计的集合。
讨论提纲
- 在你的系统中,哪一个单一的缓存如果现在失效,会最危及你的源站?是什么在保护它?
- 对于你缓存层级结构中的每一层,你能凭记忆说出它当前的命中率吗?如果不能,这说明了什么?
- 在哪些地方,你已经用带版本号的键把一个失效问题转化为了命名问题?在哪些地方你仍然可以这样做?
- 你的写入路径中,哪些使用了 cache-aside、write-through、write-back 或 write-around?每一个都是有意选择的吗?
- 如果攻击者能够控制一个请求头,他们能污染你的用户所共享的任何被缓存的响应吗?
- 如果你的命中率悄悄下降了二十个百分点,你能在几分钟之内知道吗?
关键要点
- 缓存能降低延迟、负载和成本,缓存层级结构(客户端、CDN 与边缘、反向代理、应用、数据库)让你能够在安全的前提下尽量靠前、靠外地作答。
- 失效是最难的部分;要有意识地设计缓存键和 TTL,并尽可能用带版本号的键把失效问题转化为命名问题。
- 用请求合并、抖动和过期后异步再验证在高负载下保护缓存,因为缓存最需要发挥作用的时刻,恰恰是它最可能被一次击穿打垮的时刻。
- 按工作负载分别选择写入模式和淘汰策略,明确使用 HTTP 缓存语义(
Cache-Control、ETag、验证机制),而不是让缓存自行猜测。 - 把被缓存的内容视为攻击面,度量命中率、淘汰率和陈旧度,因为一个未被观测或键设计不当的缓存,不是资产,而是隐藏的负担。
参考文献与延伸阅读
- Martin Kleppmann, Designing Data-Intensive Applications
- Andrew S. Tanenbaum and Herbert Bos, Modern Operating Systems
- John L. Hennessy and David A. Patterson, Computer Architecture: A Quantitative Approach
- Roy T. Fielding and Julian Reschke, “Hypertext Transfer Protocol (HTTP/1.1): Caching,” RFC 7234, IETF
- Mark Nottingham, “Caching Tutorial for Web Authors and Webmasters”
- James Kettle, “Practical Web Cache Poisoning,” PortSwigger Research
- Betsy Beyer, Chris Jones, Jennifer Petoff, and Niall Richard Murphy (eds.), Site Reliability Engineering: How Google Runs Production Systems
- Michael T. Nygard, Release It! Design and Deploy Production-Ready Software