在 CDN 缓存后统计页面浏览量且不丢失数据
边缘缓存使大多数页面浏览量无法到达您的源站,因此内联计数会失效。将收集操作移至信标,并替换路由提供给您的守卫。
为 HTML 开启边缘缓存后,大多数页面浏览不再到达源站。这正是其目的所在。它也悄悄地导致了浏览量统计失效,因为计数器位于请求路径上,而该请求路径现在基本上是空的。修复方法是将收集操作移至一个缓存从不提供服务的小型 POST 请求中。但没人提醒你的部分是,请求路径本身也充当了验证器的角色,而脱离它则意味着你需要重建一个你都不知道自己拥有的防护机制。
为什么内联计数在缓存之后会失效
在页面处理器内部统计浏览量是显而易见的实现方式。请求已经到达,你知道是哪篇文章,并且可以避免一次额外的往返。这种方式一直工作得很好,直到你的应用前面部署了缓存。
一旦边缘节点缓存了 HTML,缓存命中时将直接提供服务,而完全不会触及源站。你的处理器不会运行。计数不会增加。计数器现在衡量的是缓存未命中数,这几乎与你想要的结果背道而驰:页面越受欢迎,它就越能稳定地由边缘节点提供服务,其显示的浏览量反而越少。
这种失败是静默的。没有错误,没有警报,也没有失败的请求。数字仍在变动,只是缓慢且错误地变动。如果一个热门文章小组件读取了这些数字,它就会开始按照“哪些页面碰巧未命中缓存”来进行排名,而这与缓存淘汰和流量分布相关,而不是与阅读量相关。
请求路径在默默守护着什么
这是容易被忽略的部分。原来的计数器看起来是这样的:
// runs only after routing proved the article exists and is published
if !isBot(r.UserAgent()) {
views.Hit(r.Context(), lang, slug)
}
那个位置的部署承担了三项工作,而其中只有一项是显而易见的。
显而易见的工作是计数。第二项工作是保证顺序:计数器在处理程序加载完文章并确认其已发布后才运行,因此未知或未发布的 slug 永远不会进入缓冲区。路由免费充当了验证器。第三项工作是物理速率限制。读者产生浏览量的速度不可能快于他们加载页面的速度,而加载页面的成本足够高,以至于没人会将其视为一个攻击面。
将收集操作移至一个公共端点,这三项工作就都立刻变了。现在由客户端来指定 slug,所以任何东西都可以命名任何东西。没有任何东西加载过文章,因此也无法证明文章的存在。而且,一个短消息体的 POST 请求成本很低,足以在循环中发送。
这才是迁移的真正成本,并且任何教程里都不会提到。计数器本身只有十行代码。你必须重建的防护机制才是剩下的全部工作。
将收集移至信标
该端点被特意设计得平淡无奇。它接受 POST 请求,记录一次浏览,并返回不含正文的 204 响应。
func (s *Server) handleHit(w http.ResponseWriter, r *http.Request, lang string) {
w.Header().Set("Cache-Control", "no-store")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBeaconBody))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(string(body))
if !validSlug(slug) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if isBot(r.UserAgent()) {
w.WriteHeader(http.StatusNoContent)
return
}
s.views.Hit(r.Context(), lang, slug)
w.WriteHeader(http.StatusNoContent)
}
有三个细节远比表面上看起来更重要。
Cache-Control: no-store 并非可有可无的装饰。如果你的缓存规则很宽松,POST 响应最终可能会被缓存,然后整个过程会在下一层重复自身。大多数 CDN 默认不会缓存 POST 响应,但明确声明这一点没有任何成本,并且能经受住未来的规则变更。
方法必须是 POST,而不是 GET。GET 信标是可缓存、可预取的,并且会被链接扫描器触发。POST 则没有这些问题。
在客户端,应该调用 sendBeacon 而不是 fetch:
if (navigator.sendBeacon) {
navigator.sendBeacon(url, slug);
} else if (window.fetch) {
fetch(url, { method: "POST", body: slug, keepalive: true }).catch(function () {});
}
sendBeacon 在页面卸载后仍可继续运行,而不带 keepalive 的 fetch 则不行。并且它以 text/plain 格式发送字符串正文,这使请求保持在简单请求类别中,并避免了预检请求。因为该脚本在每次页面加载时运行一次,所以每次浏览你都能精确地获得一个信标,无需去重逻辑。从浏览器缓存中恢复的后退和前进导航不会重新运行该脚本,因此历史导航也不会增加计数。
替换你刚刚移除的防护机制
对于任意 slug,一个诱人的修复方法是在每次信标请求时都查询文章。请抵制这种做法。每次信标请求都进行一次读取操作,其成本比你正在缓冲的写入操作更高,并且在按量计费的数据库上,这会将一个廉价的计数器变成你最大的读取来源。
三个低成本的层可以替代它,它们都不会触及数据库:
写入操作已经是非更新插入(non-upserting)的。计数器使用更新(update)操作,而不是更新插入(upsert)操作,因此一个未知的 slug 会匹配零个文档,不会造成任何更改。不会出现幽灵行。这个特性很可能已经存在于你的代码中,在构建任何其他东西之前,值得确认一下,因为它免费消除了最可怕的故障模式。
缓冲区获得一个基数上限。浏览量通常在内存中累积,并定期刷新。为每个窗口的唯一键添加一个上限:
v, loaded := b.counts.LoadOrStore(key, new(int64))
if !loaded && b.keys.Add(1) > maxBufferKeys {
b.counts.Delete(key)
b.keys.Add(-1)
return
}
现有的键会不断累积,因此真实流量不受影响。只有超过上限的新键才会被丢弃。真实基数是文章数乘以语言数,对于几十篇文章来说,这个数值比一万个键的限制低了两个数量级。
该端点有自己的速率限制器。使用一个与其他任何受限路由都独立的令牌桶。共享令牌桶意味着对一个端点的滥用会把读者锁在另一个端点之外。读者每次查看文章会发送一个信标,因此,每秒 2 次的持续速率外加 20 次的突发量,既能覆盖一次性打开多个标签页的情况,又能阻止循环。
CORS 不是该防御的一部分
一直以来有一种看法,认为将 POST 从 Access-Control-Allow-Methods 中移除可以阻止其他源向你的端点发送 POST 请求。事实并非如此。
一个 text/plain 主体的 POST 是一个简单请求。浏览器无需预检请求就会发送它。然后 CORS 决定调用页面是否可以读取响应,而由于信标完全忽略响应,攻击者没有任何损失。请求已经到达,浏览次数也已被计入。
所以这个响应头并不能保护你,将 POST 添加进去也无济于事。添加它会开放先前被阻止的、需要预检的跨源调用,这纯粹是增加了攻击面而没有任何好处,因为你自己的信标是同源的,根本不会查询 CORS。
正确的做法是不要动这个响应头,并在它旁边写下原因,这样下一个人就不会为了保持一致性而添加 POST。防御措施是速率限制器和缓冲区上限。在注释中点明这一点,比那个响应头本身有价值得多。
守护了错误方向的测试
这次变更带来的最有用的东西,就是一个测试失败。有一个测试,它断言加载文章会增加其浏览量,而这个测试立刻就失败了。
这个测试在编写时是正确的。现在它完全反过来了。在 CDN 环境下,如果文章处理器会增加计数器,那它本身就是个 bug,因为这意味着计数逻辑已漂移回一条大多数读者永远不会触及的路径上。因此,这个测试没有被删除,而是被反转了:
// Loading an article must NOT count a view. Collection belongs to the beacon,
// which the cache does not serve. A failure here means inline counting is back.
if got := viewCount(t, srv, st, "hello", "en"); got != 0 {
t.Fatalf("view count = %d on the SSR path", got)
}
反转测试而非删除测试,这便是全部的教训。一个被删除的测试不会留下任何痕迹,六个月后,有人会因为看起来像是个疏忽而将 views.Hit 添加回页面处理器中。一个反转的测试会在旧契约曾经存在的地方声明新契约,一旦旧设计回归,它就会明确地失败。
将其与一个测试配对,该测试用于检验信标脚本是否确实渲染到了页面中。端点正常工作和页面调用它是两种独立的故障,发布其中一个而没有另一个,会导致你得到一个没有任何东西调用的、能正常工作的计数器。
上线后需要检查什么
要端到端地验证整个链条,而不要只相信端点本身。确认信标脚本出现在渲染后的文章中,并且带有正确的 slug。确认 POST 请求返回 204,并带有显示其已绕过缓存的缓存状态标头。检查源站日志,确认 POST 请求已到达,因为被内容安全策略阻止的信标会在浏览器中彻底失败,且不会在服务器上留下任何痕迹。然后,等待一个刷新间隔,并确认数字有所变动。
还有最后一件事要写在显眼的地方:绝对值现在是不连续的。变更前统计的是缓存未命中数,变更后统计的是实际浏览量。相对排名会在几小时内恢复,但跨越此次切换的累计总数是两种不同衡量标准相加的结果,如果不写在变更日志里,三个月后就没人会记得这件事了。