Cache Policy — 效能與一致性之間的抉擇

莫力全 Kyle Mo
10 min readJun 13, 2021
家裡陽台看出去的景色,疫情之下好久沒有出門了,只好在家寫文章。

快取 Cache,是一個軟體開發中重要到不能再更重要的概念,如果完全不知道快取概念的讀者,可以參考我蠻久之前寫過的一篇文章。而快取其實又根據作用的位置分為許多種類,例如 CPU Cache、DNS Cache、Browser Cache,而本篇文章要探討的是介於 Application Server 與 Database 之間的快取,又可以稱作 Server Cache(常見的像是 Redis 或 Memcached),其不同快取策略(Cache Policy)的適用情景與差別。

Server Cache

Server Cache 的策略看似很簡單,當請求來時 Application Server 先到 Cache 看看有沒有資料,有資料的話就直接從快取拿資料,快取沒有的話再去跟資料庫拿資料。

而我們使用快取服務最希望達到的就是高 Cache Hit Ratio,減少 Cache Miss 的機率,如此一來才能最高限度的提升效能,減少資料庫的負擔。

那難題出在哪?為什麼會需要不同的快取策略?在分散式系統架構且面對高流量併發請求下,我們知道 DB 會有資料不一致的問題,而同樣的,快取伺服器也需要面對一致性的難題:

在高併發的流量下,有可能產生快取與 DB 資料不一致的問題。

為了最大化快取的效益值,我們必須依照不同情況採取不同的快取策略,這是非常重要的關鍵,如果選錯了快取策略,連帶的可能造成應用效能比未使用快取時還要糟糕,資料庫也有可能負荷不了高流量,最終導致慘重的損失。

採用適合的 Cache Policy

其實 Caching 機制並不是像我們聽到的那麼簡單:「請求來先去問 Cache,有就直接拿,沒有就再去資料庫抓就好。」這樣說雖然也沒錯,但仍然有太多面向疏於考慮,要選擇適合的 Cache Policy,發揮快取的最大效益,可以從四個面向下手:

  • 資料的種類
  • 快取的位置
  • 快取的讀流程
  • 快取的寫流程

資料的種類

並不是所有的資料都適合存到快取。

這是很基本的一個觀念,通常適合存到快取的資料會有「常被使用」、「不常被修改」的特徵。

再來是得先確定資料的 Access Pattern,也就是資料實際上是怎麼被讀取跟寫入的,讀跟寫的頻率又差距多大。舉例來說,社群媒體上的個人資料通常是寫入一次後,會有多次讀取、而像是系統的 log 則是 write heavy,讀取的次數反而不多,這種情形下要不要快取就要經過謹慎思考了,就算要快取,適合的快取策略也會與 read heavy 的情形不同。

快取的位置

基本上按照快取的位置可以分為兩種 Poliocy :

  • Inline Cache
  • Look-Aside Cache

這兩種策略的差異在於快取溝通的對象與模式不同,這邊我打算搭配快取的讀取流程一起當作講解範例,因為基本上快取的讀流程沒有什麼變化與爭議,都是遵守「先到快取看看有沒有資料,有就直接回傳,沒有再去跟資料庫拿資料。」的原則。

如果採用 Cache Aside 的讀取流程會像上面這張圖,如果快取有資料就直接拿,沒有的話就跟資料庫拿,「資料庫回傳資料後直接給 Application,再由 Application 把資料存回到快取中。」這也是業界最常見到的模式,這種策略的好處在於比較能承受 Cache Failure 的狀況,因為可以再去跟資料庫要資料(當然這對應用效能是硬傷,在高流量下也可能導致資料庫炸掉)。

如果採用 Inline Cache 的讀取流程則會如上圖,與 Cache Aside 的差別在於從資料庫取回資料後會直接存到快取中再傳回給 client ,然後去資料庫抓資料的責任也變為由 Cache Provider 負責。

快取的讀流程

其實快取的讀流程在上面區塊就講的差不多了,不過這邊還是來做個小總結。

基本上當然會先去讀快取(不然還真的不知道你建立這個快取是要幹嘛😂),如果緩存沒東西,再跟 DB 讀,不過根據快取的位置,分為 Inline 與 Look Aside ,那麼跟 DB 要資料的角色就分為兩種狀況:

  • Read-Through (Inline) : 快取跟 DB 要資料
  • Read-Aside (Look Aside):Application 跟 DB 要

兩種方法有各自的優缺點,不過業界上較常見的似乎是 Read Aside 的方式。

另外快取的讀取流程會有一個問題,那就是在第一次讀取時,因為快取裡還沒有資料,所以一定會到資料庫拿,有些開發者會採用 「warning」 或「pre-heating」的方式手動發起 query,讓快取先存好資料,使用者第一次發出請求時快取就有資料可以回傳。

快取的寫流程

快取的寫流程才是快取策略最複雜與坑最大的地方,上面有提到過「在高併發的流量下,有可能產生快取與 DB 資料不一致的問題。」而比較大的問題就是出在寫的流程,因為不同的寫入方式會造成不同的問題,而快取的寫策略就是為了解決不一致性的問題,不過這裡得先強調,這個問題沒有完美的解法,就算看似最佳的解法也會有它無法解決的狀況。

快取的寫策略大致可以分為

  • 先存 DB 後存 Cache
  • 先存快取後存 DB

先來看看「先改快取,再改 DB 」的狀況

不過關於寫快取這塊,其實可以分為修改(set)與直接刪除(delete)快取,在架構師之路的其中一篇文章中建議採用刪除快取而非修改快取,原因在於高併發狀況下修改快取,可能會導致資料不一致的問題

正確的快取與 DB 資料應該要是 100,但卻是 200

原因在於我們沒辦法確定到底哪一個請求會先完成。

不過前面也說過了,沒有完美的解法,雖然刪除緩存可以避免高併發「寫入」不一致的問題,但他卻會在同時有並行「讀取」時有可能會發生問題

快取 0 ,DB 200 的不一致狀況

雖然以上狀況發生機率也許不高,的確比修改快取產生的不一致來的好,但只要有可能發生就有風險,真的發生了,是不太好處理的,另外如果採用直接刪除快取,則會產生第一次請求快取會沒有資料的問題。。

如果不考慮讀取的狀況的話,這種「先改快取,再改 DB 」的方式還有幾種策略,不過以下策略是採用更改快取而不是刪除快取,因為它們較依賴快取提供的資料,期望使用者可以在快取就找到資料:

  • Write-Through:更新快取後,同步更新 DB。因為同時修改快取跟 DB,所以會比沒用快取還要慢,但是卻增強了一致性,如果是寫入一次後,接下來幾乎會是大量的讀取請求,那這樣的寫入 Latency 也許是值得的。
  • Write-Back | Write-Behind:更新快取後,非同步更新 DB。寫入快取後就回傳給 Client,等有空閒時再慢慢修改 DB ,降低了 write lantency。為了確保用戶不會有拿不到資料的狀況,最晚也要在快取要過期並被 evict 以前完成對 DB 的更改。這種方式的缺點很明顯,通常快取資料是存在 Memory 中的,萬一快取服務 crash 了,資料很可能就永遠遺失了。

再來看看「先改 DB,再刪除快取的狀況」:

即便是先改 DB,如果選擇修改快取,ㄧ樣有可能會產生不一致問題,因此這邊從刪除快取的策略來看。也就是說流程是這個樣子:

  1. 用戶請求修改資料
  2. 先修改 DB資料
  3. 直接將快取移除

這個模式也被稱作 Cache Aside Pattern,以快取位置來看是採用 Look Aside,這個模式也是 Facebook 採用的 Policy,不過它會產生一個問題:快取操作 Failure 會產生資料不一致

刪除快取失敗,Request B 讀到舊的快取

但這個狀況發生的機會不高,如果 Retry 幾次刪除快取操作都不成功,很有可能是快取 Server 掛掉了,那這樣子的狀況下 Request B 應該也讀不到舊的快取資料才對,另一個解決方案則是可以在快取操作 Failure 時對 DB 做 rollback。

雖然 Facebook 採用 Cache Aside Pattern,不過仍有一些人認為先刪除快取再改 DB 是比較好的做法,也有些人認為應該修改快取而不是直接刪除快取,這邊留給大家做個反思,什麼樣的快取策略會比較適合你的需求呢?

小結

我猜可能看到這裡已經有些讀者感到眼花撩亂了。不過最後讓我們總結一下,快取策略可以依照快取的位置與讀寫的方式來區分,雖然看似有很多種搭配組合,但實際上並不是隨便搭配都能發揮效用,在業界中比較常見的策略組合可能有:

look-aside-cache | read-aside | write-around

inline-cache | read-through | write-through

透過這篇文章,我們也了解到,實際上有些問題沒有完美解,只有較適合、較佳的解法,因此大概了解每個策略的 Pros & Cons,才能依據自己的需求挑選出適合的 Cache Policy 組合。

Reference

--

--