1 、背景
Redis的出現確實大大地提高系統大併發能力支撐的可能性,轉眼間Redis的最新版本已經是3.X版本了,但我們的系統依然繼續跑著2.8,並很好地支撐著我們當前每天5億訪問量的應用系統。想當年Redis的單點單執行緒特性無法滿足我們日益壯大的系統,只能硬著頭皮把Redis“叢集化”負載。且這套“叢集化”方案良好地執行至今。雖難度不高,勝在簡單和實用。無論簡單還是很簡單,記錄這種經歷是一件非常有趣的事情。
2 、問題
系統訪問量日益倍增,當前的Redis單點服務確實客觀存在連續可用性以及支撐瓶頸風險,這種主/備模式在服務故障突發的情況下就會被動停止服務進行Redis節點切換。針對單點問題,我們結合自身的業務應用場景對Redis“叢集化”提出幾個主要目標:
1、避免單點情況,確保服務高可用;
2、緊可能把資料分散式儲存,降低故障影響範圍,滿足服務靈活伸縮;
3、控制“叢集化”的複雜度,從而控制邊際成本;
3 、過程
以上目標1和2就是所謂的分散式叢集方案,把大問題分而治之。但最難把控的是目標3的“簡化”實現。基於當時開源社群的那幾種Redis叢集方案,對於我們“簡化”的要求來說相對略顯臃腫。所以還是決定結合自身的業務應用等因素打造一個“合適”的Redis叢集。
初始,我們憑藉自己對分散式叢集的認識勾結合應用場景勾勒出一個我們覺得足夠“簡化”的設計圖,然後在這個“簡化”架構的基礎上繼續擊破我們各種應用場景所帶來的缺陷。架構圖如下:
不難看出,我們想盡量通過一個RedisManager類和配置檔案就能管理整個叢集,不需要而外的軟體支援。單例使用的時候RedisManager和配置檔案就已經存在,RedisManager有各種單例操作的API重寫(如get、set等),現在我們還是想保持這種模式對業務處理提供叢集API,保持整個服務化應用框架(類似於今天所倡導的“微服務”)的輕量級特性。如上圖所示,資料根據hash實現分成不同塊放在不同的hash節點上,而每個hash節點必須存在兩個Redis例項做hash節點叢集支撐。為什麼會是兩個而不是三個或可擴充套件多個?我們是這樣考慮的:
1、任何可持續擴充套件或抽象是站在規範這個巨人的肩膀上,我們秉承了整個系統架構“約定遠遠大於配置”的原則,適當地限制了邊界範圍換取控制性而又不失靈活。
2、對於我們系統目前的伺服器質量來說,當機的概率較小,雙機(雙例項)同時當機的概率更小。就算這個概率出現,我們眾多的業務場景還是允許這種部分間接性故障。這就是成本與質量之間的平衡和取捨。
3、由於我們沒有使用額外的軟體輔助,這些額外的操作都依賴了執行緒額外效能去彌補,例如兩個Redis例項負載之間的同步等,所以我們是用效能換取部分一致性。負載節點越多效能消耗越多,所以兩個例項做負載是我們“適當約束”和衡量的決策。
在此我向大家推薦一個架構學習交流群。交流學習群號:575745314 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多
4 、場景
4.1 場景 1
在RedisManager類中有兩個最基本的API,那就是c_get和c_set,其中c代表cluster。這兩個API的基本實現如下:
從這兩個基本操作可以看出,我們利用了遍歷hash節點的所有負載例項來實現高可用性,並通過“同步寫”來滿足Redis資料“弱一致性”問題。而這個“同步寫”就是額外的效能消耗,是依賴於雙寫過程中只成功寫入一個例項的概率。因為Redis的穩定性,這種概率不高,所以額外效能消耗的概率也不高。以上操作幾乎適用所有快取類叢集支援。這類快取資料的強一致性更多放在資料庫。資料在庫表變更後,只需要把快取資料delete即可。具體場景如下:
1、資料的變更通過“後臺”維護,變更後同步DELETE快取的相互資料。
2、業務執行緒請求快取資料為空(c_get),則查詢書庫並把資料同步至快取(c_set)。
3、Redis的hash節點叢集安裝叢集內部實現負責和資料同步問題(c_get和c_set的實現原來)。
我們大部分業務資料快取都是基於以上流程實現,這個流程會存在一個髒資料問題,例如當UPDATE庫表成功但DELETE快取資料不成功,就會存在髒資料。為了能儘可能降低髒資料的可能性,我們會在快取設定快取資料一個有效期(setex),就算髒資料出現,也只會影響seconds時間段。另外在後臺變更過程如果DELETE快取失敗我們會有適當的提示語提示,好讓認為發現繼續進一步處理(例如重新變更)。
4.1 場景 2
除了場景1的快取用途外,還存在持久化場景。就是基於Redis做資料持久化叢集,即所有操作都是基於Redis叢集的。那麼在資料一致性問題上就需要下點功夫了(c_set_sync),虛擬碼如下:
c_set_sync(key,value){
if(c_del_sync(key)){
c_set(key,value);
}
}
c_del_sync(key){
if(del(r1,key)&&del(r2,key)){
return true;
}
return false;
}
複製程式碼
通過以上虛擬碼可以看出,c_set_sync方法是先強制全部刪除資料後再c_set,確保資料一致性。但這會出現一個資料丟失問題,就是c_del_sync後但set失敗,那資料就會丟失,因為我們的資料幾乎都是從後臺操作的,如果出現這種資料丟失,簡單的我們可以重新配置,複雜的我們可以通過日誌恢復。
5 伸縮
以上兩個場景更多圍繞C(一致性)和A(可用性)的特性進行討論,那麼接下來再介紹一下我們“叢集化”的P(分割槽容錯性)特性。其實從我思考觸發就可以看得出我們對P的權重是輕於C和A的。為什麼這麼說?因為我們系統架構是服務化架構(那時我還沒接觸到“微服務”概念),也就是從問題角度把大問題(業務統稱)拆分各種小問題(服務化)逐一獨立解決。各種小問題的業務複雜度我們緊可能控制到一定的輕量級程度(如果要量化解釋的話那就是服務保持在10到20個API的規模,甚至小於10)。而且每個服務的承載量增長率預估值也在可控範圍,所以到目前為止,極少有對Redis叢集進行伸縮的需求。但少數的伸縮還是存在,但頻率不高。對於一個完整的叢集化方案,伸縮功能必須得有,只不過可能需要像以上兩種場景那樣針對不同業務使用場景在“規範化(原架構基礎上)”下定製出各種場景API。
5.1 場景 1
因為快取場景相對簡單,擴充套件或收縮hash節點後,如果在快取中找不到資料,則會訪問資料庫重新Load資料到新的hash節點。伸縮期完成初期可能會對資料庫帶來一定的壓力,這種壓力的大小來源於設計hash資料的變化大小,這種資料變化大小取決於重新這個hash實現規則的變化的大小。所以,可根據具體情況來重寫hash規則。
還有一個就是資料一致性問題(C和P),如何在動態伸縮過程中,確保快取資料一致性。為了解決這個問題,我們在動態擴充套件過程中,停止各種更新介面操作。因為我們的資料變更都是通過管理員的,所以這個代價可以忽略不計。
5.2 場景 2
此場景2對應是卻卻是4.2場景,如果用Redis叢集做持久化工具,如果確保分割槽容錯性(P)和資料一致性(C)。對於資料一致性問題,我們同樣選擇了場景1的辦法,在伸縮期間停止所有更新操作,只保留讀。這就避免了資料一致性問題。對於分割槽容錯性問題,那就是如果確保重新hash後,資料能流向各種的新hash節點呢。為了繼續保持這種“簡化性”框架,我們繼續選擇了犧牲一定的效能來滿足分割槽容錯性問題,具體實現如下所示:
1、先嚐試從New_Hash節點讀取;
2、若不存在則繼續尋找Old_Hash節點;
3、若還是不存在,怎放回空;
4、若存在則c_set到New_Hash節點。
通過以上流程分析看到,我們犧牲了部分執行緒效能(第一次訪問的變更資料的執行緒)的效能,可能會多2到3此的redis請求(每個Redis請求約5至10毫秒)。當伸縮完成後,重新放開資料更新API(我們服務化框架所有業務API都可以通過控制檯控制併發並設定相關提示語,無需重啟應用)。除了讀取需要遍歷新舊hash節點外,為了確保資料一致性問題,我們c_del_sync內建了一個判斷是否存在舊hash節點。虛擬碼如下:
c_del_sync(key){
if(hash_old){
if(del(nr1,key)&&del(nr2,key) &&del(or1,key) &&del(or2,key)) {
true;
}
}else{
if(del(r1,key)&&del(r2,key)){
true;
}
}
return false;
}
複製程式碼
這種場景比較適用於set操作不多的場景,因為多set操作會多消耗約一倍的效能,如果覺得資源充足,這當然可以考慮。
在此我向大家推薦一個架構學習交流群。交流學習群號:575745314 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多
6 、總結
以上叢集化方案已經執行約兩年,系統日訪問量約億,70%歸功於Redis的支撐。以上叢集方案是基於我們的行業業務場景和自身框架量身定做的,我更多地是想分享解決這個問題的思路和過程。世界上沒有“絕對通用”,只有“相對通用”,通用範圍越廣,臃腫程度越高,可能帶來的成本就會越大。我們更多的是面對如果“很好地”解決問題,這個“很好地”隱藏著各種各樣的考慮因素。抉擇就是一個為了能達到最佳效果而去衡量、選擇、放棄的過程。