天天看點

服務緩存設計指南(二): 正确使用緩存

作者:趙帥虎

上一篇文章我們介紹了服務緩存的基本概念和常見緩存政策,今天我們來看看如何正确地使用緩存吧。

什麼時候用緩存

在合适的場景下,緩存能夠極大地提升服務的性能、擴充性和可用性。一般情況下,資料量越大,通路這份資料的使用者量越大,緩存的效果越好。在應對大流量的并發請求時,通路緩存比通路資料源,能夠顯著降低服務延遲,并支援更高的QPS。

正常的資料庫(比如MySQL)可能隻支援一定數量的并發連接配接。然而,如果從一個共享緩存讀取資料,而不是從底層的資料,即便資料庫的并發數已經被消耗完,client仍然能夠正常擷取資料。在資料更新頻率不敏感的業務場景下,即便資料庫服務挂了,client也能繼續使用緩存裡的資料。

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

使用時,對于業務上絕對不能丢的資料,務必持久化到資料庫。即便緩存服務挂了,服務仍然能夠直接操作資料庫,而不至于丢失一部分資料。

如何有效地緩存資料

為了保證緩存的有效性,關鍵點在于确定1)緩存什麼資料、2)什麼時候做緩存。

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

我們也可以提前把部分/全部資料加載到緩存,比較常見的是在服務啟動階段。不過,在大型系統中,我們不推薦這種方式,因為這可能導緻資料庫的通路量突增,導緻服務不穩定。

是以選擇哪一種呢?這時候我們需要對流量進行一些分析,協助我們判斷是否對緩存進行預熱,緩存什麼資料。比如,一些使用者每天都會使用應用程式,我們就可以把這些使用者的靜态資料緩存下來;但對于一周通路一次系統的使用者,緩存就沒必要了。

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

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

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

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

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

服務可以修改緩存裡的資料,但存在一些副作用。我們不推薦把緩存作為一個持久存儲使用,而是預設緩存裡的資料随時可能丢失。千萬不要把有價值的資料隻放在緩存裡,在資料庫裡務必也存儲一份。一旦緩存失效或緩存服務挂了,我們也不會丢失資料。

什麼情況下緩存頻繁變更的資料

如果你頻繁修改資料庫裡的資料,資料庫的壓力會比較大。舉個例子,如果一個裝置頻繁地報告自己的狀态和資料名額,如果應用層考慮到緩存會經常過期,選擇不進行緩存;直接從資料庫讀寫也會存在同樣的問題,相當于把壓力轉移到了資料庫上。

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

如何設計過期時間

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

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

有時候,緩存會被占滿。這種情況下,把新資料寫入緩存會導緻已有的資料被删掉,這個過程叫緩存逐出。最常見的緩存逐出政策是LRU,當然也可以把逐出政策設定成不逐出,會導緻新資料寫入失敗。最常見的Redis就提供了:

  1. noeviction: 不逐出、
  2. lru:最少使用算法,最長時間沒有使用的資料
  3. random: 随機逐出資料
  4. lfu: 一段時間内使用頻次最低的資料
  5. ttl: 到了過期時間的資料,與是否通路無關

讓client側緩存裡的資料失效

client側緩存裡的資料通常是脫離應用服務的管轄範圍,服務不能直接強制client側添加或删除緩存裡的資料條目。

如果client側配置了一個很糙的緩存政策,緩存裡的資料很可能是過期的。比如,如果緩存的過期政策沒配置好,即便server側資料早已經更新,client側可能一直使用本地的過期資料。

緩存的并發讀寫問題

通常情況下,一個服務的多個執行個體共享一個緩存,每個執行個體都會讀/寫緩存裡大的資料,産生了并發讀寫問題。考慮一個場景,應用程式需要更新緩存裡的一條資料,但我們必須保證一個執行個體寫入的資料不能被另一個執行個體的寫覆寫掉。

考慮到可能的資料競争,有兩種更新政策:

  • 樂觀鎖:再更新資料的前一個瞬間,檢查緩存裡的資料在上次讀之後是否發生過變化。如果沒有變化,這次更新就是有效的。相反,應用程式需要根據業務邏輯判斷是否執行此次更新。這個方法适用于資料變更不頻繁或沖突不經常發生的場景;
  • 悲觀鎖:擷取一個條資料時,給資料加鎖,避免其他執行個體做變更。加鎖確定了沖突不會發生,但會阻塞其他執行個體處理這條資料。悲觀鎖會影響技術方案的擴充性,比較适合耗時很短的操作。這種方法适用于沖突經常發生的場景,尤其是大量的資料被更新,必須保證變更的一緻性;

緩存的最終一緻性

在生産環境中,為了保證核心業務的穩定性,我們通常會使用讀寫分離的資料庫方案。最常見的莫過于MySQL主從結構,比如一主二從的結構。上層應用寫入/變更資料時,通常會通路主節點,讀取資料時通路從節點。主從的資料同步借助于binlog機制,靠的是最終一緻性。

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

  1. 這條資料資料此後很長一段時間沒有被更新過;
  2. 緩存沒有設定過期時間,或過期時間很長;

那麼後面很長一段時間,應用程式讀到的都是舊資料,與實際不比對。有什麼解法呢?

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

比較折中的解法有:

  1. 給緩存設定一個不長不短的過期時間,保證資料庫壓力不大,緩存也有效果;
  2. 隻緩存不變化的字段,變化的字段從資料庫取;

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

繼續閱讀