有個老笑話說,電腦科學裡只有兩個難題:快取失效以及命名。這個笑話之所以歷久不衰,是因為它確實如此。快取很容易加入,卻很難維持正確,而幾乎所有的快取錯誤其實都是失效錯誤:快取正在提供已不再符合資料庫的資料。本文將介紹維持快取資料正確的策略,從簡單的 TTL 到明確與事件驅動的失效,以及如何判斷何時使用哪一種策略。
這是 Redis Masterclass 的第 13 部分,接續 refresh-ahead。
為什麼失效這麼難
困難在於快取與資料庫是同一份資料的兩份複本,而資料庫一變動,快取就錯了,直到有什麼機制修正它。每種策略都對「快取如何得知底層資料已變更」給出不同答案,且各自在資料新鮮度、複雜度與正確性之間做出不同取捨。沒有完美的答案,這也是笑話真正的含義:你永遠在選擇權衡。
策略 1:TTL 到期
最簡單的策略是不主動失效,只讓條目自然過期。你設定一個 TTL,經過一段時間後資料會重新載入新鮮內容。資料過時的程度受 TTL 限制:若 TTL 為 60 秒,快取最多只會落後 60 秒。
SET user:1 "..." EX 60 # 接受最多 60 秒的過時
Enter fullscreen mode Exit fullscreen mode
這適合可接受一定程度過時的資料,而這類資料比人們想像的還多。商品列表晚 30 秒、儀表板晚 1 分鐘、設定在 5 分鐘內更新:對這些情境而言,單純的 TTL 就足夠,而且非常簡單。TTL 的長短依資料可容忍的過時程度決定。缺點是無法同時擁有完美新鮮度與長時間快取:新鮮度與命中率彼此牴觸。
策略 2:寫入時明確失效
當無法容忍過時時,就要明確失效:每次寫入資料庫時,刪除對應的快取鍵,讓下次讀取重新填充。這就是 cache-aside 文章提到的 delete-on-write 規則:
async function updateUser(id, data) {
await db.update('users', id, data);
await redis.del(`user:${id}`); // 立即失效
}
Enter fullscreen mode Exit fullscreen mode
如此一來,資料變更的瞬間快取就已修正,而不是等到 TTL 秒數後。挑戰在於完整性:必須失效寫入影響到的每一個快取鍵,且很容易漏掉。若使用者的資料同時出現在 user:1、users:list 快取以及 search:results 快取,更新該使用者時必須失效這三者。漏掉任何一個就會留下過時條目。明確失效很精準,但需要追蹤資料被快取的所有位置,並在變更時全部失效。
策略 3:基於標籤/群組的失效
當一次變更需失效多個鍵時,逐一追蹤容易出錯。基於標籤的失效會將相關鍵歸類,讓它們可以一起失效。一種做法是使用 Redis set 記錄哪些鍵屬於某個群組:
# 快取時,將鍵記錄在其標籤下
SADD tag:user:1 user:1 profile:1 posts:by:1
# 要失效 user 1 的所有內容:
# 讀取 set、刪除所有鍵、再刪除 set
Enter fullscreen mode Exit fullscreen mode
如此一來,更新 user 1 時就能一次失效整個群組,而不用在每個寫入處手動列舉鍵。這將明確失效的做法擴展到複雜快取情境:單一實體的資料分散在許多鍵中。它增加了簿記工作(維護標籤集合),但消除了「是否記得每一個鍵」的風險。
策略 4:事件驅動失效
在多服務或多副本的系統中,寫入的服務可能不是持有過時快取的服務。事件驅動失效會廣播變更,讓所有相關方都能失效。Redis pub/sub(後續會介紹)或變更串流會傳送「user 1 已變更」的訊息,每個服務訂閱後清除其相關快取:
# 寫入者變更時發布
PUBLISH cache:invalidate '{"entity":"user","id":1}'
# 每個服務訂閱並刪除其對應鍵
Enter fullscreen mode Exit fullscreen mode
這讓分散式系統中的快取保持一致,因為單一節點的 DEL 不足以覆蓋所有副本。它最強大也最複雜,需要訊息機制以及各處的訂閱者。僅在真正分散式的快取情境中使用,因為本地失效無法觸及所有副本。
版本化鍵:無需刪除的失效
另一種巧妙做法是透過變更鍵來繞過失效。在快取鍵中加入版本號,透過提升版本號來「失效」,而不刪除任何東西:
GET user:1:v5 # 目前版本
# 變更時,增加版本指標,讓讀取改用 v6
Enter fullscreen mode Exit fullscreen mode
舊版本的鍵會停止被請求,並依其 TTL 自然過期,而新讀取會使用新版本。這避免了刪除與重新填充交錯導致的競態問題,適合那些精準失效成本高的快取。代價是舊版本會留在記憶體中直到過期。
選擇策略
決策依資料特性而定:
- 可容忍過時? 單用 TTL。最簡單,且足以應對許多資料。
- 需要立即新鮮度、單一服務? 明確的 delete-on-write,若變更影響多個鍵則搭配基於標籤的群組失效。
- 跨服務的分散式快取? 透過 pub/sub 或變更串流進行事件驅動失效。
- 失效競態是問題? 版本化鍵。
大多數真實系統會組合使用這些策略:在所有項目上都保留 TTL 作為安全後盾(即使漏掉失效,最終仍會自我修正),同時對需要新鮮度的資料執行寫入時的明確失效。這種組合效果良好,因為 TTL 能捕捉到任何被明確失效遺漏的部分。
快取失效之所以難,是因為它本質上是要保持兩份複本同步,而每種策略都在新鮮度與複雜度之間取捨。從 TTL 開始,在需要新鮮度的地方加入明確失效,當變更會擴散到多個鍵時用標籤群組,最後只在分散式情境中才使用事件驅動失效。永遠保留底層 TTL 作為後盾,因為你忘記失效的那個鍵,就是你即將釋出的錯誤。
下一篇我們將探討幾個模式已暗示的特定失敗:快取雪崩,當熱門鍵過期時,一群請求同時衝擊資料庫。
重點摘要
- 幾乎所有快取錯誤都是失效錯誤:快取正在提供已不再符合資料庫的資料。
- TTL 到期以零主動工作限制過時程度,足以應對任何可容忍輕微過時的資料。
- 明確的 delete-on-write 提供立即新鮮度,但需要失效變更影響到的每一個鍵。
- 基於標籤的群組失效可同時失效多個相關鍵;事件驅動失效則維持分散式快取的一致性。
- 在明確失效之下保留 TTL 作為後盾,如此即使某條寫入路徑忘記失效,過時條目仍會自我修正。
常見問題
為什麼快取失效被認為很難?
因為快取與資料庫是同一份資料的兩份複本,而資料庫一變更,快取就錯了。每種策略都對快取如何得知變更給出不同且不完美的答案,每種都在新鮮度與複雜度之間取捨。
最簡單的快取失效策略是什麼?
TTL 到期:設定存活時間,讓條目過期並重新載入。過時程度受 TTL 限制,且無需主動失效。只要資料能容忍一點過時,這就足夠了。
資料變更時如何失效快取?
在寫入資料庫後立即刪除受影響的快取鍵(delete-on-write),讓下次讀取能重新填充新鮮值。挑戰在於失效變更觸及的每一個鍵;當變更影響多個鍵時,基於標籤的群組失效會有所幫助。
如何跨多個服務維持快取一致?
使用事件驅動失效:透過 Redis pub/sub 或變更串流廣播變更訊息,讓每個服務訂閱並清除其相關快取。單一本地刪除無法觸及其他服務持有的快取。
即使使用明確失效,是否仍應使用 TTL?
是的。保留 TTL 作為後盾,如此若某條寫入路徑忘記失效某個鍵,過時條目仍會在過期時自我修正。它將永久錯誤轉變為暫時錯誤。
延伸閱讀
本文最初發表於 amanksingh.com/blog/redis-cache-invalidation。
關於作者
Aman Kumar Singh 是印度諾伊達的 Team Lead & Senior Software Engineer,撰寫全端工程、系統設計,以及使用 TypeScript、Next.js、NestJS、PostgreSQL 與 Redis 建置生產級 SaaS 的相關文章。
- 作品集:amanksingh.com
- GitHub:amansingh1501
- LinkedIn:amansingh1597
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.