...
Back

你测到的关于缓存的一切,很可能都是错的

我们打开了一个边缘缓存优化,把整站打成了 500。但更耐久的教训不是那次故障 —— 而是我们一直用来验证缓存的那个工具,从头到尾都在骗我们。

你测到的关于缓存的一切,很可能都是错的

你测到的关于缓存的一切,很可能都是错的 🧊

有两件事发生的时间足够接近,值得当成一个故事讲。我们启用了一个激进的边缘缓存拦截设置,站点开始返回 500 —— 不是某些路由,是全部。而另外一件,是在排查过程中我们发现:那条用了好几个月、用来检查缓存是否生效的命令,根本回答不了这个问题。

故障是昂贵的那件。测量错误是会轮到你身上的那件。


你的探针改变了答案

习惯做法是用 HEAD 请求去看缓存头,因为它快,而且你只想要头:

curl -I https://example.com/some/path      # HEAD

在某些边缘网络上,HEAD 请求永远不会是缓存命中。它不像 GET 那样被缓存,所以状态头回来的样子就像这个资源不可缓存 —— 每一次、对每样东西都是,包括那些实际上正被完美地从缓存提供的资源。我们花了实打实的时间去"修"那些本来就在工作的缓存规则,因为我们的仪器从构造上就注定报告失败。

正确的探针是丢掉响应体,但发出一个真实请求:

# 真正的 GET,只看头,body 丢弃。
curl -sS -o /dev/null -D - https://example.com/some/path | grep -i 'cf-cache-status\|age\|cache-control'
 
# 第二个请求比第一个告诉你更多 —— 先 MISS 后 HIT 才是那个信号。

推广到这个具体的头之外:用你的用户所发出的那种请求形状来验证。 一个在方法、请求头或协议上与真实流量不同的探针,测量的是另一套系统,而不是你在问的那套。


缓存决策依据的东西,可能不是你以为的输入

另外两个让我们意外的:

规则常常按文件扩展名匹配,而不是按体积或内容类型。 我们以为某个大的二进制资源因为体积太大而没被缓存;实际上是因为它的后缀。把同样的字节从一个扩展名改成另一个,它就从"永不缓存"变成了"稳定缓存"。如果你在推理某个东西为什么被缓存或不被缓存,去看实际的匹配规则,而不是那个看起来相关的属性。

Range 请求的行为和普通请求不一样。 媒体元素用字节范围来取数据,而一个 range 响应并不会像完整响应那样填充浏览器缓存。所以一个视频可以在边缘被完美缓存,却仍然在每次播放时被重新拉取。边缘缓存和浏览器缓存是两个独立的层,有各自的规则,而对其中一个的测量对另一个什么也没说。


那次故障,简短版

我们启用的那个设置,让边缘在请求生命周期中更早地拦截并提供缓存响应 —— 这是实打实的性能收益,而它假设存在一个持久化的存放位置,是框架的缓存模型预期要有的。我们的没有配。结果不是缓存效果变差,而是每一个请求硬失败。

两条教训,都不是专门关于缓存的:

一个改变了请求在哪里被处理的性能优化,不是一个性能改动。 它是一个带性能收益的架构改动,应当接受架构级评审。我们把一个布尔值当成了调优旋钮,因为它是以旋钮的样子呈现的。

"全站 500"是"前置条件缺失"的长相。 当一个开关放倒了所有路由时,假设几乎从来不是"这个开关有 bug",而是"这个开关需要某个不存在的东西"。这个重新表述能比通读开关的实现快得多地找到原因。

这个设置在我们的配置里仍然是关闭的,附带一条注释说明为什么关,以及要满足什么条件才能重新打开。那条注释才是这次故障真正的交付物。一个被拨回安全值却没有解释的开关,是留给下一个读配置、觉得"这看起来挺保守"的人的陷阱。


值得保留的习惯

在相信任何关于基础设施行为的测量之前,问:我的探针像真实流量吗?探测这个动作本身会不会改变结果?

对缓存来说,答案经常是"不像"和"会",按这个顺序。同一个问题也适用于绕过负载均衡器的健康检查、从同一区域内部发起的延迟测试、以及用与生产不同的 API key 做的限流检查。每一个里面,仪器都在微妙地采样着另一套系统 —— 而一个对着另一套系统信心满满地报数的仪器,比没有仪器更糟。