本文結合自身後臺開發經驗,從高可用、高性能、易維護和低風險(安全)角度出發,嘗試總結業界常見微服務介面設計原則,幫助大家設計出優秀的微服務。 ...
本文結合自身後臺開發經驗,從高可用、高性能、易維護和低風險(安全)角度出發,嘗試總結業界常見微服務介面設計原則,幫助大家設計出優秀的微服務。
1.前言
微服務是一種系統架構風格,是 SOA(面向服務架構)的一種實踐。微服務架構通過業務拆分實現服務組件化,通過組件組合快速開發系統,業務單一的服務組件又可以獨立部署,使得整個系統變得清晰靈活:
- 原子服務
- 獨立進程
- 隔離部署
- 去中心化服務治理
一個大型複雜的軟體應用,都可以拆分成多個微服務。各個微服務可被獨立部署,各個微服務之間是松耦合的。現如今後臺服務大部分以微服務的形式存在,每個微服務負責實現應用的一個功能模塊。而微服務由一個個介面組成,每個介面實現某個功能模塊下的子功能。
以一個 IM 應用為例,它的功能架構可能是下麵這樣的:
所以如果是後臺開發的同學,經常需要實現一個後臺微服務來提供相應的能力,完成業務功能。
服務以介面形式提供服務。在實現服務時,我們要將一個大的功能拆分成一個個獨立的子功能來實現,每一個子功能就是我們要在服務中實現的一個介面。
有時一個服務會有很多介面,每個介面所要實現的功能可能會有關聯,那麼這就非常考驗設計服務介面的功底,讓服務變得簡單可靠。
業界已經有很多比較成熟的實踐原則,可以幫助我們設計實現出一個可靠易維護的服務。
微服務設計原則並沒有嚴格的規範,下麵結合業界成熟的方法和個人多年後臺開發經驗,介紹高可用,高性能,易維護,低風險服務常用的設計原則。
2.高可用
2.1 降級兜底
大部分服務是如下的結構,既要給使用方使用,又依賴於他人提供的第三方服務,中間又穿插了各種業務邏輯,這裡每一塊都可能是故障的來源。
如果第三方服務掛掉怎麼辦?我們業務也跟著掛掉?顯然這不是我們希望看到的結果,如果能制定好降級兜底的方案,那將大大提高服務的可靠性。
比如我們做個性化推薦服務時,需要從用戶中心獲取用戶的個性化數據,以便代入到模型里進行打分排序,但如果用戶中心服務掛掉,我們獲取不到數據了,那麼就不推薦了?顯然不行,我們可以在本地 cache 里放置一份熱門商品以便兜底。
又比如做一個數據同步的服務,這個服務需要從第三方獲取最新的數據並更新到 MySQL 中,恰好第三方提供了兩種方式:
- 一種是消息通知服務,只發送變更後的數據;
- 一種是 HTTP 服務,需要我們自己主動調用獲取數據。
我們一開始選擇消息同步的方式,因為實時性更高,但是之後就遭遇到消息遲遲發送不過來的問題,而且也沒什麼異常,等我們發現一天時間已過去,問題已然升級為故障。合理的方式應該兩個同步方案都使用,消息方式用於實時更新,HTTP 主動同步方式定時觸發(比如 1 小時)用於兜底,即使消息出了問題,通過主動同步也能保證一小時一更新。
2.2 過載保護(保護自己)
如果是高併發場景使用的介面,那麼需要做過載保護,防止服務過載引發雪崩。
相信很多做過高併發服務的同學都碰到類似事件:某天 A 君突然發現自己的介面請求量突然漲到之前的 10 倍,沒多久該介面幾乎不可使用,並引發連鎖反應導致整個系統崩潰。
如何應對這種情況?生活給了我們答案:比如老式電閘都安裝了保險絲,一旦有人使用超大功率的設備,保險絲就會燒斷以保護各個電器不被強電流給燒壞。同理我們的介面也需要安裝上“保險絲”,以防止非預期的請求對系統壓力過大而引起的系統癱瘓,當流量過大時,可以採取拒絕或者引流等機制。
過載保護的做法:
- 請求等待時間超時
比如把接收到的請求放在指定的隊列中排隊處理,如果請求等待時間超時了(假設是 100ms),這個時候直接拒絕超時請求;再比如隊列滿了之後,就清除隊列中一定數量的排隊請求,保護服務不過載,保障服務高可用。
- 服務過載及早拒絕
根據服務當前指標(如 CPU、記憶體使用率、平均耗時等)判斷服務是否處於過載,過載則及早拒絕請求並帶上特殊錯誤碼,告知上游下游已經過載,應做限流處理。
2.3 流量控制(保護下游)
流量控制,或者叫限流,一般用戶保護下游不被大流量壓垮。
常見的場景有:
(1)下游有嚴格的請求限制;比如銀行轉賬介面,微信支付介面等都有嚴格的介面限頻;
(2)調用的下游不是為高併發場景設計;比如提供非同步計算結果拉取的服務,並不需要考慮各種複雜的高併發業務場景,提供高併發流量場景的支持。每個業務場景應該在拉取數據時緩存下來,而不是每次業務請求都過來拉取,將業務流量壓垮下游。
(3)失敗重試。調用下游失敗了,一定要重試嗎?如果不管三七二十一直接重試,這樣是不對的,比如有些業務返回的異常表示業務邏輯出錯,那麼你怎麼重試結果都是異常;又如有些異常是介面處理超時異常,這個時候就需要結合業務來判斷了,有些時候重試往往會給後方服務造成更大壓力,造成雪上加霜的效果。所有失敗重試要有收斂策略,必要時才重試,做好限流處理。
控制流量,常用的限流演算法有漏桶演算法和令牌桶演算法。必要的情況下,需要實現分散式限流。
2.4 快速失敗
遵循快速失敗原則,一定要設置超時時間。
某服務調用的一個第三方介面正常響應時間是 50ms,某天該第三方介面出現問題,大約有 15%的請求響應時間超過 2s,沒過多久服務 load 飆高到 10 倍以上,響應時間也非常緩慢,即第三方服務將我們服務拖垮了。
為什麼會被拖垮?沒設置超時!我們採用的是同步調用方式,使用了一個線程池,該線程池裡最大線程數設置了 50,如果所有線程都在忙,多餘的請求就放置在隊列里中。如果第三方介面響應時間都是 50ms 左右,那麼線程都能很快處理完自己手中的活,並接著處理下一個請求,但是不幸的是如果有一定比例的第三方介面響應時間為 2s,那麼最後這 50 個線程都將被拖住,隊列將會堆積大量的請求,從而導致整體服務能力極大下降。
正確的做法是和第三方商量確定個較短的超時時間比如 200ms,這樣即使他們服務出現問題也不會對我們服務產生很大影響。
2.5 無狀態服務
儘可能地使微服務無狀態。
無狀態服務,可以橫向擴展,從而不會成為性能瓶頸。
狀態即數據。如果某一調用方的請求一定要落到某一後臺節點,使用服務在本地緩存的數據(狀態),那麼這個服務就是有狀態的服務。
我們以前在本地記憶體中建立的數據緩存、Session 緩存,到現在的微服務架構中就應該把這些數據遷移到分散式緩存中存儲,讓業務服務變成一個無狀態的計算節點。遷移後,就可以做到按需動態伸縮,微服務應用在運行時動態增刪節點,就不再需要考慮緩存數據如何同步的問題。
2.6 最少依賴
能不依賴的,儘可能不依賴,越少越好。
減少依賴,便可以減少故障發生的可能性,提高服務可靠性。
任何依賴都有可能發生故障,即使其如何保證,我們在設計上應儘可能地減少對第三方的依賴。如果無法避免,則需要對第三方依賴在發生故障時做好相應處理,避免因第三方依賴的抖動或不可用導致我們自身服務不可用,比如降級兜底。
2.7 簡單可靠
可靠性只有靠不斷追求最大程度的簡化而得到。
乏味是一種美德。與生活中的其他東西不同,對於軟體而言,“乏味”實際上是非常正面的態度。我們不想要自發性的和有趣的程式;我們希望這些程式按設計執行,可以預見性地完成目標。與偵探小說不同,缺少刺激、懸念和困惑是源代碼的理想特征。
因為工程師也是人,他們經常對於自己編寫的代碼形成一種情感依附,這些衝突在大規模清理源代碼的時候並不少見。一些人可能會提出抗議,“如果我們以後需要這個代碼怎麼辦?”,“我們為什麼不只是把這些代碼註釋掉,這樣稍後再使用它的時候會更容易。”,“為什麼不增加一個功能開關?”,這些都是糟糕的建議。源代碼控制系統中的更改反轉很容易,數百行的註釋代碼則會造成干擾和混亂;那些由於功能開關沒有啟用而沒有被執行的代碼,就像一個定時炸彈等待爆炸。極端地說,當你指望一個 Web 服務 7*24 可以用時,某種程度上,每一行新代碼都是負擔。
法國詩人 Antoine de Saint-Exupéry 曾寫道:“不是在不能添加更多的時候,而是沒有什麼可以去掉的時候,才能達到完美”。這個原則同樣適用於軟體設計。API 設計是這個規則應該被遵循的一個清晰的例子。書寫一個明確的、簡單的 API 是介面可靠的保證。我們向 API 消費者提供的方法和參數越少,這些 API 就越容易理解。在軟體工程上,少就是多!一個很小的,很簡單的 API 通常也是一個對問題深刻理解的標誌。
軟體的簡單性是可靠性的前提條件。當我們考慮如何簡化一個給定的任務的每一步時,我們並不是在偷懶。相反,我們是在明確實際上要完成的任務是什麼,以及如何容易地做到。我們對新功能說“不”的時候,不是在限制創新,而是在保持環境整潔,以免分心。這樣我們可以持續關註創新,並且可以進行真正的工程工作。
2.8 分散原則
雞蛋不要放一個籃子,分散風險。
比如一個模塊的所有介面不應該放到同一個服務中,如果服務不可用,那麼該模塊的所有介面都不可用了。我們可以基於主次進行服務拆分,將重要介面放到一個服務中,次要介面放到另外一個服務中,避免相互影響。
再如所有交易數據都放在同一個庫同一張表裡面,萬一這個庫掛了,此時影響所有交易。我們可以對資料庫水平切分,分庫分表。
2.9 隔離原則
控制風險不擴散,不放大。
不同模塊之間要相互隔離,避免單個模塊有問題影響其他模塊,傳播擴散了影響範圍。
比如部署隔離:每個模塊的服務部署在不同物理機上;
再如 DB 隔離:每個模塊單獨使用自身的存儲實例。
古代赤壁之戰就是一個典型的反面例子,鐵鎖連船導致隔離性被破壞,一把大火燒了 80W 大軍。
隔離是有級別的,隔離級別越高,風險傳播擴散的難度就越大,容災能力越強。
例如:一個應用集群由 N 台伺服器組成,部署在同一臺物理機上,或同一個機房的不同物理機上,或同一個城市的不同機房裡,或不同城市裡,不同的部署代表不同的容災能力。
例如:人類由無數人組成,生活在同一個地球的不同洲上,這意味著人類不具備星球級別的隔離能力,當地球出現毀滅性影響時,人類是不具備容災的。
2.10 冪等設計(可重入)
所謂冪等,簡單地說,就是對介面的多次調用所產生的結果和調用一次是一致的。數據發生改變才需要做冪等,有些介面是天然保證冪等性的。
比如查詢介面,有些對數據的修改是一個常量,並且無其他記錄和操作,那也可以說是具有冪等性的。其他情況下,所有涉及對數據的修改、狀態的變更就都有必要防止重覆性操作的發生。實現介面的冪等性可防止重覆操作所帶來的影響。
重覆請求很容易發生,比如用戶誤觸,超時重試等。舉個最簡單的例子,那就是支付,用戶購買商品後支付,支付扣款成功,但是返回結果時網路異常(超時成功),此時錢已經扣了,用戶再次點擊按鈕,此時會進行第二次扣款,返回結果成功,用戶查詢餘額返發現多扣錢了,流水記錄也變成了兩條,就沒有保證介面的冪等性。
2.11 故障自愈
沒有 100% 可靠的系統,故障不可避免,但要有自愈能力。
人體擁有強大的自愈能力,比如手指劃破流血,會自動止血,結痂,再到皮膚再生。微服務應該像人體一樣,當面對非毀滅性傷害(故障)時,在不藉助外力的情況下,自行修複故障。比如消息處理或非同步邏輯等非關鍵操作失敗引發的數據不一致,需要有最終一致的修複操作,如兜底的定時任務,失敗重試隊列,或由用戶在下次請求時觸發修複邏輯。
2.12 CAP 定理
2000 年,加州大學伯克利分校的電腦科學家 Eric Brewer 在分散式計算原理研討會(PODC)上提出了一個猜想,分散式系統有三個指標:
一致性(Consistency)
可用性(Availability)
分區容錯性(Partition tolerance)
它們的第一個字母分別是 C、A、P。
Eric Brewer 說,這三個指標最多只能同時實現兩點,不可能三者兼顧,這便是著名的布魯爾猜想。
在隨後的 2002 年,麻省理工學院(MIT)的 Seth Gilbert 和 Nancy Lynch 發表了布魯爾猜想的證明,使之成為一個定理,即 CAP 定理。
CAP 定理告訴我們,如果服務是分散式服務,那麼不同節點間通信必然存在失敗可能性,即我們必須接受分區容錯性(P),那麼我們必須在一致性(C)和可用性(A)之間做出取捨,即要麼 CP,要麼 AP。
如果你的服務偏業務邏輯,對接用戶,那麼可用性顯得更加重要,應該選擇 AP,遵守 BASE 理論,這是大部分業務服務的選擇。
如果你的服務偏系統控制,對接服務,那麼一致性顯得更加重要,應該選擇 CP,遵守 ACID 理論,經典的比如 Zookeeper。
總體來說 BASE 理論面向的是大型高可用、可擴展的分散式系統。與傳統 ACID 特性相反,不同於 ACID 的強一致性模型,BASE 提出通過犧牲強一致性來獲得可用性,並允許數據段時間內的不一致,但是最終達到一致狀態。同時,在實際分散式場景中,不同業務對數據的一致性要求不一樣,因此在設計中,ACID 和 BASE 應做好權衡和選擇。
2.13 BASE 理論
在 CAP 定理的背景下,大部分分散式系統都偏向業務邏輯,面向用戶,那麼可用性相對一致性顯得更加重要。如何構建一個高可用的分散式系統,BASE 理論給出了答案。
2008 年,eBay 公司選則把資料庫事務的 ACID 原則放寬,於電腦協會(Association for Computing Machinery,ACM)上發表了一篇文章Base: An Acid Alternative,正式提出了一套 BASE 原則。
BASE 基於 CAP 定理逐步演化而來,其來源於對大型分散式系統實踐的總結,是對 CAP 中一致性和可用性權衡的結果,其核心思想是即使無法做到強一致性,但每個業務根據自身的特點,採用適當的方式來使系統達到最終一致性。BASE 可以看作是 CAP 定理的延伸。
BASE 理論指:
- Basically Available(基本可用)
基本可用就是假設系統出現故障,要保證系統基本可用,而不是完全不能使用。比如採用降級兜底的策略,假設我們在做個性化推薦服務時,需要從用戶中心獲取用戶的個性化數據,以便代入到模型里進行打分排序。但如果用戶中心服務掛掉,我們獲取不到數據了,那麼就不推薦了?顯然不行,我們可以在本地 cache 里放置一份熱門商品以便兜底。
- Soft state( 軟狀態)
軟狀態指的是允許系統中的數據存在中間狀態,並認為該狀態不影響系統的整體可用性,即允許系統在多個不同節點的數據副本存在數據延時。
- Eventual consistency(最終一致性)
上面講到的軟狀態不可能一直是軟狀態,必須有時間期限。在期限過後,應當保證所有副本保持數據一致性,從而達到數據的最終一致性,因此所有客戶端對系統的數據訪問最終都能夠獲取到最新的值,而這個時間期限取決於網路延時,系統負載,數據複製方案等因素。
3.高性能
3.1 無鎖
3.1.1 鎖的問題
高性能系統中使用鎖,往往帶來的壞處要大於好處。
併發編程中,鎖帶解決了安全問題,同時也帶來了性能問題,因為鎖讓併發處理變成了串列操作,所以如無必要,儘量不要顯式使用鎖。
鎖和併發,貌似有一種相剋相生的關係。
為了避免嚴重的鎖競爭導致性能的下降,有些場景採用了無鎖化設計,特別是在底層框架上。無鎖化主要有兩種實現,無鎖隊列和無鎖數據結構。
3.1.2 串列無鎖
串列無鎖最簡單的實現方式可能就是單線程模型了,如 Redis/Nginx 都採用了這種方式。在網路編程模型中,常規的方式是主線程負責處理 I/O 事件,並將讀到的數據壓入隊列,工作線程則從隊列中取出數據進行處理,這種單 Reactor 多線程模型需要對隊列進行加鎖,這種模型叫單 Reactor 多線程模型。如下圖所示:
上圖的模式可以改成串列無鎖的形式,當 MainReactor accept 一個新連接之後從眾多的 SubReactor 選取一個進行註冊,通過創建一個 Queue 與 I/O 線程進行綁定,此後該連接的讀寫都在同一個隊列和線程中執行,無需進行隊列的加鎖。這種模型叫主從 Reactor 多線程模型。
3.1.3 無鎖數據結構
利用硬體支持的原子操作可以實現無鎖的數據結構,很多語言都提供 CAS 原子操作(如 Go 中的 atomic 包和 C++11 中的 atomic 庫),可以用於實現無鎖數據結構,如無鎖鏈表。
我們以一個簡單的線程安全單鏈表的插入操作來看下無鎖編程和普通加鎖的區別。
template<typename T> struct Node { Node(const T &value) : data(value) {} T data; Node *next = nullptr; };
有鎖鏈表 WithLockList:
template<typename T> class WithLockList { mutex mtx; Node<T> *head; public: void pushFront(const T &value) { auto *node = new Node<T>(value); lock_guard<mutex> lock(mtx); // (1) node->next = head; head = node; } };
無鎖鏈表 LockFreeList:
template<typename T> class LockFreeList { atomic<Node<T> *> head; public: void pushFront(const T &value) { auto *node = new Node<T>(value); node->next = head.load(); while(!head.compare_exchange_weak(node->next, node)); // (2) } };
從代碼可以看出,在有鎖版本中 (1) 進行了加鎖。在無鎖版本中,(2) 使用了原子 CAS 操作 compare_exchange_weak,該函數如果存儲成功則返回 true,同時為了防止偽失敗(即原始值等於期望值時也不一定存儲成功,主要發生在缺少單條比較交換指令的硬體機器上),通常將 CAS 放在迴圈中。
下麵對有鎖和無鎖版本進行簡單的性能比較,分別執行 1000,000 次 push 操作。測試代碼如下:
int main() { const int SIZE = 1000000; //有鎖測試 auto start = chrono::steady_clock::now(); WithLockList<int> wlList; for(int i = 0; i < SIZE; ++i) { wlList.pushFront(i); } auto end = chrono::steady_clock::now(); chrono::duration<double, std::micro> micro = end - start; cout << "with lock list costs micro:" << micro.count() << endl; //無鎖測試 start = chrono::steady_clock::now(); LockFreeList<int> lfList; for(int i = 0; i < SIZE; ++i) { lfList.pushFront(i); } end = chrono::steady_clock::now(); micro = end - start; cout << "free lock list costs micro:" << micro.count() << endl; return 0; }
三次輸出如下,可以看出無鎖版本有鎖版本性能高一些。
with lock list costs micro:548118 free lock list costs micro:491570 with lock list costs micro:556037 free lock list costs micro:476045 with lock list costs micro:557451 free lock list costs micro:481470
3.1.4 減少鎖競爭
如果加鎖無法避免,則可以採用分片的形式,減少對資源加鎖的次數,這樣也可以提高整體的性能。
比如 Golang 優秀的本地緩存組件 bigcache 、go-cache、freecache 都實現了分片功能,每個分片一把鎖,採用分片存儲的方式減少加鎖的次數從而提高整體性能。
以一個簡單的示例,通過對map[uint64]struct{}
分片前後併發寫入的對比,來看下減少鎖競爭帶來的性能提升。
var ( num = 1000000 m0 = make(map[int]struct{}, num) mu0 = sync.RWMutex{} m1 = make(map[int]struct{}, num) mu1 = sync.RWMutex{} ) // ConWriteMapNoShard 不分片寫入一個 map。 func ConWriteMapNoShard() { g := errgroup.Group{} for i := 0; i < num; i++ { g.Go(func() error { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil }) } _ = g.Wait() } // ConWriteMapTwoShard 分片寫入兩個 map。 func ConWriteMapTwoShard() { g := errgroup.Group{} for i := 0; i < num; i++ { g.Go(func() error { if i&1 == 0 { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil } mu1.Lock() defer mu1.Unlock() m1[i] = struct{}{} return nil }) } _ = g.Wait() }
看下二者的性能差異:
func BenchmarkConWriteMapNoShard(b *testing.B) { for i := 0; i < b.N; i++ { ConWriteMapNoShard() } } BenchmarkConWriteMapNoShard-12 3 472063245 ns/op func BenchmarkConWriteMapTwoShard(b *testing.B) { for i := 0; i < b.N; i++ { ConWriteMapTwoShard() } } BenchmarkConWriteMapTwoShard-12 4 310588155 ns/op
可以看到,通過對分共用資源的分片處理,減少了鎖競爭,能明顯地提高程式的併發性能。可以預見的是,隨著分片粒度地變小,性能差距會越來越大。當然,分片粒度不是越小越好。因為每一個分片都要配一把鎖,那麼會帶來很多額外的不必要的開銷。可以選擇一個不太大的值,在性能和花銷上尋找一個平衡。
3.2 緩存
3.2.1 為什麼要有緩存?
數據的訪問具有局部性,符合二八定律:80% 的數據訪問是集中在 20% 的數據上,這部分數據也被稱作熱點數據。
不同層級的存儲訪問速率不同,記憶體讀寫速度快於磁碟,磁碟快於遠端存儲。基於記憶體的存儲系統(如 Redis)高於基於磁碟的存儲系統(如 MySQL)。
因為存在熱點數據和存儲訪問速率的不同,我們可以考慮採用緩存。
緩存緩存一般使用記憶體作為本地緩存。
必要情況下,可以考慮多級緩存,如一級緩存採用本地緩存,二級緩存採用基於記憶體的存儲系統(如 Redis、Memcache 等)。
緩存是原始數據的一個複製集,其本質就是空間換時間,主要是為瞭解決高併發讀。
3.2.2 緩存的使用場景
緩存是空間換時間的藝術,使用緩存能提高系統的性能。“勁酒雖好,可不要貪杯”,使用緩存的目的是為了提高性價比,而不是一上來就為了所謂的提高性能不計成本的使用緩存,而是要看場景。
適合使用緩存的場景,以之前參與過的項目企鵝電競為例:(1)一旦生成後基本不會變化的數據:如企鵝電競的游戲列表,在後臺創建一個游戲之後基本很少變化,可直接緩存整個游戲列表;
(2)讀密集型或存在熱點的數據:典型的就是各種 App 的首頁,如企鵝電競首頁直播列表;
(3)計算代價大的數據:如企鵝電競的 Top 熱榜視頻,如 7 天榜在每天凌晨根據各種指標計算好之後緩存排序列表;
(4)千人一面的數據:同樣是企鵝電競的 Top 熱榜視頻,除了緩存的整個排序列表,同時直接在進程內按頁緩存了前 N 頁數據組裝後的最終回包結果;
不適合使用緩存的場景:
(1)寫多讀少,更新頻繁;
(2)對數據一致性要求嚴格。
3.2.3 緩存的分類?
(1)進程級緩存
緩存的數據直接在進程地址空間內,這可能是訪問速度最快使用最簡單的緩存方式了。主要缺點是受制於進程空間大小,能緩存的數據量有限,進程重啟緩存數據會丟失。一般通常用於緩存數據量不大的場景。
(2)集中式緩存
緩存的數據集中在一臺機器上,如共用記憶體。這類緩存容量主要受制於機器記憶體大小,而且進程重啟後數據不丟失。常用的集中式緩存中間件有單機版 redis、memcache 等。
(3)分散式緩存
緩存的數據分佈在多台機器上,通常需要採用特定演算法(如 Hash)進行數據分片,將海量的緩存數據均勻的分佈在每個機器節點上。常用的組件有:Memcache(客戶端分片)、Codis(代理分片)、Redis Cluster(集群分片)。
(4)多級緩存
指在系統中的不同層級進行數據緩存,以提高訪問效率和減少對後端存儲系統的衝擊。
3.2.4 緩存的使用模式
關於緩存的使用,已經有人總結出了一些模式,主要分為 Cache-Aside 和 Cache-As-SoR 兩類。其中 SoR(System-of-Record)表示記錄系統,即數據源,而 Cache 正是 SoR 的拷貝。
- Cache-Aside:旁路緩存
這應該是最常見的緩存模式了。對於讀,首先從緩存讀取數據,如果沒有命中則回源 SoR 讀取並更新緩存。對於寫操作,先寫 SoR,再寫緩存。這種模式架構圖如下:
這種模式用起來簡單,但對應用層不透明,需要業務代碼完成讀寫邏輯。同時對於寫來說,寫數據源和寫緩存不是一個原子操作,可能出現以下情況導致兩者數據不一致。
(1)在併發寫時,可能出現數據不一致。
如下圖所示,user1 和 user2 幾乎同時進行讀寫。在 t1 時刻 user1 寫 db,t2 時刻 user2 寫 db,緊接著在 t3 時刻 user2 寫緩存,t4 時刻 user1 寫緩存。這種情況導致 db 是 user2 的數據,緩存是 user1 的數據,兩者不一致。
(2)先寫數據源成功,但是接著寫緩存失敗,兩者數據不一致。
對於這兩種情況如果業務不能忍受,可簡單的通過先 delete 緩存然後再寫 db 解決,其代價就是下一次讀請求的 cache miss。
- Cache-as-SoR:緩存即數據源
該模式把 Cache 當作 SoR,所以讀寫操作都是針對 Cache,然後 Cache 再將讀寫操作委托給 SoR,即 Cache 是一個代理。如下圖所示:
有三種實現方式:
(1)Read-Through:稱為穿透讀模式,首先查詢 Cache,如果不命中則再由 Cache 回源到 SoR 即存儲端實現 Cache-Aside 而不是業務)。
(2)Write-Through:稱為穿透寫模式,由業務先調用寫操作,然後由 Cache 負責寫緩存和 SoR。
(3)Write-Behind:稱為回寫模式,發生寫操作時業務只更新緩存並立即返回,然後非同步寫 SoR,這樣可以利用合併寫/批量寫提高性能。
3.2.5 緩存淘汰策略
在空間有限、低頻熱點訪問或者無主動更新通知的情況下,需要對緩存數據進行回收,常用的回收策略有以下幾種:
(1)基於時間:基於時間的策略主要可以分兩種。
- TTL(Time To Live):即存活期,從緩存數據創建開始到指定的過期時間段,不管有沒有訪問緩存都會過期。如 Redis 的 EXPIRE。
- TTI(Time To Idle):即空閑期,緩存在指定的時間沒有被訪問將會被回收。
(2)基於空間:緩存設置了存儲空間上限,當達到上限時按照一定的策略移除數據。
(3)基於容量:緩存設置了存儲條目上限,當達到上限時按照一定的策略移除數據。
(4)基於引用:基於引用計數或者強弱引用的一些策略進行回收。
緩存常見淘汰演算法如下:
- FIFO(First In First Out):先進選出原則,先進入緩存的數據先被移除。
- LRU(Least Recently Used):最基於局部性原理,即如果數據最近被使用,那麼它在未來也極有可能被使用,反之,如果數據很久未使用,那麼未來被使用的概率也較。
- LFU:(Least Frequently Used):最近最少被使用的數據最先被淘汰,即統計每個對象的使用次數,當需要淘汰時,選擇被使用次數最少的淘汰。
3.2.6 緩存的崩潰與修複
由於在設計不足、請求攻擊(並不一定是惡意攻擊)等會造成一些緩存問題,下麵列出了常見的緩存問題和解決方案。
- 緩存穿透
大量使用不存在的 Key 進行查詢時,緩存沒有命中,這些請求都穿透到後端的存儲,最終導致後端存儲壓力過大甚至被壓垮。這種情況原因一般是存儲中數據不存在,主要有三個解決辦法。
(1)設置空置或預設值:如果存儲中沒有數據,則設置一個空置或者預設值緩存起來,這樣下次請求時就不會穿透到後端存儲。但這種情況如果遇到惡意攻擊,不斷的偽造不同的 Key 來查詢時並不能很好的應對,這時候需要引入一些安全策略對請求進行過濾。
(2)布隆過濾器:採用布隆過濾器將,將所有可能存在的數據哈希到一個足夠大的 Bitmap 中,一個一定不存在的數據會被這個 Bitmap 攔截掉,從而避免了對底層資料庫的查詢壓力。
(3)singleflight 多個併發請求對一個失效的 Key 進行源數據獲取時,只讓其中一個得到執行,其餘阻塞等待到執行的那個請求完成後,將結果傳遞給阻塞的其他請求達到防止擊穿的效果。
- 緩存雪崩
指大量的緩存在某一段時間內集體失效,導致後端存儲負載瞬間升高甚至被壓垮。通常是以下原因造成:
(1)緩存失效時間集中在某段時間,對於這種情況可以採取對不同的 Key 使用不同的過期時間,在原來基礎失效時間的基礎上再加上不同的隨機時間;
(2)採用取模機制的某緩存實例宕機,這種情況移除故障實例後會導致大量的緩存不命中。有兩種解決方案:(a)採取主從備份,主節點故障時直接將從實例替換主;(b)使用一致性哈希替代取模,這樣即使有實例崩潰也只是少部分緩存不命中。
- 緩存熱點
雖然緩存系統本身性能很高,但也架不住某些熱點數據的高併發訪問從而造成緩存服務本身過載。假設一下微博以用戶 ID 作為哈希 Key,突然有一天亦菲姐姐宣佈婚了,如果她的微博內容按照用戶 ID 緩存在某個節點上,當她的萬千粉絲查看她的微博時必然會壓垮這個緩存節點,因為這個 Key 太熱了。這種情況可以通過生成多份緩存到不同節點上,每份緩存的內容一樣,減輕單個節點訪問的壓力。
3.2.6 緩存的一些好實踐
- 動靜分離
對於一個緩存對象,可能分為很多種屬性,這些屬性中有的是靜態的,有的是動態的。在緩存的時候最好採用動靜分離的方式。以免因經常變動的數據發生更新而要把經常不變的數據也更新至緩存,成本很高。
- 慎用大對象
如果緩存對象過大,每次讀寫開銷非常大並且可能會卡住其他請求,特別是在 redis 這種單線程的架構中。典型的情況是將一堆列表掛在某個 value 的欄位上或者存儲一個沒有邊界的列表,這種情況下需要重新設計數據結構或者分割 value 再由客戶端聚合。
- 過期設置
儘量設置過期時間減少臟數據和存儲占用,但要註意過期時間不能集中在某個時間段。
- 超時設置
緩存作為加速數據訪問的手段,通常需要設置超時時間而且超時時間不能過長(如 100ms 左右),否則會導致整個請求超時連回源訪問的機會都沒有。
- 緩存隔離
首先,不同的業務使用不同的 Key,防止出現衝突或者互相覆蓋。其次,核心和非核心業務進行通過不同的緩存實例進行物理上的隔離。
- 失敗降級
使用緩存需要有一定的降級預案,緩存通常不是關鍵邏輯,特別是對於核心服務,如果緩存部分失效或者失敗,應該繼續回源處理,不應該直接中斷返回。
- 容量控制
使用緩存要進行容量控制,特別是本地緩存,緩存數量太多記憶體緊張時會頻繁的 swap 存儲空間或 GC 操作,從而降低響應速度。
- 業務導向
以業務為導向,不要為了緩存而緩存。對性能要求不高或請求量不大,分散式緩存甚至資料庫都足以應對時,就不需要增加本地緩存,否則可能因為引入數據節點複製和冪等處理邏輯反而得不償失。
- 監控告警
對大對象、慢查詢、記憶體占用等進行監控,做到緩存可觀測,用得放心。
3.3 非同步
3.3.1 調用非同步
調用非同步發生在使用非同步編程模型來提高代碼效率的時候,實現方式主要有:
- Callback
非同步回調通過註冊一個回調函數,然後發起非同步任務,當任務執行完畢時會回調用戶註冊的回調函數,從而減少調用端等待時間。這種方式會造成代碼分散難以維護,定位問題也相對困難;
- Future
當用戶提交一個任務時會立刻先返回一個 Future,然後任務非同步執行,後續可以通過 Future 獲取執行結果;
可以對多個非同步編程進行編排,組成更複雜的非同步處理,並以同步的代碼調用形式實現非同步效果。CPS 將後續的處理邏輯當作參數傳遞給 Then 並可以最終捕獲異常,解決了非同步回調代碼散亂和異常跟蹤難的問題。Java 中的 CompletableFuture 和 C++ PPL 基本支持這一特性。典型的調用形式如下:
void handleRequest(const Request &req) { return req.Read().Then([](Buffer &inbuf){ return handleData(inbuf); }).Then([](Buffer &outbuf){ return handleWrite(outbuf); }).Finally(){ return cleanUp(); }); }
關於 CPS 更多信息推薦閱讀:2018 中國 C++ 大會的吳銳_C++伺服器開發實踐部分。
3.3.2 流程非同步
同步改非同步,可以降低主鏈路的處理耗時。
舉個例子,比如我們去 KFC 點餐,遇到排隊的人很多,當點完餐後,大多情況下我們會隔幾分鐘就去問好了沒,反覆去問了好幾次才拿到,在這期間我們也沒法幹活了。
這個就叫同步輪訓,這樣效率顯然太低了。
服務員被問煩了,就在點完餐後給我們一個號碼牌,每次準備好了就會在服務台叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續乾自己的事。這就叫非同步。
3.4 池化
3.4.1 為什麼要池化
池化的目的是完成資源復用,避免資源重覆創建、刪除來提高性能。
常見的池子有記憶體池、連接池、線程池、對象池...
記憶體、連接、線程、對象等都是資源,創建和銷毀這些資源都有一個特征, 那就是會涉及到很多系統調用或者網路 IO。每次都在請求中去創建這些資源,會增加處理耗時,但是如果我們用一個 容器(池) 把它們保存起來,下次需要的時候,直接拿出來使用,避免重覆創建和銷毀浪費的時間。
3.4.1 記憶體池
我們都知道,在 C/C++ 中分別使用 malloc/free 和 new/delete 進行記憶體的分配,其底層調用系統調用 sbrk/brk。頻繁的調用系統調用分配釋放記憶體不但影響性能還容易造成記憶體碎片,記憶體池技術旨在解決這些問題。正是這些原因,C/C++ 中的記憶體操作並不是直接調用系統調用,而是已經實現了自己的一套記憶體管理,malloc 的實現主要有三大實現。
- ptmalloc:glibc 的實現。
- tcmalloc:Google 的實現。
- jemalloc:Facebook 的實現。
雖然標準庫的實現在操作系統記憶體管理的基礎上再加了一層記憶體管理,但應用程式通常也會實現自己特定的記憶體池,如為了引用計數或者專門用於小對象分配。所以看起來記憶體管理一般分為三個層次。
3.4.2 線程池
線程創建是需要分配資源的,這存在一定的開銷,如果我們一個任務就創建一個線程去處理,這必然會影響系統的性能。線程池的可以限制線程的創建數量並重覆使用,從而提高系統的性能。
線程池可以分類或者分組,不同的任務可以使用不同的線程組,可以進行隔離以免互相影響。對於分類,可以分為核心和非核心,核心線程池一直存在不會被回收,非核心可能對空閑一段時間後的線程進行回收,從而節省系統資源,等到需要時在按需創建放入池子中。
3.4.3 連接池
常用的連接池有資料庫連接池、redis 連接池、TCP 連接池等等,其主要目的是通過復用來減少創建和釋放連接的開銷。連接池實現通常需要考慮以下幾個問題:
-
初始化:啟動即初始化和惰性初始化。啟動初始化可以減少一些加鎖操作和需要時可直接使用,缺點是可能造成服務啟動緩慢或者啟動後沒有任務處理,造成資源浪費。惰性初始化是真正有需要的時候再去創建,這種方式可能有助於減少資源占用,但是如果面對突發的任務請求,然後瞬間去創建一堆連接,可能會造成系統響應慢或者響應失敗,通常我們會採用啟動即初始化的方式。
-
連接數目:權衡所需的連接數,連接數太少則可能造成任務處理緩慢,太多不但使任務處理慢還會過度消耗系統資源。
-
連接取出:當連接池已經無可用連接時,是一直等待直到有可用連接還是分配一個新的臨時連接。
-
連接放入:當連接使用完畢且連接池未滿時,將連接放入連接池(包括連接池已經無可用連接時創建的臨時連接),否則關閉。
-
連接檢測:長時間空閑連接和失效連接需要關閉並從連接池移除。常用的檢測方法有:使用時檢測和定期檢測。
3.4.4 對象池
嚴格來說,各種池都是對象池的的具體應用,包括前面介紹的三種池。
對象池跟各種池一樣,也是緩存一些對象從而避免大量創建同一個類型的對象,同時限制了實例的個數。如 Redis 中 0-9999 整數對象就通過對象池進行共用。在游戲開發中對象池經常使用,如進入地圖時怪物和 NPC 的出現並不是每次都是重新創建,而是從對象池中取出。
3.5 批量
能批量就不要併發。
如果調用方需要調用我們介面多次才能進行一個完整的操作,那麼這個介面設計就可能有問題。
比如獲取數據的介面,如果僅僅提供getData(int id)
介面,那麼使用方如果要一次性獲取 20 個數據,它就需要迴圈遍歷調用我們介面 20 次,不僅使用方性能很差,也無端增加了我們服務的壓力,這時提供一個批量拉取的介面getDataBatch(List<Integer> idList)
顯然是必要的。
對於批量介面,我們也要註意介面的吞吐能力,避免長時間執行。
還是以獲取數據的介面為例:getDataList(List<Integer> idList)
,假設一個用戶一次傳 1w 個 id 進來,那麼介面可能需要很長的時間才能處理完,這往往會導致超時,用戶怎麼調用結果都是超時異常,那怎麼辦?限制長度,比如限制長度為 100,即每次最多只能傳 100 個 id,這樣就能避免長時間執行,如果用戶傳的 id 列表長度超過 100 就報異常。
加了這樣限制後,必須要讓使用方清晰地知道這個方法有此限制,儘可能地避免用戶誤用。
有三種方法:
- 改變方法名,比如
getDataListWithLimitLength(List<Integer> idList)
; - 在介面說明文檔中增加必要的註釋說明;
- 介面明確拋出超長異常,直白告知主調。
3.6 併發
3.6.1 請求併發
如果一個任務需要處理多個子任務,可以將沒有依賴關係的子任務併發化,這種場景在後臺開發很常見。如一個請求需要查詢 3 個數據,分別耗時 T1、T2、T3,如果串列調用總耗時 T=T1+T2+T3。對三個任務執行併發,總耗時 T=max(T1,T 2,T3)。同理,寫操作也如此。對於同種請求,還可以同時進行批量合併,減少 RPC 調用次數。
3.6.2 冗餘請求
冗餘請求指的是同時向後端服務發送多個同樣的請求,誰響應快就是使用誰,其他的則丟棄。這種策略縮短了主調方的等待時間,但也使整個系統調用量猛增,一般適用於初始化或者請求少的場景。比如騰訊公司 WNS 的跑馬模塊其實就是這種機制,跑馬模塊為了快速建立長連接同時向後臺多個 IP/Port 發起請求,誰快就用誰,這在弱網的移動設備上特別有用,如果使用等待超時再重試的機制,無疑將大大增加用戶的等待時間。
這種方式較少使用,知道即可。
3.7 存儲設計
任何一個系統,從單機到分散式,從前端到後臺,功能和邏輯各不相同,但乾的只有兩件事:讀和寫。而每個系統的業務特性可能都不一樣,有的側重讀、有的側重寫,有的兩者兼備,本節主要探討在不同業務場景下存儲讀寫的一些方法論。
3.7.1 讀寫分離
大多數業務都是讀多寫少,為了提高系統處理能力,可以採用讀寫分離的方式將主節點用於寫,從節點用於讀,如下圖所示。
讀寫分離架構有以下幾個特點:(1)資料庫服務為主從架構;(2)主節點負責寫操作,從節點負責讀操作;(3)主節點將數據複製到從節點;
基於讀寫分離思想,可以設計出多種主從架構,如主-主-從、主-從-從等。主從節點也可以是不同的存儲,如 MySQL+Redis。
讀寫分離的主從架構一般採用非同步複製,會存在數據複製延遲的問題,適用於對數據一致性要求不高的業務。可採用以下幾個方式儘量避免複製滯後帶來的問題。
- 寫後讀一致
即讀自己的寫,適用於用戶寫操作後要求實時看到更新。典型的場景是,用戶註冊賬號或者修改賬戶密碼後,緊接著登錄,此時如果讀請求發送到從節點,由於數據可能還沒同步完成,用戶登錄失敗,這是不可接受的。針對這種情況,可以將自己的讀請求發送到主節點上,查看其他用戶信息的請求依然發送到從節點。
- 二次讀取
優先讀取從節點,如果讀取失敗或者跟蹤的更新時間小於某個閥值,則再從主節點讀取。
- 區分場景
關鍵業務讀寫主節點,非關鍵業務讀寫分離。
- 單調讀
保證用戶的讀請求都發到同一個從節點,避免出現回滾的現象。如用戶在 M 主節點更新信息後,數據很快同步到了從節點 S1,用戶查詢時請求發往 S1,看到了更新的信息。接著用戶再