使用快取的 9 大誤區

InfoQ - 汪洋發表於2015-12-25

如果說要對一個站點或者應用程式經常優化,可以說快取的使用是最快也是效果最明顯的方式。一般而言,我們會把一些常用的,或者需要花費大量的資源或時間而產生的資料快取起來,使得後續的使用更加快速。

如果真要細說快取的好處,還真是不少,但是在實際的應用中,很多時候使用快取的時候,總是那麼的不盡人意。換句話說,假設本來採用快取,可以使得效能提升為100(這裡的數字只是一個計量符號而已,只是為了給大家一個“量”的體會),但是很多時候,提升的效果只有80,70,或者更少,甚至還會導致效能嚴重的下降,這個現象在使用分散式快取的時候尤為突出。

在本篇文章中,我們將為大家講述導致以上問題的9大症結,並且給出相對應的解決方案。文章以.NET為例子進行程式碼的演示,對於來及其他技術平臺的朋友也是有參考價值的,只要替換相對應的程式碼就行了!

為了使得後文的闡述更加的方便,也使得文章更為的完整,我們首先來看看快取的兩種形式:本地記憶體快取,分散式快取。

首先對於本地記憶體快取,就是把資料快取在本機的記憶體中,如下圖1所示:

從上圖中可以很清楚的看出:

  • 應用程式把資料快取在本機的記憶體,需要的時候直接去本機記憶體進行獲取。
  • 對於.NET的應用而言,在獲取快取中的資料的時候,是通過物件的引用去記憶體中查詢資料物件的,也就說,如果我們通過引用獲取了資料物件之後,我們直接修改這個物件,其實我們真正的是在修改處於記憶體中的那個快取物件。

對於分散式的快取,此時因為快取的資料是放在快取伺服器中的,或者說,此時應用程式需要跨程式的去訪問分散式快取伺服器,如圖2:

不管快取伺服器在哪裡,因為涉及到了跨程式,甚至是跨域訪問快取資料,那麼快取資料在傳送到快取伺服器之前就要先被序列化,當要用快取資料的時候,應用程式伺服器接收到了序列化的資料之後,會將之反序列化。序列化與反序列化的過程是非常消耗CPU的操作,很多問題就出現在這上面。

另外,如果我們把獲取到的資料,在應用程式中進行了修改,此時快取伺服器中的原先的資料是沒有修改的,除非我們再次將資料儲存到快取伺服器。請注意:這一點和之前的本地記憶體快取是不一樣的。

對於快取中的每一份資料,為了後文的講述方面,我們稱之為“快取項“。

普及完了這兩個概念之後,我們就進入今天的主題:使用快取常見的9大誤區:

  1. 太過於依賴.NET預設的序列化機制
  2. 快取大物件
  3. 使用快取機制線上程間進行資料的共享
  4. 認為呼叫快取API之後,資料會被立刻快取起來
  5. 快取大量的資料集合,而讀取其中一部分
  6. 快取大量具有圖結構的物件導致記憶體浪費
  7. 快取應用程式的配置資訊
  8. 使用很多不同的鍵指向相同的快取項
  9. 沒有及時的更新或者刪除再快取中已經過期或者失效的資料

下面,我們就每一點來具體的看看!

太過於依賴.NET預設的序列化機制

當我們在應用中使用跨程式的快取機制,例如分散式快取memcached或者微軟的AppFabric,此時資料被快取在應用程式之外的程式中。每次,當我們要把一些資料快取起來的時候,快取的API就會把資料首先序列化為位元組的形式,然後把這些位元組傳送給快取伺服器去儲存。同理,當我們在應用中要再次使用快取的資料的時候,快取伺服器就會將快取的位元組傳送給應用程式,而快取的客戶端類庫接受到這些位元組之後就要進行反序列化的操作了,將之轉換為我們需要的資料物件。

另外還有三點需要注意的就是:

  • 這個序列化與反序列化的機制都是發生在應用程式伺服器上的,而快取伺服器只是負責儲存而已。
  • .NET中的預設使用的序列化機制不是最優的,因為它要使用反射機制,而反射機制是是非常耗CPU的,特別是當我們快取了比較複雜的資料物件的時候。

基於這個問題,我們要自己選擇一個比較好的序列化方法來儘可能的減少對CPU的使用。常用的方法就是讓物件自己來實現ISerializable介面。

首先我們來看看預設的序列化機制是怎麼樣的。如圖3:

然後,我們自己來實現ISerializable介面,如下圖4所示:

我們自己實現的方式與.NET預設的序列化機制的最大區別在於:沒有使用反射。自己實現的這種方式速度可以是預設機制的上百倍。

可能有人認為沒有什麼,不就是一個小小的序列化而已,有必要小題大做麼?

在開發一個高效能應用(例如網站)而言,從架構,到程式碼的編寫,以及後面的部署,每一個地方都需要優化。一個小問題,例如這個序列化的問題,初看起來不是問題,如果我們站點應用的訪問量是百萬,千萬,甚至更高階別的,而這些訪問需要去獲取一些公共的快取的資料,這個之前所謂的小問題就不小了!

下面,我們來看第二個誤區。

快取大物件

有時候,我們想要把一些大物件快取起來,因為產生一次大物件的代價很大,我們需要產生一次,儘可能的多次使用,從而提升響應。

提到大物件,這裡就很有必要對其進行一個比較深入的介紹了。在.NET中,所謂的大物件,就是指的其佔用的記憶體大於了85K的物件,下面通過一個比較將問題說清楚。

如果現在有一個Person類的集合,定義為List<Person>,每個Person物件佔用1K的記憶體,如果這個Person集合中包含了100個Person物件例項,那麼這個集合是否是大物件呢?

回答是:不是!

因為集合中只是包含的Person物件例項的引用而言,即,在.NET的託管堆上面,這個Person集合分配的記憶體大小也就是100個引用的大小而言。

然後,對於下面的這個物件,就是大物件了: byte[] data = new byte[87040](85 * 1024 = 87040)。

說到了這裡,那就就談談,為什麼說:產生一次大物件的代價很大。

因為在.NET中,大物件是分配在大物件託管堆上面的(我們簡稱為“大堆”,當然,還有一個對應的小堆),而這個大堆上面的物件的分配機制和小堆不一樣:大堆在分配的時候,總是去需找合適的記憶體空間,結果就是導致出現記憶體碎片,導致記憶體不足!我們用一個圖來描述一下,如圖5所示:

上圖非常明瞭,在圖5中:

  • 垃圾回收機制不會在回收物件之後壓縮大堆(小堆是壓縮的)。
  • 分配物件的時候,需要去遍歷大堆,去需找合適的空間,遍歷是要花成本的。
  • 如果某些空間小於85K,那麼就不能分配了,只能白白浪費,也導致記憶體碎片。

講完了這些之後,我們言歸正傳,來看看大物件的快取。

正如之前講過,將物件快取和讀取的時候是要進行序列化與反序列化的,快取的物件越大(例如,有1M等),整個過程中就消耗更多的CPU。

對於這樣的大物件,要看它使用的是否很頻繁,是否是公用的資料物件,還是每個使用者都要產生的。因為我們一旦快取了(特別在分散式快取中),就需要同時消耗快取伺服器的記憶體與應用程式伺服器的CPU。如果使用的不頻繁,建議每次生成!如果是公用的資料,那麼建議多多的測試:將生產大物件的成本與快取它的時候消耗的記憶體和CPU的成本進行比較,選擇成本小的!如果是每個使用者都要產生的,看看是否可以分解,如果實在不能分解,那麼快取,但是及時的釋放!

使用快取機制線上程間進行資料的共享

當資料放在快取中的時候,我們程式的多個執行緒都可以訪問這個公共的區域。多個執行緒在訪問快取資料的時候,會產生一些競爭,這也是多執行緒中常常發生的問題。

下面我們分別從本地記憶體快取與分散式快取兩個方面介紹競爭的帶來的問題。

看下面的一段程式碼:

對於本地記憶體快取,對於上面的程式碼,當這個三個執行緒執行起來之後,線上程1中,item的值很多時候可能為1,執行緒2可能是2,執行緒3可能是3。當然,這不一定!只是大多數情況下的可能值!

如果是對於分散式快取,就不好說了!因為資料的修改不是立刻發生在本機的記憶體中的,而是經過了一個跨程式的過程。

有一些快取模組已經實現了加鎖的方式來解決這個問題,例如AppFabric。大家在修改快取資料的時候要特別注意這一點。

認為呼叫快取API之後,資料會被立刻快取起來

有時候,當我們呼叫了快取的API之後,我們就會認為:資料已經被換成了,之後就可以直接讀取快取中的資料。儘管情況很多時候如此,但是不是絕對的!很多的問題就是這樣產生的!

我們通過一個例子來講解。

例如,對於一個ASP.NET 應用而言,如果我們在一個按鈕的Click事件中呼叫了快取API,然後在頁面呈現的時候,就去讀取快取,程式碼如下:

上面的程式碼照道理來說是對的,但是會發生問題。按鈕點選之後回傳頁面,然後呈現頁面的時候顯示資料,流程沒有問題。但是沒有考慮到這樣一個問題:如果伺服器的記憶體緊張,而導致進行伺服器記憶體的回收,那麼很有可能快取的資料就沒有了!

這裡有朋友就要說了:記憶體回收這麼快?

這主要看我們的一些設定和處理。

一般而言,快取機制都是會設定絕對過期時間與相對過期時間,二者的區別,大家應很清楚,我這裡不多說。對於上面的程式碼而言,如果我們設定的是絕對過期時間,假設1分鐘,如果頁面處理的非常慢,時間超過了1分鐘,那麼等到呈現的時候,可能快取中的資料已經沒有了!

有時候,即使我們在第一行程式碼中快取了資料,那麼也許在第三行程式碼中,我們去快取讀取資料的時候,就已經沒有了。這或許是因為在伺服器記憶體壓力很大的,快取機制將最少訪問的資料直接清掉。或者伺服器CPU很忙,網路也不好,導致資料沒有被即使的序列化儲存到快取伺服器中。

另外,對於ASP.NET而言,如果使用了本地記憶體快取,那麼,還涉及到IIS的配置問題(對快取記憶體的限制),我們有機會專門為大家分享這方面的知識。

所以,每次在使用快取資料的時候,要判斷是否存在,不然,會有很多的“找不到物件”的錯誤,產生一些我們認為的“奇怪而又合理的現象”。

快取大量的資料集合,而讀取其中一部分

在很多時候,我們往往會快取一個物件的集合,但是,我們在讀取的時候,只是每次讀取其中一部分。 我們舉個例子來說明這個問題(例子可能不是很恰當,但是足以說明問題)。

在購物站點中,常見的操作就是查詢一些產品的資訊,這個時候,如果使用者輸入了“25寸電視機”,然後查詢相關的產品。這個時候,在後臺,我們可以查詢資料庫,找到幾百條這樣的資料,然後,我們將這幾百條資料作為一個快取項快取起來,程式碼的程式碼如下:

同時,我們對找出的產品進行分頁的顯示,每次展示10條。其實在每次分頁的時候,我們都是根據快取的鍵去獲取資料,然後選擇下一個10條資料,然後顯示。

如果是使用本地記憶體快取,那麼這可能不是什麼問題,如果是採用分散式快取,問題就來了。下圖可以清楚的說明這個過程,如圖所示:

相信大家看完這個圖,然後結合之前的講述應該很清楚了問題所在了:每次都按照快取鍵獲取全部資料,然後在應用伺服器那裡反序列化全部資料,但是隻是取其中10條。

這裡可以將資料集合再次拆分,分為例如25-0-10-products,25-11-20-products等的快取項,如下圖所示:

當然,查詢和快取的方式有很多,拆分的方式也有很多,這裡這是給出一些常見的問題!

快取大量具有圖結構的物件導致記憶體浪費

為了更好的說明這個問題,我們首先看到下面的一個類結構圖,如圖:

如果我們要把一些Customer資料快取起來,這裡就可以可能出現兩個問題:

  1. 由於使用.NET的預設序列化機制,或者沒有適當的加入相應Attribute(屬性),使得快取了一些原本不需要快取的資料。
  2. 將Customer快取的時候,同時,為了更快的獲取Customer的Order資訊,將Order資訊快取在了另外一個快取項中,導致同一份資料被快取兩次。

下面,我們就分別來看看這兩個問題。

首先看到第一個。如果我們使用分散式快取來快取一些Customer的資訊的時候,如果我們沒有自己重新Customer的序列化機制,而是採用的預設的,那麼序列化機制在序列化Customer的時候,會將Customer所引用的物件也序列化,然後在序列化被序列化物件中的其他引用物件,最後的結果就是:Customer被序列化,Customer的Order資訊被序列化,Order引用的OrderItem被序列化,最後OrderItem引用的Product也會序列化。

整個物件圖全部被序列化了,如果這種情況是我們想要的,那麼沒有問題;如果不是的,那麼,我們就浪費了很多的資源了,解決的方法有兩個:第一,自己實現序列化,自己完全控制哪些物件需要序列化,我們前面已經講過了;第二,如果使用預設的序列化機制,那麼在不要需要序列化的物件上面加上[NonSerialized]標記。

下面,我們看到第二個問題。這個問題主要是由於第一個問題引起的:原本在快取Customer的時候,已經將Customer的其他資訊,例如Order,Product已經快取了。但是很多的技術人員不清楚這一點,然後又把Customer的Order資訊去快取在其他的快取項,使用的使用就根據Customer的標識,例如ID去快取中獲取Order資訊,如下程式碼所示:

解決這個問題的方法也比較明顯,參看第一個問題的解決方案就可以了!

快取應用程式的配置資訊

因為快取是有一套資料失效檢測週期的(之前說過,要麼是固定時間失效,要麼是相對時間失效),所以,很多的技術人員喜歡把一些動態變化的資訊儲存在快取中,以充分利用快取機制的這種特性,其中,快取程式的配置資訊就是其中一個例子。

因為在應用的中的一些配置,可能會發生變化,最簡單的就是資料庫連線字串了,如下程式碼:

當這樣設定之後,每隔一段時間快取失效之後,就去重新讀取配置檔案,這時候,可能此時的配置就和之前不一樣了,並且其他的地方都可以讀取快取從而進行更新,特別是在多臺伺服器上面部署同一個站點的時候,有時候,我們沒有及時的去修改每個伺服器上面的站點的配置檔案裡面的資訊,這個時候如何使用分散式快取快取配置資訊,只要更新一個站點的配置檔案,其他站點就全部修改了,技術人員皆大歡喜。OK,這確實看起來是個不錯的方法(在必要的時候可以採用一下),但是,不是所有的配置資訊都要保持一樣的,而且還要考慮怎樣一個情況:如果快取伺服器出了問題,當機了,那麼我們所有使用這個配置資訊的站點可能都會出問題。

建議對於這些配置檔案的資訊,採用監控的機制,例如檔案監控,每次檔案發生變化,就重新載入配置資訊。

使用很多不同的鍵指向相同的快取項

我們有時候會遇到這樣的一個情況:我們把一個物件快取起來,用一個鍵作為快取鍵來獲取這個資料,之後,我們又通過一個索引作為快取鍵來獲取這個資料,如下程式碼所示:

我們之所以這樣寫,主要因為我們會以多種方式來從快取中讀取資料,例如在進行迴圈遍歷的時候,需要通過索引來獲取資料,例如index++等,而有些情況,我們可能需要通過其他的方式,例如,產品名來獲取產品的資訊。

如果遇到這樣的情況,那麼就建議將這些多個鍵組合起來,形成如下的形式:

另外一個常見的問題就是:相同的資料被快取在不同的快取項中,例如,如果使用者查詢尺寸為36寸的彩電,那麼可能有可能一個編號為100的電視產品就在結果中,此時,我們將結果快取。另外,使用者在查詢一個生產廠家為TCL的電視,如果編號為100的電視產品又出現在結果中,我們把結果又快取在另外一個快取項中。這個時候,很顯然,出現了記憶體的浪費。

對於這樣的情況,之前筆者採用的方法就是,在快取中建立了一個索引列表,如圖所示:

當然,這其中有很多的細節和問題需要解決,這裡就不一一述說,要看各自的應用和情況而定! 也非常歡迎大家提供更好的方法。

沒有及時的更新或者刪除再快取中已經過期或者失效的資料

這種情況應該是使用快取最常見的問題,例如,如果我們現在獲取了一個Customer的所有沒有處理的訂單的資訊,然後快取起來,類似的程式碼如下:

之後,使用者的一個訂單被處理了,但是快取還沒有更新,那麼這個時候,快取中的資料就已經有問題!當然,我這裡只是列舉的最簡單的場景,大家可以聯想自己應用中的其他產品,很有可能會出現快取中的資料和實際資料庫中的不一樣。

現在很多的時候,我們已經容忍了這種短時間的不一致的情況。其實對於這種情況,沒有非常完美的解決方案,如果要做,倒是可以實現,例如每次修改或者刪除一個資料,就去遍歷快取中的所有資料,然後進行操作,但是這樣往往得不償失。另外一個折中的方法就是,判斷資料的變化週期,然後儘可能的將快取的時間變短一點。

相關文章