Jason Thorsness

githubgithub iconlinkedinlinkedin icontwittertwitter icon hi 26 — May 12 25

Tower Defense: Cache Control (缓存控制)

26 — May 12 25 share ontwitter icon share ontwitter icon 对于那些直接来到这里并且错过了我的离奇介绍的人来说,这不是一个游戏。 这是一个更好的东西:一篇关于缓存的文章!

我将描述我的网站 jasonthorsness.comhn.unlurker.com 上使用的技术。 后者是开源的,因此您可以仔细阅读实现

如果您希望通过网络暴露的副项目来防御微薄的预算,以抵御网络流量的猛攻,您可能会发现这些方法很有用。

如果您是问题的一部分,是无数用户中的一员,成群结队地一波又一波地冲击我的防御——请继续阅读以侦察我的设置,并找出它是否能应对挑战。

本文逐步介绍三个难度级别:

难度等级| 描述 ---|--- Easy (简单)| 大部分是静态网站 Medium (中等)| 数据驱动的动态网站 Hard (困难)| 经过身份验证的每个用户的网站

Easy (简单): Mostly-Static Sites (大部分是静态网站)

Static (静态)意味着对于每个用户来说,内容都是相同的,并且不会随时间变化而变化。 例如,由于我写作速度缓慢,jasonthorsness.com 在两次更新之间保持不变数周。 即使那样,我的像这样的旧文章 也不会改变,除非我调整一个通用的布局。 对于这种网站,应用 content-hashed (内容哈希)资源、使用 CDN 并保持动态部分在客户端是主要的行业组合。

Content-Hashed Resources (内容哈希资源)

为了改进对支持资源(CSS、JS、图像等)的处理,现代 Web 框架中的一个通用做法是将 content hashes (内容哈希)添加到资源名称中。 通过从文件内容派生文件名称,您可以认为具有给定名称的文件是不变的。 例如,在这个网站上,在撰写本文时,其中一种字体以 /_next/static/media/bd734242e06bd6ad-s.p.woff2 的形式提供服务,其缓存控制标头为 public,max-age=31536000,immutable。 名称的 bd734242e06bd6ad 部分是文件内容的哈希值。 CDN 和用户的浏览器会将此文件缓存尽可能长的时间,而无需担心它会变得陈旧。 如果我更改了字体,该文件将具有不同的名称,因此所有缓存都会错过并从源服务器获取新字体。

如果您在浏览器的开发工具中打开网络选项卡并刷新此页面,您会看到大多数资源都以这种方式提供服务,并在 0-1 毫秒内从内存或磁盘缓存中到达。 这同时也是成本最低和延迟最低的缓存方式——除了用户的浏览器之外,没有任何东西可以工作。

network

从磁盘或内存提供的资源

🏰 在 Tower Defense (塔防)术语中:浏览器缓存中的 content-hashed (内容哈希)静态文件让您可以在生成点击败大量请求。

CDNs (内容分发网络)

从网站本身外部引用的资源(例如您在 URL 栏中看到的路径)不能附加哈希值,因为每当内容更改时链接都会断开。 服务器改为使用“Etag”标头传递响应,该标头标识内容的当前版本。 当浏览器再次请求资源时,它会在“If-None-Match”标头中包含最后一个 Etag 值。 每当服务器上的当前 ETag 与浏览器中的 If-None-Match 值匹配时,服务器都可以使用 304 Not Modified 而不是实际内容进行响应。

如果您再次在浏览器的开发工具中打开网络选项卡并刷新此页面,您会看到 /26 通过 304 响应提供服务。 您还应该看到整个页面的传输量少于 2 kiB! 如果您看到的数量超过此值,则可能是您安装的浏览器扩展程序注入了内容。 在隐身模式下或使用访客个人资料再次尝试。

304 Not Modified 的一个问题是仍然需要往返服务器。 但这真的是个问题吗? 如果您仔细查看该网络选项卡,您可能会看到 /26 在大约 ~60 毫秒内提供给您。 此页面的单个源服务器位于美国东部某处,但报告的延迟将在全球范围内保持较低水平。 这是因为资源不是从源服务器提供服务,而是从全球交付点网络缓存和提供服务,通常称为 CDN(内容交付网络)。 这减少了源服务器的负载,并确保了全球用户的快速性能。 如果您尊重您的用户,低延迟的全球访问非常重要 - 即使这个无关紧要的博客也会定期获得来自美国、欧洲和亚洲的流量。

该网站使用 Vercel 的 CDN,在撰写本文时,它有 119 个位置 来提供内容。 除了静态资源之外,您还可以在这些位置运行代码以实现自定义的低延迟功能

network

低延迟、低传输

🏰 CDN 位置是生成点和您的基地之间的塔。 当事情正常运作时,他们会处理大部分攻击。

What About The Dynamic Parts? (动态部分呢?)

即使是大部分静态网站也可能具有动态组件。 例如,除了上面提到的城市搜索 边缘函数之外,我还有一个 VPS 监控图表、一些 DIY 分析、一些 LLM 恶搞 以及一个需要用户登录以编译代码更改 的页面。 所有这些都需要额外的动态内容,这些内容会因时间或用户输入而异。

为了确保大多数内容可以保持针对静态交付进行优化,动态部分全部通过客户端 JavaScript 处理,该脚本向专用动态 API 端点发出单独的 API 调用。 这实现了站点静态部分和动态部分的清晰分离,并且还有助于防止爬虫和其他机器人不必要地触发动态功能。

🏰 对动态资源的请求通常必须到达源服务器。 幸运的是,在 Tower Defense (塔防) 比喻中,源服务器本身并不是爬行者的最终目标。 他们追求的是内部的宝贵资源:CPU 周期和上游 API。 继续到下一个难度级别,了解一些“服务器内部”的保护策略。

Medium (中等): A Data-Driven Dynamic Site (数据驱动的动态网站)

数据驱动的网站的大部分内容会随着时间自动更改。 这个博客不是这样的网站,因此在本节中,我们将看看我最近的项目 hn.unlurker.com。 Unlurker 完全属于动态类别:它始终只显示来自 Hacker News 的最新活动。 内容很快就会变得陈旧,因此缓存是一个挑战。 Unlurker 网站使用两层缓存:

Short-Term Cache-Control Headers (短期缓存控制标头)

即使您拥有数据驱动的动态网站,通常也可以将内容在短时间内视为静态内容。 对于 Unlurker,新的评论和故事每分钟只会显示几次,因此我可以应用以下缓存控制标头:

public, max-age=15, s-maxage=15, stale-while-revalidate=15

如果您查看 hn.unlurker.com 中的标头,您只会看到 public, max-age=15,因为 CDN 会剥离其余部分并在内部处理它们。 要查看效果,请每隔几秒钟来回切换下拉列表中的选项。 您会看到延迟永远保持在 < ~60 ms。 由于 stale-while-revalidate=15,CDN 在后台异步执行来自源服务器的昂贵刷新。 您可以检查 X-Vercel-Cache 标头以查看内容是 HIT(新鲜)、STALE(仍在使用但触发了异步刷新)还是 MISS(完全陈旧并从源服务器同步获取)。 对我来说,这对应于本地缓存的延迟为 1-2 毫秒,CDN 命中或过时的延迟为 ~60 毫秒,而完全到我的可怜的 VPS 且可能到 HN API 的延迟可能为 ~800 毫秒。

stale-while-revalidate

三种 Vercel 缓存状态

stale-while-revalidate 是一个相对较新的缓存控制选项。 它可以保持所有用户的低延迟; 在缓存过期后,没有人会为成为第一个请求者而“付出代价”。 对此标头的控制是我无法将 NextJS 用于 Unlurker 的原因 - NextJS 似乎不支持动态页面,并且 NextJS ISR 与 stale-while-revalidate 相比存在一个主要限制,因为它不支持最大陈旧度。 对于低流量站点,用户可能会看到几个小时前的内容,这是不可接受的。 我切换到 Vercel 上的 react-router,它不会弄乱标头。

使用 hn.unlurker.com 上提供的选项,只有 10 * 12 * 8 * 2 或 1920 种可能的组合,最多每 15 秒刷新一次,因此该技术将前端请求速率限制为每秒 128 个请求,而不管来自用户浏览器的传入请求速率如何。

在这种情况下,前端函数不应用进一步的缓存,并且每个请求都会启动对后端 API 的单个获取以获取数据。

🏰 Tower Defense (塔防) 比喻是否正在崩溃? 短期缓存控制标头就像我们到目前为止讨论的浏览器缓存和 CDN 塔,清除几乎所有的爬行者,但它们会定期生成自己的爬行者,前往源服务器。 这会是一种新颖的游戏机制吗? 您首先在这里阅读它。

Backend Caches (后端缓存)

Unlurker 后端在共享的 2 vCPU VPS 上运行。 在那里运行的程序是保护 CPU 周期和上游 HN API 的最后机会。 对于像这样的动态站点,Web 服务器内部的缓存和高效的请求处理与利用浏览器缓存和 CDN 一样重要。

Unlurker 后端使用内存缓存来保护 CPU 周期,然后使用单例化和磁盘缓存来提高性能并保护 HN API。

Memory Caching (内存缓存)

任何计算成本高昂且可能被多次请求的东西都适合保存在内存缓存中。 Unlurker 维护每个项目的 规范化评论文本的缓存。 评论和故事本身也存储在内存缓存中 60 秒。 这使得没有缓存未命中的请求的成本仅为几个哈希查找加上响应序列化。

Single-Instancing (单例化)

在内存缓存未命中时,后端需要从磁盘缓存或什至 HN API 获取故事或评论。 这些是相对昂贵的操作。 为了降低成本,对同一资源的请求被合并为单个请求。 这在 Go 中很容易,通常使用 singleflight 包,但在这种情况下(为了与内存缓存良好集成)使用 自定义实现。 无论有多少并发请求进来,都只会对磁盘缓存进行一次检查,并且只会向 HN API 发出一个请求。 Unlurker 对 HN API 的总体负载可能淹没在噪声中(尤其是如果我通过说服足够多的人尝试 下载整个东西 创建了负载)。

Disk Caching (磁盘缓存)

单独的内存缓存存在几个问题:RAM 中的空间有限,并且进程重新启动会清除整个缓存。 为了解决这些问题,Unlurker 还将故事和评论保存在 SQLite 数据库形式的磁盘缓存中。 这比内存慢一点,但实际上没有大小限制,并且可以在进程重新启动后幸存下来。

磁盘缓存没有在固定的 60 秒后使项目过期,而是使用基于故事或评论年龄的“陈旧”函数。 它从新项目的大约 60 秒慢慢攀升到几天前的大约 30 分钟,然后更快速地增加,直到超过几个星期前的项目被认为是不可变的。

对项目的请求是分批进行的,因此从项目的创建时间派生过期时间也有助于消除过期集群,并将对 HN API 的请求随时间分散开来。

🏰 内存缓存、单例化和磁盘缓存是位于基地周围的强大塔群,可以处理几乎所有剩余的爬行者。 如果它们选择得当,它们可以处理令人难以置信的负载,并且最终传递到消耗大量 CPU 并启动对 HN API 的请求的数量将少于您的健康点数。 你赢了!

Why Not Redis? (为什么不用 Redis?)

我只有一个 VPS,所以我可以使用简单的 SQLite 数据库。 如果我在单独的服务器上有我的 API 的许多实例,我可能在某个时候想要用 Redis 实例替换磁盘缓存,并且_可能_考虑使用 Redis 进行跨服务器单例化。 但对于我的网站(可能还有大多数其他网站)来说,这远远超出了情况的需要。

Hard (困难): Authenticated Per-User Sites (经过身份验证的每个用户的网站)

不幸的是,对于本文而言,我最近才开始向我的副项目添加一些经过身份验证的功能。 文章 LLM not LLVM 要求用户在可以使用 LLM“重新编译”页面上的示例之前进行身份验证,但这仅是一个客户端功能。

对于每个用户的网站,第一步始终是识别和隔离非每个用户的部分,并使用与静态和动态网站相同的技术为它们提供服务。

除此之外,对于真正的每个用户的部分,在边缘进行缓存变得更具挑战性 - 数据通常过于敏感而无法在 CDN 中缓存,即使您可以缓存,它也是每个用户的,因此缓存命中很少。 缓存解决方案成为用户浏览器和源服务器之间的合作伙伴关系,后者了解身份验证方案,并且可以使用已经提到的相同内存和磁盘以及单例化方案缓存每个用户的响应。

将数据下载到用户浏览器并在本地处理请求的策略可能会有所帮助。 这样,可以在客户端上计算缓慢变化的数据的许多功能,并且只需要从服务器同步增量。

还有更多方法 - 也许我的下一个项目应该要求进行身份验证,以便我可以更多地探索此难度级别。

Conclusion (结论)

缓存一直对站点性能至关重要,并且随着越来越多的站点依赖于计量 API(如 OpenAI 的 LLM)和无服务器托管提供商(如 Vercel),它对于成本管理也变得同样重要。 正确设置缓存架构,您会惊讶于只需几个 vCPU 和几个千兆字节的 RAM 即可扩展微薄的预算。 请记住,过去的网站运行在具有当今网站资源的一小部分的服务器上,并且在许多情况下,由于更仔细的缓存和计划,它们可能更好地处理了负载。

感谢您的阅读! 如果您有任何问题或意见,请通过 X 与我联系。 如果有人想开发 Tower Defense: Cache Control,请直接去吧!

Top TermsPrivacy