服務緩存設計指南

設計服務時,我們通常使用緩存來提升系統的性能和擴展性。最核心的點是把頻繁訪問的數據拷貝到一個訪問性能更好的存儲上。如果這個更快的存儲同時離服務實例非常近,比如專線連接的不同機房、同一個機房裏的相同 VPC、同一個機架上、甚至同一個 Pod(k8s 最小部署單元)裏,緩存能夠更快地提供數據,顯著降低 client 端的響應時間。

緩存最有效的場景是:client 端重複讀取相同的數據,尤其是數據源符合下面四個條件時:

分佈式應用下的緩存策略

分佈式應用通常會採用下面兩種緩存策略:

這兩種策略下,緩存邏輯既可以放在 client 側,也可以放在 server 側。client 側緩存由提供用戶交互的進程 / 線程來實現,比如網頁瀏覽器或桌面應用。server 側緩存由運行在遠端、提供業務邏輯支持的進程來實現。

私有緩存

最常見的私有緩存是一個內存緩存,它運行在進程的地址空間裏,該進程中的代碼可以直接訪問緩存中的數據。這種類型的緩存訪問速度最快,它可以存儲中等數據量的靜態數據。緩存大小通常與進程運行的機器 / 虛擬機 / 容器的內存相對應。

如果需要緩存的數據量超過了實例的內存大小,可以把數據寫入本地文件系統。進程訪問文件系統裏的數據會比內存慢很多,但相對於通過網絡從遠端拉取數據,通常會更快一些。這種情況在近年來有一些變化,網卡速度和硬盤速度更多呈現你追我趕的態勢,比如萬兆網卡讀取速度可達到 10Gbps,換算一下 1.25GB/s;而 SSD 的讀取速度是 500MB/s,高端的 NVMe SSD 讀取速度可達到 7000M/s。實際場景中,還需要結合成本、穩定性等多個因素考慮。

考慮到降本增效,內存磁盤混用也是比較常見的方案,但爲了保證內存的命中率,避免太多請求落到磁盤上,算法上需要做很多優化。由於自己寫難度太高,業界提供了比較成熟的方案,比如基於 LSM Tree 的 RocksDB,可以直接引入代碼依賴把一個數據庫嵌入到服務裏。

如果一個多實例的服務採用了該模式,每個實例都會有自己的一份緩存,緩存互相獨立,都有自己的數據。

從某個角度來看,緩存是原始數據在過去某個時間點的快照。如果數據不是靜態的,不同服務實例的內存裏將存儲不同版本的快照。結果就是:不同實例上執行同一個查詢可能獲取不同的結果,具體如下圖所示:

圖 1: 服務不同實例各自有自己的內存緩存

共享緩存

使用共享緩存,可以解決私有緩存不一致的問題,它能保證不同的實例看到同一份緩存數據。緩存作爲一個服務與業務服務獨立部署,如下圖所示:

圖 2: 使用共享緩存

共享緩存提供了很好的可擴展性。緩存服務通常部署在一個服務器集羣上,通過 hash 算法把數據均勻地分散到集羣的各個服務器節點上。這些邏輯對於業務服務是透明的。通常業務服務的實例只需要把請求發送給緩存服務,緩存服務決定數據存放在哪些節點上,或從哪些節點上讀取數據。緩存服務擴容對業務服務也是透明的,所以操作起來簡單很多。

共享緩存最常見的是 Redis/Memcached 等 KV 緩存,有一個例外是,Google 的 groupcache,它直接使用業務的服務器節點內存,看起來像是私有緩存,但通過服務發現,可以使用集羣裏所有實例的內存,實現一個分佈式緩存。

值得注意的是,緩存服務擴容時,讀寫性能可能有一定程度的下降,可根據業務負載判斷是否需要在低峯期進行。

共享緩存有兩個缺點:

什麼時候用緩存

在合適的場景下,緩存能夠極大地提升服務的性能、擴展性和可用性。一般情況下,數據量越大,訪問這份數據的用戶量越大,緩存的效果越好。在應對大流量的併發請求時,訪問緩存比訪問數據源,能夠顯著降低服務延遲,並支持更高的 QPS。

常規的數據庫(比如 MySQL)可能只支持一定數量的併發連接。然而,如果從一個共享緩存讀取數據,而不是從底層的數據,即便數據庫的併發連接已經被消耗完,client 仍然後溝正常獲取數據。在數據更新頻率不敏感的業務場景下,即便數據庫服務掛了,client 也能繼續使用緩存裏的數據。

我們推薦把高頻讀且低頻更新的數據緩存下來,不推薦緩存敏感數據(比如權限驗證信息)。

使用時,對於業務上絕對不能丟的數據,務必持久化到數據庫。即便緩存服務掛了,服務仍然能夠直接操作數據庫,而不至於丟失一部分數據。

如何有效地緩存數據

爲了保證緩存的有效性,關鍵點在於確定 1)緩存什麼數據、2)什麼時候做緩存。

我們可以在第一次讀取數據時把數據添加到緩存,這樣服務只需要從數據庫讀取一次,後續的讀請求均走緩存即可。

我們也可以提前把部分 / 全部數據加載到緩存,比較常見的是在服務啓動階段。不過,在大型系統中,我們不推薦這種方式,因爲這可能導致數據庫的訪問量突增,導致服務不穩定。

所以選擇哪一種呢?這時候我們需要對流量進行一些分析,協助我們判斷是否對緩存進行預熱,緩存什麼數據。比如,一些用戶每天都會使用應用程序,我們就可以把這些用戶的靜態數據緩存下來;但對於一週訪問一次系統的用戶,緩存就沒必要了。

緩存尤其適用於不可變數據或變化頻次很低的數據。常見的有電商場景下的商品信息、商品價格等,生成比較耗時的共享靜態資源。在服務啓動階段,我們可以預加載一部分數據到緩存中,用來滿足頻繁的資源請求,提升系統性能。爲了保證數據更新,可以啓動一個後臺進行,定期從數據庫拉取最新的數據,更新緩存。還有一種比較複雜的方案,通過消息隊列監聽數據的變化,更新緩存。

對於動態變化的數據,緩存的效果相對比較有限,在一些特殊場景下除外(後面會詳細說)。原始數據定期發生變化時,要麼緩存中的數據很快就過期了,要麼數據同步的代價降低緩存的效用。

我們不一定要緩存實體的所有信息,有些場景下只需要緩存特定的不可變字段,有時候只需要緩存過濾條件以方便獲取 ID。舉個例子,一條數據代表一個有很多字段的對象,比如一個銀行客戶(字段有名字、地址、賬戶餘額等),ta 的名字地址通常是靜態的,而賬戶餘額則經常發生變化。這種情況下,可以只緩存這些靜態字段,其餘字段在需要時從數據庫或其他服務獲取即可。

預熱緩存還是按需加載,還是全都要,我們更推薦讓數據說話,使用的手段有性能測試、使用情況分析等。最終決定應考慮到數據的變化情況和使用情況。如果服務需要承載大量的請求,或是高度分佈式的,緩存使用率和性能分析也十分有必要。比如在高併發的場景下,緩存預熱可以降低高峯期數據庫的壓力。

緩存也可以用來避免重複的計算。如果一個服務調用需要處理大量數據,或者進行復雜的計算,我們可以將結果緩存起來。如果後續出現同樣的計算,服務直接讀緩存即可。

服務可以修改緩存裏的數據,但存在一些副作用。我們不推薦把緩存作爲一個持久存儲使用,而是預設緩存裏的數據隨時可能丟失。千萬不要把有價值的數據只放在緩存裏,在數據庫裏務必也存儲一份。一旦緩存失效或緩存服務掛了,我們也不會丟失數據。

緩存頻繁變更的數據

如果你頻繁修改數據庫裏的數據,數據庫的壓力會比較大。舉個例子,如果一個設備頻繁地報告自己的狀態和數據指標,如果應用層考慮到緩存會經常過期,選擇不進行緩存;直接從數據庫讀寫也會存在同樣的問題,相當於把壓力轉移到了數據庫上。

這種情況下,可以考慮把動態數據直接存儲在緩存裏,而不是放到數據庫。考慮到這是非核心數據,也不需要進行審計,有一些變更沒有被記錄到也可以接收。

設計過期時間

大多數場景下,緩存裏的數據是從數據庫拷貝過來的,幾乎一模一樣。但數據緩存以後,數據庫裏的數據可能發生變化,導致緩存裏的數據過期。很多緩存服務可以配置過期時間,以避免數據過期的時間太久。

數據過期後,緩存會把數據清理掉,下一次請求來的時候,服務必須從數據庫重新獲取數據(然後添加到緩存裏)。我們可以給緩存服務設置一個統一的過期時間,也可以針對每一個 key 設置獨立的過期時間。

有時候,緩存會被佔滿。這種情況下,把新數據寫入緩存會導致已有的數據被刪掉,這個過程叫緩存逐出。最常見的緩存逐出策略是 LRU,當然也可以把逐出策略設置成不逐出,會導致新數據寫入失敗。最常見的 Redis 就提供了:

  1. noeviction: 不逐出、

  2. lru:最少使用算法,最長時間沒有使用的數據

  3. random: 隨機逐出數據

  4. lfu: 一段時間內使用頻次最低的數據

  5. ttl: 到了過期時間的數據,與是否訪問無關

緩存的併發讀寫問題

通常情況下,一個服務的多個實例共享一個緩存,每個實例都會讀 / 寫緩存裏大的數據,產生了併發讀寫問題。考慮一個場景,應用程序需要更新緩存裏的一條數據,但我們必須保證一個實例寫入的數據不能被另一個實例的寫覆蓋掉。

考慮到可能的數據競爭,有兩種更新策略:

緩存的最終一致性

在生產環境中,爲了保證核心業務的穩定性,我們通常會使用讀寫分離的數據庫方案。最常見的莫過於 MySQL 主從結構,比如一主二從的結構。上層應用寫入 / 變更數據時,通常會訪問主節點,讀取數據時訪問從節點。主從的數據同步藉助於 binlog 機制,靠的是最終一致性。

一般情況下,這沒什麼問題。但涉及到短期大量的數據寫入時,binlog 同步會出現明顯的延遲。設想一下,在應用程序和 MySQL 之間如果還有一層緩存,應用程序的一個實例 1)更新數據庫; 2)將緩存置爲過期;此時應用程序的另一個實例讀取這條數據,觸發一次 cache miss,所以它 MySQL 從節點讀取最新數據;但此時 binlog 同步還沒有完成,所以讀到了舊數據,記錄在緩存中。我們做一個大膽的假設:

  1. 這條數據數據此後很長一段時間沒有被更新過;

  2. 緩存沒有設置過期時間,或過期時間很長;

那麼後面很長一段時間,應用程序讀到的都是舊數據,與實際不匹配。有什麼解法呢?

最簡單粗暴的解法是:讀寫都走主節點,這相當於把從節點給幹廢了,來一次單點故障,整個服務就掛了;

比較折中的解法有:

  1. 給緩存設置一個不長不短的過期時間,保證數據庫壓力不大,緩存也有效果;

  2. 只緩存不變化的字段,變化的字段從數據庫取;

如果把最終一致性貫徹到底,可以做一個消費 binlog 寫緩存的常駐任務,不過不建議自己寫,最好複用公司的大數據體系(binlog2kafka,Flink SQL)。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GQWbEKbUOVNXjMrrjHUrBg