部署后缓存失效:哪些需要清除,哪些不需要
每天部署,无需担心过时的边缘缓存或源站过载。将资产分类为永不清除的不可变哈希键和按路径清除的可变 HTML。
CDN 边缘缓存是你的网站速度快的原因,也是你的部署不可见的原因。同一个缓存,既可以在十毫秒内从邻近城市提供一个页面,也会在你发布修复后的一小时内继续提供上周的版本。天真的做法是在每次部署时清除所有内容,但这会清空缓存,并将下一波流量直接发送到你的源站,而这正是缓存存在的目的所要吸收的负载。可行的解决方案并非单一的设置。它是一小组根据资产类型选择的规则,在这些规则下,你的大部分字节被永久缓存且永不清除,只有少数实际更改的文件才会被设为失效。这篇文章阐述了这些规则、支持它们的缓存标头,以及执行精确操作的清除调用。
为什么边缘缓存会在部署后提供过时的内容
CDN 缓存响应是因为你通过 Cache-Control 标头和 max-age 指示它这样做。一旦边缘节点有了副本,它就会用该副本进行响应,直到缓存过期或你明确清除它。边缘节点不知道你进行了部署。它不会监视你的 git 历史或发布管道。从它的角度来看,什么都没发生,所以它会继续提供它所拥有的内容。
这种差异就是问题的全部所在。两种故障模式位于同一个刻度盘的两端。将刻度盘转向长缓存生命周期和高命中率,部署可能需要一个小时才能到达用户,如果浏览器也持有副本,时间会更长。将其转向短生命周期和主动清除,那么每次部署都会将冷流量倾倒到你的源站,命中率下降,并且你发布得越频繁,延迟数据就越差。这两端都不是你想要的状态。
出路是停止将整个网站视为一个可缓存的整体。不同的文件按不同的时间表更改,它们应该有不同的规则。
将资产分为不可变和可变
内容网站提供的几乎每个文件都可归入两类之一,而这种划分使得其余策略变得简单。
第一类是构建后永不改变的内容。一个打包的 JavaScript 文件、一个样式表、一个字体、一个处理过的图片。它们的字节是固定的。如果你编辑了源代码,构建过程会生成一个不同的文件,而你宁愿将其作为一个新事物来提供,而不是就地修改旧文件。
第二类是在一个稳定地址下就地改变的内容。你的 HTML 页面是最清晰的例子。URL /blog/some-post 必须保持工作,并始终指向该文章的当前版本。当你修复一个拼写错误时,地址不会改变,但其背后的字节会改变。
这两类内容需要相反的缓存规则。下表在一页内展示了整个策略,文章的其余部分将解释每一行。
| 资产类型 | 地址风格 | Cache-Control | 部署操作 |
|---|---|---|---|
| JS、CSS、字体、构建后的图片 | 哈希化名称,app.a1b2c3.js |
public, immutable, max-age=31536000 |
无,新的构建是新的名称 |
| 渲染的 HTML 页面 | 稳定路径,/blog/post |
public, max-age=60, stale-while-revalidate=86400 |
仅清除已更改的路径 |
| API 和 JSON 片段 | 稳定路径 | public, max-age=30, stale-while-revalidate=300 |
通过代理键清除 |
| 站点地图、订阅源 | 稳定路径 | public, max-age=300 |
发布时清除 |
| 用户特定响应 | 稳定路径 | private, no-store |
从不在边缘缓存 |
一句话总结:不可变内容缓存一年且永不清除,可变内容短暂缓存并精确清除。你的流量几乎都不需要清除操作,因为你绝大多数的字节都是不可变的。
不可变资产:为名称添加哈希值且永不清除
第一类的诀窍是将文件内容的哈希值放入其名称中。一个构建步骤会读取 app.js 的最终字节,计算一个简短的摘要,并生成 app.a1b2c3d4.js。加载它的 HTML 会引用那个确切的名称。更改一行源代码,摘要就会改变,因此下一次构建会生成 app.e5f6g7h8.js,而 HTML 现在则会转而指向它。
因为名称源自内容,所以一个给定的名称永远只能表示一组确切的字节。这让你能够发送 Web 中最强的缓存标头:
Cache-Control: public, immutable, max-age=31536000
max-age=31536000 是一年。immutable 指令告诉浏览器在重新加载时无需费心重新验证,因为这个承诺是千真万确的:只要该名称存在,其对应的字节就不会改变。CDN 会缓存该文件一次,并从边缘节点提供服务,直到该文件因不被使用而被逐出。你的源服务器几乎不会看到该文件的重复流量。
对于部署而言,这样做的好处是你永远不需要清除这些文件。部署操作不会覆盖 app.a1b2c3d4.js。它会发布一个使用新名称的新文件,并更新 HTML 中的引用。新名称从未存在于任何缓存中,因此没有什么过时的内容需要清除,而旧名称会继续为仍在请求它的任何页面提供旧的字节。对于你最大的资产而言,缓存失效不再是你需要执行的操作。它变成了一种不可能发生的状态,因为一个名称的含义永远不会改变。
可变的 HTML:短 TTL 和定向清除
HTML 页面不能使用哈希命名,因为它们的地址必须保持稳定,以用于链接、书签和搜索引擎。因此,它们会得到相反的处理:短暂的缓存生命周期,加上对已更改的特定路径进行显式清除。
单独使用短 TTL 也能奏效,但它本身会迫使你在新鲜度和源站负载之间做出选择。stale-while-revalidate 打破了这种紧张关系,下面的章节会对此进行介绍。目前,标头如下所示:
Cache-Control: public, max-age=60, stale-while-revalidate=86400
max-age=60 意味着边缘节点在一分钟内将页面视为新鲜的,这可以吸收热门 URL 的突发流量。但等待一分钟才能让实际部署生效实在太长了,所以你不能依赖过期机制。在部署结束时,你会清除你更改的确切路径。
清除是对 CDN API 的一次经过身份验证的调用,它会丢弃特定的缓存对象。其形式在不同提供商之间是相同的:你指定要丢弃的内容。
curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
-H "Authorization: Bearer $CDN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"files": [
"https://example.com/blog/some-post",
"https://example.com/blog/some-post/"
]}'
重点在于“定向”这个词。你清除的是本次部署所触及的页面,而不是整个区域。如果一个版本更改了三篇文章和一个索引页,那么你就需要清除四个 URL。所有其他缓存页面都保持“热”状态,因此这次部署对你的其余流量是不可见的,你的源站也永远不会注意到它的发生。
问题在于,你必须知道哪些路径发生了变化。如果你的构建过程已经能生成一个清单文件,那么对最近两次构建输出进行差异比较,就能得到变更的集合。如果不能,下一节将提供一种完全无需枚举 URL 即可进行清除的方法。
按代理键清除,而非按 URL
当一个变更扩散到多个页面时,按 URL 清除的方式就会失效。编辑作者姓名后,你可能需要刷新该作者的每篇帖子、作者页面以及一些标签页面。手动列出这些 URL 的做法既不可靠又容易出错。
代理键解决了这个问题。当源服务器渲染响应时,它会附加一个标头,用一个或多个标签来标记该响应。大多数 CDN 会读取像 Surrogate-Key 或缓存标签标头这样的标头,并按其标签为每个缓存对象建立索引。
Surrogate-Key: post-1024 author-42 tag-infra
一个帖子页面带有其自身的 id、其作者的 id 以及其标签。之后,当作者发生变更时,你清除一个键,CDN 就会丢弃携带该键的每个对象,无论有多少个:
curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
-H "Authorization: Bearer $CDN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tags": ["author-42"]}'
你不再需要跟踪哪些 URL 受到了影响。你根据响应所依赖的内容为其添加标签,并根据已更改的依赖项进行清除。即使在影响范围(blast radius)很广的情况下,这也能保持清除操作的精确性,并且使部署步骤不必推断你的 URL 结构。源服务器已经知道每个页面包含了哪些数据,因此是声明这些依赖项的正确位置。
在部署结束时自动执行清除操作
需要人工记住的清除操作,往往会被跳过;而被跳过的清除操作会导致网站内容陈旧,看起来就像一次失败的部署。解决方法是,在新版本上线后,将清除操作作为流水线的最后一步自动运行。
顺序至关重要。首先部署新的源站,确认其在正常提供服务后,再执行清除操作。如果在新版本上线前进行清除,边缘节点会从源站重新获取旧的字节并重新缓存,除了瞬间增加了额外的负载外,你什么目的都没有达到。在上线后清除,那么接下来对每个已清除路径的请求将会错过边缘缓存,访问新的源站,并重新缓存新版本。
# 1. deploy and wait for the new revision to receive traffic
deploy_release
# 2. compute the changed paths from the build manifest diff
CHANGED=$(diff_manifest build/prev build/current)
# 3. purge only those paths, at the very end
purge_paths "$CHANGED"
将清理操作接入部署流程中,也为你提供了一个可靠的地方来查看哪些内容失效了。记录被清理的路径或键。当有人询问某个页面为什么更新了,或者为什么没有更新时,答案就在部署日志中,而不是在某个人的记忆里。
在重新验证时提供陈旧内容
stale-while-revalidate 可以让你将 TTL 设置得很短,而无需付出延迟的代价。它出现在上面的 HTML 标头中,其本身就值得我们去理解,因为它消除了我们担心频繁过期的最后一个理由。
Cache-Control: public, max-age=60, stale-while-revalidate=86400
在最初的 60 秒内,边缘节点将缓存的页面视为新鲜内容来提供。此后,在接下来的一天内,边缘节点被允许立即提供过期的副本,同时在后台从源站获取新副本。触发刷新的用户无需等待源站。他们会以边缘节点的速度获得略微陈旧的页面,而下一位访问者将获得更新后的页面。新鲜度只滞后一个请求,而不是一个完整的源站往返时间。
这改变了较短 max-age 的体验。如果没有它,60 秒的 TTL 意味着每个 URL 每分钟会有一个不幸的用户需要等待源站响应。有了它,该用户会得到即时响应,而源站的回填则在带外进行。你可以同时获得近乎实时的新鲜度和近乎为零的面向用户的源站延迟。当你需要时,显式清除仍然会强制立即刷新,因此这两者可以协同工作:清除操作处理部署,而 stale-while-revalidate 处理部署之间的缓慢内容漂移。
权衡利弊,以及何时可以进行完全清除
这一切都不是没有代价的。经过哈希处理的不可变名称意味着你的构建管道必须计算摘要,重写 HTML 中的每个引用,并保留旧文件足够长的时间,以确保仍在加载旧版本的页面不会损坏。这带来了实实在在的复杂性,并且它将永远存在于你的构建工具中。
定向清除也有其自身的成本:你必须知道哪些内容发生了变化。要么你的构建过程会生成一个可供你进行差异比较的清单,要么你的源站会用其正确维护的代理键来标记响应。一个过时的标签或一个缺失的清单条目意味着页面会悄无声息地不刷新,这比整个网站统一落后于最新版本更难被注意到。
与这些成本相权衡的是完全清除,它不需要任何这些操作。一次调用即可清除整个区域,并保证所有内容都是最新的。在少数情况下,这确实是正确的答案。一个流量较小的小型网站可以承受回源造成的冷启动冲击而无人察觉。在紧急情况下,比如下架一个绝不能再被访问的页面,使用这种“钝器”是值得的。而一次真正触及每个页面的迁移,也无法从精确操作中获益。
一条行之有效的规则是:将你的大部分字节内容设为不可变,这样它们就永远不需要被清除;通过路径或键来清除剩余的可变部分;并将完全清除作为一种备用工具,仅在情况小到无需在意或紧急到无暇思考时才使用。你的日常部署应该只触及少数几个路径,并保持缓存的其余部分与你发布前一秒的状态完全一样“温热”。