在 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,所以可以隨意指定任何 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 格式傳送字串主體,這會讓請求保持在簡單請求的類別中,並避免預檢 (preflight)。因為指令碼每次頁面載入只會執行一次,所以每次檢視只會得到一個信標 (beacon),無需任何去重複的邏輯。從瀏覽器快取還原的上一頁和下一頁導覽不會重新執行指令碼,因此歷史記錄導覽也不會增加計數。
取代你剛移除的防護機制
對於任意 slug,一個誘人的修復方法是在每次 beacon 時查詢文章。請抗拒這種做法。每次 beacon 都讀取一次,比你正在緩衝的寫入操作更昂貴,而且在計量資料庫上,這會將一個便宜的計數器變成你最大的讀取來源。
三個廉價的層級取代了它,而且都不會動到資料庫:
寫入操作已經是 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
}
現有的鍵會持續累積,因此真實的流量不受影響。只有超過上限的新鍵會被丟棄。真實的基數是文章數乘以語言數,對於幾十篇文章來說,這個數字比一萬個鍵的限制低了兩個數量級。
該端點有自己的速率限制器。請使用與任何其他受限路由分開的權杖桶。共用權杖桶意味著濫用一個端點會將讀者鎖在另一個端點之外。讀者每次瀏覽文章會發送一個信標,因此,每秒持續兩次的速率,加上二十次的突發量,足以應付一次打開多個分頁的情況,同時又能阻止迴圈。
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 加回頁面處理常式中。反轉的測試會在舊合約曾經存在的地方陳述新的合約,並且一旦舊設計回歸,它就會大聲地失敗。
將它與一個測試配對,該測試確保信標腳本確實渲染到頁面中。端點正常運作和頁面呼叫它是兩個獨立的失敗點,而交付其中一個卻沒有另一個,會讓你得到一個沒有任何東西呼叫的、可運作的計數器。
上線後要檢查的事項
請端對端地驗證整個鏈路,而非僅信任端點。確認信標腳本 (beacon script) 出現在渲染後的文章中,並帶有正確的 slug。確認 POST 請求回傳 204,並帶有一個顯示其已繞過快取的快取狀態標頭 (cache status header)。檢查來源伺服器日誌 (origin logs) 中是否有 POST 請求抵達,因為被內容安全政策 (content security policy) 封鎖的信標會在瀏覽器中完全失敗,且不會在伺服器上留下任何痕跡。然後等待一個刷新間隔 (flush interval),並確認數字有所變動。
最後還有一件事要寫在顯眼的地方:絕對值現在是不連續的。變更前的所有數據計算的是快取未命中次數,變更後的所有數據計算的是實際瀏覽次數。相對排名會在幾小時內恢復,但一個橫跨這次切換的累計總數是兩種不同測量方式的加總,而除非記錄在變更日誌 (changelog) 中,否則三個月後就沒人會記得這件事了。