Cache應用中的服務過載案例研究

發表於2016-06-18

簡單地說,過載是外部請求對系統的訪問量突然激增,造成請求堆積,服務不可用,最終導致系統崩潰。本文主要分析引入Cache可能造成的服務過載,並討論相關的預防、恢復策略。Cache在現代系統中使用廣泛,由此引入的服務過載隱患無處不在,但卻非常隱蔽,容易被忽視。本文希望能為開發者在設計和編寫相關型別應用,以及服務過載發生處理時能夠有章可循。

一個服務過載案例

本文討論的案例是指存在正常呼叫關係的兩個系統(假設呼叫方為A系統,服務方為B系統),A系統對B系統的訪問突然超出B系統的承受能力,造成B系統崩潰。造成服務過載的原因很多,這裡分析的是嚴重依賴Cache的系統服務過載。首先來看一種包含Cache的體系結構(如下圖所示)。

Cache應用中的服務過載案例研究

A系統依賴B系統的讀服務,A系統是60臺機器組成的叢集,B系統是6臺機器組成的叢集,之所以6臺機器能夠扛住60臺機器的訪問,是因為A系統並不是每次都訪問B,而是首先請求Cache,只有Cache的相應資料失效時才會請求B。

這正是Cache存在的意義,它讓B系統節省了大量機器;如果沒有Cache,B系統不得不組成60臺機器的叢集,如果A也同時依賴除B系統外的另一個系統(假設為C系統)呢?那麼C系統也要60臺機器,放大的流量將很快耗盡公司的資源。

然而Cache的引入也不是十全十美的,這個結構中如果Cache發生問題,全部的流量將流向依賴方,造成流量激增,從而引發依賴系統的過載。

回到A和B的架構,造成服務過載的原因至少有下面三種:

  1. B系統的前置代理髮生故障或者其他原因造成B系統暫時不可用,等B系統系統服務恢復時,其流量將遠遠超過正常值。
  2. Cache系統故障,A系統的流量將全部流到B系統,造成B系統過載。
  3. Cache故障恢復,但這時Cache為空,Cache瞬間命中率為0,相當於Cache被擊穿,造成B系統過載。

第一個原因不太好理解,為什麼B系統恢復後流量會猛增呢?主要原因就是快取的超時時間。當有資料超時的時候,A系統會訪問B系統,但是這時候B系統偏偏故障不可用,那麼這個資料只好超時,等發現B系統恢復時,發現快取裡的B系統資料已經都超時了,都成了舊資料,這時當然所有的請求就打到了B。

下文主要介紹服務過載的預防和發生後的一些補救方法,以預防為主,從呼叫方和服務方的視角闡述一些可行方案。

服務過載的預防

所謂Client端指的就是上文結構中的A系統,相對於B系統,A系統就是B系統的Client,B系統相當於Server。

Client端的方案

針對上文闡述的造成服務過載的三個原因:B系統故障恢復、Cache故障、Cache故障恢復,我們看看A系統有哪些方案可以應對。

合理使用Cache應對B系統當機

一般情況下,Cache的每個Key除了對應Value,還對應一個過期時間T,在T內,get操作直接在Cache中拿到Key對應Value並返回。但是在T到達時,get操作主要有五種模式:

1. 基於超時的簡單(stupid)模式

在T到達後,任何執行緒get操作發現Cache中的Key和對應Value將被清除或標記為不可用,get操作將發起呼叫遠端服務獲取Key對應的Value,並更新寫回Cache,然後get操作返回新值;如果遠端獲取Key-Value失敗,則get丟擲異常。

為了便於理解,舉一個碼頭工人取貨的例子:5個工人(執行緒)去港口取同樣Key的貨(get),發現貨已經過期被扔掉了,這時5個工人各自分別去對岸取新貨,然後返回。

2. 基於超時的常規模式

在T到達後,Cache中的Key和對應Value將被清除或標記為不可用,get操作將呼叫遠端服務獲取Key對應的Value,並更新寫回Cache;此時,如果另一個執行緒發現Key和Value已經不可用,get操作還需要判斷有沒有其他執行緒發起了遠端呼叫,如果有,那麼自己就等待,直到那個執行緒遠端獲取操作成功,Cache中得Key變得可用,get操作返回新的Value。如果遠端獲取操作失敗,則get操作丟擲異常,不會返回任何Value。

還是碼頭工人的例子:5個工人(執行緒)去港口取同樣Key的貨(get),發現貨已經過期被扔掉了,那麼只需派出一個人去對岸取貨,其他四個人在港口等待即可,而不用5個人全去。

基於超時的簡單模式和常規模式區別在於對於同一個超時的Key,前者每個get執行緒一旦發現Key不存在,則發起遠端呼叫獲取值;而後者每個get執行緒發現Key不存在,則還要判斷當前是否有其他執行緒已經發起了遠端呼叫操作獲取新值,如果有,自己就簡單的等待即可。

顯然基於超時的常規模式比基於超時的簡單模式更加優化,減少了超時時併發訪問後端的呼叫量。

實現基於超時的常規模式就需要用到經典的Double-checked locking慣用法了。

3. 基於重新整理的簡單(stupid)模式

在T到達後,Cache中的Key和相應Value不動,但是如果有執行緒呼叫get操作,將觸發refresh操作,根據get和refresh的同步關係,又分為兩種模式:

  • 同步模式:任何執行緒發現Key過期,都觸發一次refresh操作,get操作等待refresh操作結束,refresh結束後,get操作返回當前Cache中Key對應的Value。注意refresh操作結束並不意味著refresh成功,還可能拋了異常,沒有更新Cache,但是get操作不管,get操作返回的值可能是舊值。
  • 非同步模式:任何執行緒發現Key過期,都觸發一次refresh操作,get操作觸發refresh操作,不等refresh完成,直接返回Cache中的舊值。

舉上面碼頭工人的例子說明基於重新整理的常規模式:這次還是5工人去港口取貨,這時貨都在,但是已經舊了,這時5個工人有兩種選擇:

  • 5個人各自去遠端取新貨,如果取貨失敗,則拿著舊貨返回(同步模式)
  • 5個人各自通知5個僱傭工去取新貨,5個工人拿著舊貨先回(非同步模式)

4. 基於重新整理的常規模式

在T到達後,Cache中的Key和相應Value都不會被清除,而是被標記為舊資料,如果有執行緒呼叫get操作,將觸發refresh更新操作,根據get和refresh的同步關係,又分為兩種模式:

  • 同步模式:get操作等待refresh操作結束,refresh結束後,get操作返回當前Cache中Key對應的Value,注意:refresh操作結束並不意味著refresh成功,還可能拋了異常,沒有更新Cache,但是get操作不管,get操作返回的值可能是舊值。如果其他執行緒進行get操作,Key已經過期,並且發現有執行緒觸發了refresh操作,則自己不等refresh完成直接返回舊值。
  • 非同步模式:get操作觸發refresh操作,不等refresh完成,直接返回Cache中的舊值。如果其他執行緒進行get操作,發現Key已經過期,並且發現有執行緒觸發了refresh操作,則自己不等refresh完成直接返回舊值。

再舉上面碼頭工人的例子說明基於重新整理的常規模式:這次還是5工人去港口取貨,這時貨都在,但是已經舊了,這時5個工人有兩種選擇:

  • 派一個人去遠方港口取新貨,其餘4個人拿著舊貨先回(同步模式)。
  • 5個人通知一個僱傭工去遠方取新貨,5個人都拿著舊貨先回(非同步模式)。

基於重新整理的簡單模式和基於重新整理的常規模式區別就在於取數執行緒之間能否感知當前資料是否正處在重新整理狀態,因為基於重新整理的簡單模式中取數執行緒無法感知當前過期資料是否正處在重新整理狀態,所以每個取數執行緒都會觸發一個重新整理操作,造成一定的執行緒資源浪費。

而基於超時的常規模式和基於重新整理的常規模式區別在於前者過期資料將不能對外訪問,所以一旦資料過期,各執行緒要麼拿到資料,要麼丟擲異常;後者過期資料可以對外訪問,所以一旦資料過期,各執行緒要麼拿到新資料,要麼拿到舊資料。

5. 基於重新整理的續費模式

該模式和基於重新整理的常規模式唯一的區別在於refresh操作超時或失敗的處理上。在基於重新整理的常規模式中,refresh操作超時或失敗時丟擲異常,Cache中的相應Key-Value還是舊值,這樣下一個get操作到來時又會觸發一次refresh操作。

在基於重新整理的續費模式中,如果refresh操作失敗,那麼refresh將把舊值當成新值返回,這樣就相當於舊值又被續費了T時間,後續T時間內get操作將取到這個續費的舊值而不會觸發refresh操作。

基於重新整理的續費模式也像常規模式那樣分為同步模式和非同步模式,不再贅述。

下面討論這5種Cache get模式在服務過載發生時的表現,首先假設如下:

  • 假設A系統的訪問量為每分鐘M次。
  • 假設Cache能存Key為C個,並且Key空間有N個。
  • 假設正常狀態下,B系統訪問量為每分鐘W次,顯然W\<\<M。

這時因為某種原因,比如B長時間故障,造成Cache中得Key全部過期,B系統這時從故障中恢復,五種get模式分析表現分析如下:

  1. 在基於超時和重新整理的簡單模式中,B系統的瞬間流量將達到和A的瞬時流量M大體等同,相當於Cache被擊穿。這就發生了服務過載,這時剛剛恢復的B系統將肯定會被大流量壓垮。
  2. 在基於超時和重新整理的常規模式中,B系統的瞬間流量將和Cache中Key空間N大體等同。這時是否發生服務過載,就要看Key空間N是否超過B系統的流量上限了。
  3. 在基於重新整理的續費模式中,B系統的瞬間流量為W,和正常情況相同而不會發生服務過載。實際上,在基於重新整理的續費模式中,不存在Cache Key全部過期的情況,就算把B系統永久性地幹掉,A系統的Cache也會基於舊值長久的平穩執行。

第3點,B系統不會發生服務過載的主要原因是基於重新整理的續費模式下不會出現chache中的Key全部長時間過期的情況,即使B系統長時間不可用,基於重新整理的續費模式也會在一個過期週期內把舊值當成新值繼續使用。所以當B系統恢復時,A系統的Cache都處在正常工作狀態。

從B系統的角度看,能夠抵抗服務過載的基於重新整理的續費模式最優。

從A系統的角度看,由於一般情況下A系統是一個高訪問量的線上web應用,這種應用最討厭的一個詞就是“執行緒等待”,因此基於重新整理的各種非同步模式較優。

綜合考慮,基於重新整理的非同步續費模式是首選

然而凡是有利就有弊,有兩點需要注意的地方:

  1. 基於重新整理模式最大的缺點是Key-Value一旦放入Cache就不會被清除,每次更新也是新值覆蓋舊值,JVM GC永遠無法對其進行垃圾收集,而基於超時的模式中,Key-Value超時後如果新的訪問沒有到來,記憶體是可以被GC垃圾回收的。所以如果你使用的是寸土寸金的本地記憶體做Cache就要小心了。
  2. 基於重新整理的續費模式需要做好監控,不然有可能Cache中的值已經和真實的值相差很遠了,應用還以為是新值而使用。

關於具體的Cache,來自Google的Guava本地快取庫支援上文的第二種、第四種和第五種get操作模式。

但是對於Redis等分散式快取,只提供原始的get、set方法,而提供的get僅僅是獲取,與上文提到的五種get操作模式不是一個概念。開發者想用這五種get操作模式的話不得不自己封裝和實現。

五種get操作模式中,基於超時和重新整理的簡單模式是實現起來最簡單的模式,但遺憾的是這兩種模式對服務過載完全無免疫力,這可能也是服務過載在大量依賴快取的系統中頻繁發生的一個重要原因吧。

本文之所以把第1、3種模式稱為stupid模式,是想強調這種模式應該儘量避免,Guava裡面根本沒有這種模式,而Redis只提供簡單的讀寫操作,很容易就把系統實現成了這種方式。

應對分散式Cache當機

如果是Cache直接掛了,那麼就算是基於重新整理的非同步續費模式也無能為力了。這時A系統鐵定無法對Cache進行存取操作,只能將流量完全打到B系統,B系統面對服務過載在劫難逃……

本節討論的預防Cache當機僅限於分散式Cache,因為本地Cache一般和A系統應用共享記憶體和程式,本地Cache掛了A系統也掛了,不會出現本地Cache掛了而A系統應用正常的情況。

首先,A系統請求執行緒檢查分散式Cache狀態,如果無應答則說明分散式Cache掛了,則轉向請求B系統,這樣一來大流量將壓垮B系統。這時可選的方案如下:

  1. A系統的當前執行緒不請求B系統,而是打個日誌並設定一個預設值。
  2. A系統的當前執行緒按照一定概率決定是否請求B系統。
  3. A系統的當前執行緒檢查B系統執行情況,如果良好則請求B系統。

方案1最簡單,A系統知道如果沒有Cache,B系統可能扛不住自己的全部流量,索性不請求B系統,等待Cache恢復。但這時B系統利用率為0,顯然不是最優方案,而且當請求的Value不容易設定預設值時,這個方案就不行了。

方案2可以讓一部分執行緒請求B系統,這部分請求肯定能被B系統hold住。可以保守的設定這個概率 u =(B系統的平均流量)/(A系統的峰值流量)

方案3是一種更為智慧的方案,如果B系統執行良好,當前執行緒請求;如果B系統過載,則不請求,這樣A系統將讓B系統處於一種當機與不當機的臨界狀態,最大限度挖掘B系統效能。這種方案要求B系統提供一個效能評估介面返回Yes和No,Yes表示B系統良好,可以請求;No表示B系統情況不妙,不要請求。這個介面將被頻繁呼叫,必須高效。

方案3的關鍵在於如何評估一個系統的執行狀況。一個系統中當前主機的效能引數有CPU負載、記憶體使用率、Swap使用率、GC頻率和GC時間、各個介面平均響應時間等,效能評估介面需要根據這些引數返回Yes或者No,是不是機器學習裡的二分類問題??關於這個問題已經可以單獨寫篇文章討論了,在這裡就不展開了,你可以想一個比較簡單傻瓜的保守策略,缺點是A系統的請求無法很好的逼近B系統的效能極限。

綜合以上分析,方案2比較靠譜。如果選擇方案3,建議由專門團隊負責研究並提供統一的系統效能實時評估方案和工具。

應對分散式Cache當機後的恢復

不要以為成功hold住分散式Cache當機就萬事大吉了,真正的考驗是分散式Cache從當機過程恢復之後,這時分散式Cache中什麼都沒有。

即使是上文中提到了基於重新整理的非同步續費策略這時也沒用,因為分散式Cache為空,無論如何都要請求B系統。這時B系統的最大流量是Key的空間取值數量。

如果Key的取值空間數量很少,則相安無事;如果Key的取值空間數量大於B系統的流量上限,服務過載依然在所難免。

這種情況A系統很難處理,關鍵原因是A系統請求Cache返回Key對應Value為空,A系統無法知道是因為當前Cache是剛剛初始化,所有內容都為空;還是因為僅僅是自己請求的那個Key沒在Cache裡

如果是前者,那麼當前執行緒就要像處理Cache當機那樣進行某種策略的迴避;如果是後者,直接請求B系統即可,因為這是正常的Cache使用流程。

對於Cache當機的恢復,A系統真的無能為力,只能寄希望於B系統的方案了。

Server端的方案

相對於Client端需要應對各種複雜問題,Server端需要應對的問題非常簡單,就是如何從容應對過載的問題。無論是快取擊穿也好,還是拒絕服務攻擊也罷,對於Server端來說都是過載保護的問題。對於過載保護,主要給出兩種可行方案,以及一種比較複雜的方案思路。

流量控制

流量控制就是B系統實時監控當前流量,如果超過預設的值或者系統承受能力,則直接拒絕掉一部分請求,以實現對系統的保護。

流量控制根據基於的資料不同,可分為兩種:

  1. 基於流量閾值的流控:流量閾值是每個主機的流量上限,流量超過該閾值主機將進入不穩定狀態。閾值提前進行設定,如果主機當前流量超過閾值,則拒絕掉一部分流量,使得實際被處理流量始終低於閾值。
  2. 基於主機狀態的流控:每個接受每個請求之前先判斷當前主機狀態,如果主機狀況不佳,則拒絕當前請求。

基於閾值的流控實現簡單,但是最大的問題是需要提前設定閾值,而且隨著業務邏輯越來越複雜,介面越來越多,主機的服務能力實際應該是下降的,這樣就需要不斷下調閾值,增加了維護成本,而且萬一忘記調整的話,呵呵……

主機的閾值可以通過壓力測試確定,選擇的時候可以保守些。

基於主機狀態的流控免去了人為控制,但是其最大的確定上文已經提到:如何根據當前主機各個引數判斷主機狀態呢?想要完美的回答這個問題目測並不容易,因此在沒有太好答案之前,我推薦基於閾值的流控。

流量控制基於實現位置的不同,又可以分為兩種:

  1. 反向代理實現流控:在反向代理如Nginx上基於各種策略進行流量控制。這種一般針對HTTP服務。
  2. 藉助服務治理系統:如果Server端是RMI、RPC等服務,可以構建專門的服務治理系統進行負載均衡、流控等服務。
  3. 服務容器實現流控:在應用程式碼裡,業務邏輯之前實現流量控制。

第3種在伺服器的容器(如Java容器)中實現流控並不推薦,因為流控和業務程式碼混在一起容易混亂;其次實際上流量已經全量進入到了業務程式碼裡,這時的流控只是阻止其進入真正的業務邏輯,所以流控效果將打折;還有,如果流量策略經常變動,系統將不得不為此經常更改。

因此,推薦前兩種方式。

最後提一個注意點:當因為流控而拒絕請求時,務必在返回的資料中帶上相關資訊(比如“當前請求因為超出流量而被禁止訪問”),如果返回值什麼都沒有將是一個大坑。因為造成呼叫方請求沒有被響應的原因很多,可能是呼叫方Bug,也可能是服務方Bug,還可能是網路不穩定,這樣一來很可能在排查一整天后發現是流控搞的鬼……

服務降級

服務降級一般由人為觸發,屬於服務過載造成崩潰恢復時的策略,但為了和流控對比,將其放到這裡。

流量控制本質上是減小訪問量,而服務處理能力不變;而服務降級本質上是降低了部分服務的處理能力,增強另一部分服務處理能力,而訪問量不變。

服務降級是指在服務過載時關閉不重要的介面(直接拒絕處理請求),而保留重要的介面。比如服務由10個介面,服務降級時關閉了其中五個,保留五個,這時這個主機的服務處理能力將增強到二倍左右。

然而,服務過載發生時動輒就超出系統處理能力10倍,而服務降級能使主機服務處理能力提高10倍麼?顯然很困難,因此服務過載的應對不能只依靠服務降級策略。

動態擴充套件

動態擴充套件指的是在流量超過系統服務能力時,自動觸發叢集擴容,自動部署並上線執行;當流量過去後又自動回收多餘機器,完全彈性。

這個方案是不是感覺很不錯。但是目前網際網路公司的線上應用跑在雲上的本身就不多,要完全實現線上應用的自動化彈性運維,要走的路就更多了。

崩潰恢復

如果服務過載造成系統崩潰還是不幸發生了,這時需要運維控制流量,等後臺系統啟動完畢後循序漸進的放開流量,主要目的是讓Cache慢慢預熱。流量控制剛開始可以為10%,然後20%,然後50%,然後80%,最後全量,當然具體的比例,尤其是初始比例,還要看後端承受能力和前端流量的比例,各個系統並不相同。

如果後端系統有專門的工具進行Cache預熱,則省去了運維的工作,等Cache熱起來再發布後臺系統即可。但是如果Cache中的Key空間很大,開發預熱工具將比較困難。

結論

“防患於未然”放在服務過載的應對上也是適合的,預防為主,補救為輔。綜合上文分析,具體的預防要點如下:

  1. 呼叫方(A系統)採用基於重新整理的非同步續費模式使用Cache,或者至少不能使用基於超時或重新整理的簡單(stupid)模式。
  2. 呼叫方(A系統)每次請求Cache時檢查Cache是否可用(available),如果不可用則按照一個保守的概率訪問後端,而不是無所顧忌的直接訪問後端。
  3. 服務方(B系統)在反向代理處設定流量控制進行過載保護,閾值需要通過壓測獲得。

崩潰的補救主要還是靠運維和研發在發生時的通力合作:觀察流量變化準確定位崩潰原因,運維控流量研發持續關注效能變化。

未來如果有條件的話可以研究下主機應用健康判斷問題和動態彈性運維問題,畢竟自動化比人為操作要靠譜。

相關文章