前陣子在看 side project 的 Firestore 的流量圖,發現一個現象:資料量明明不大(當時只有大約 100 筆活動、100-200 筆藝人資料),但讀取次數卻常常逼近每天 50,000 次上限。
往下查後,發現 /coffeeEvent 這個 API 在一小時內就會觸發 100 多次讀取,單小時讀取次數就來到 10,000 次左右。
確認了原因是 Cache Stampede,我有在 side project server 端做 in-memory cache,拿過的資料會暫存起來,避免每次請求都打資料庫。但當快取需要失效時(例如審核通過,需要拿最新資料時),多個請求同時發現快取是空的,每個請求都同時去 Firestore 拿資料,導致 Cache Stampede 進而造成資料庫讀取次數飆高。
要如何解決這樣的問題呢?和 claude 討論的做法是,透過 Promise Lock 的方式,只有第一個請求會去資料庫拿資料,其它的請求等待第一個請求回來解鎖後,取得資料庫的資料。
做法大概像以下這樣:
async getWithLock<T>(key: string, fetchFn: () => Promise<T>, ttlMinutes: number): Promise<T> {
// 1. 快取有效,直接返回
const cached = this.get<T>(key);
if (cached !== null) return cached;
// 2. 有人正在查詢中,等待結果
const pending = this.pendingRequests.get(key);
if (pending) {
return pending as Promise<T>;
}
// 3. 沒人在查,執行查詢並設定 Lock
const promise = fetchFn()
.then(result => {
this.set(key, result, ttlMinutes);
return result;
})
.finally(() => {
this.pendingRequests.delete(key);
});
// 快取存起來,讓後來的人拿取
this.pendingRequests.set(key, promise);
return promise;
}用這樣的 JS Promise lock 解決了快取失效時的 Cache Stampede,也確實在快取失效後,讀取次數不會瞬間飆升了!目前專案大概有將近 400 筆活動、300 多筆藝人、團體,審核通過後讀取次數大概僅剩下 800-1000 次,相比改善前每次審核通過後都會是 5,000-10,000 次左右,大幅減少了讀取次數的壓力!
