后台服务架构高性能设计之道
“N 高 N 可”,高性能、高并發、高可用、高可靠、可擴展、可維護、可用性等是后臺開發耳熟能詳的詞了,它們中有些詞在大部分情況下表達相近意思。本序列文章旨在探討和總結后臺架構設計中常用的技術和方法,并歸納成一套方法論。
前言
本文主要探討和總結服務架構設計中高性能的技術和方法,如下圖的思維導圖所示,左邊部分主要偏向于編程應用,右邊部分偏向于組件應用,文章將按圖中的內容展開。
1 無鎖化
大多數情況下,多線程處理可以提高并發性能,但如果對共享資源的處理不當,嚴重的鎖競爭也會導致性能的下降。面對這種情況,有些場景采用了無鎖化設計,特別是在底層框架上。無鎖化主要有兩種實現,串行無鎖和數據結構無鎖。
1.1 串行無鎖
無鎖串行最簡單的實現方式可能就是單線程模型了,如 redis/Nginx 都采用了這種方式。在網絡編程模型中,常規的方式是主線程負責處理 I/O 事件,并將讀到的數據壓入隊列,工作線程則從隊列中取出數據進行處理,這種半同步/半異步模型需要對隊列進行加鎖,如下圖所示:
上圖的模式可以改成無鎖串行的形式,當 MainReactor accept 一個新連接之后從眾多的 SubReactor 選取一個進行注冊,通過創建一個 Channel 與 I/O 線程進行綁定,此后該連接的讀寫都在同一個線程執行,無需進行同步。
1.2 結構無鎖
利用硬件支持的原子操作可以實現無鎖的數據結構,很多語言都提供 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); //①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)); //②} };從代碼可以看出,在有鎖版本中 ① 進行了加鎖。在無鎖版本中,② 使用了原子 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
2 零拷貝
這里的拷貝指的是數據在內核緩沖區和應用程序緩沖區直接的傳輸,并非指進程空間中的內存拷貝(當然這方面也可以實現零拷貝,如傳引用和 C++中 move 操作)。現在假設我們有個服務,提供用戶下載某個文件,當請求到來時,我們把服務器磁盤上的數據發送到網絡中,這個流程偽代碼如下:
filefd = open(...); //打開文件 sockfd = socket(...); //打開socket buffer = new buffer(...); //創建buffer read(filefd, buffer); //從文件內容讀到buffer中 write(sockfd, buffer); //將buffer中的內容發送到網絡數據拷貝流程如下圖:
?上圖中綠色箭頭表示 DMA copy,DMA(Direct Memory Access)即直接存儲器存取,是一種快速傳送數據的機制,指外部設備不通過 CPU 而直接與系統內存交換數據的接口技術。紅色箭頭表示 CPU copy。即使在有 DMA 技術的情況下還是存在 4 次拷貝,DMA copy 和 CPU copy 各 2 次。
【文章福利】另外小編還整理了一些C/C++后臺開發教學視頻,相關面試題,后臺學習路線圖免費分享,需要的可以自行添加:Q群:720209036 點擊加入~ 群文件共享
小編強力推薦C++后臺開發免費學習地址:C/C++Linux服務器開發高級架構師/C++后臺開發架構師?
?2.1 內存映射
內存映射將用戶空間的一段內存區域映射到內核空間,用戶對這段內存區域的修改可以直接反映到內核空間,同樣,內核空間對這段區域的修改也直接反映用戶空間,簡單來說就是用戶空間共享這個內核緩沖區。
使用內存映射來改寫后的偽代碼如下:
filefd = open(...); //打開文件 sockfd = socket(...); //打開socket buffer = mmap(filefd); //將文件映射到進程空間 write(sockfd, buffer); //將buffer中的內容發送到網絡使用內存映射后數據拷貝流如下圖所示:
?從圖中可以看出,采用內存映射后數據拷貝減少為 3 次,不再經過應用程序直接將內核緩沖區中的數據拷貝到 Socket 緩沖區中。RocketMQ 為了消息存儲高性能,就使用了內存映射機制,將存儲文件分割成多個大小固定的文件,基于內存映射執行順序寫。
2.2 零拷貝
零拷貝就是一種避免 CPU 將數據從一塊存儲拷貝到另外一塊存儲,從而有效地提高數據傳輸效率的技術。Linux 內核 2.4 以后,支持帶有 DMA 收集拷貝功能的傳輸,將內核頁緩存中的數據直接打包發到網絡上,偽代碼如下:
filefd = open(...); //打開文件 sockfd = socket(...); //打開socket sendfile(sockfd, filefd); //將文件內容發送到網絡使用零拷貝后流程如下圖:
?零拷貝的步驟為:1)DMA 將數據拷貝到 DMA 引擎的內核緩沖區中;2)將數據的位置和長度的信息的描述符加到套接字緩沖區;3)DMA 引擎直接將數據從內核緩沖區傳遞到協議引擎;
可以看出,零拷貝并非真正的沒有拷貝,還是有 2 次內核緩沖區的 DMA 拷貝,只是消除了內核緩沖區和用戶緩沖區之間的 CPU 拷貝。Linux 中主要的零拷貝函數有 sendfile、splice、tee 等。下圖是來住 IBM 官網上普通傳輸和零拷貝傳輸的性能對比,可以看出零拷貝比普通傳輸快了 3 倍左右,Kafka 也使用零拷貝技術。
3 序列化
當將數據寫入文件、發送到網絡、寫入到存儲時通常需要序列化(serialization)技術,從其讀取時需要進行反序列化(deserialization),又稱編碼(encode)和解碼(decode)。序列化作為傳輸數據的表示形式,與網絡框架和通信協議是解耦的。如網絡框架 taf 支持 jce、json 和自定義序列化,HTTP 協議支持 XML、JSON 和流媒體傳輸等。
序列化的方式很多,作為數據傳輸和存儲的基礎,如何選擇合適的序列化方式尤其重要。
3.1 分類
通常而言,序列化技術可以大致分為以下三種類型:
-
內置類型:指編程語言內置支持的類型,如 java 的 java.io.Serializable。這種類型由于與語言綁定,不具有通用性,而且一般性能不佳,一般只在局部范圍內使用。
-
文本類型:一般是標準化的文本格式,如 XML、JSON。這種類型可讀性較好,且支持跨平臺,具有廣泛的應用。主要缺點是比較臃腫,網絡傳輸占用帶寬大。
-
二進制類型:采用二進制編碼,數據組織更加緊湊,支持多語言和多平臺。常見的有 Protocol Buffer/Thrift/MessagePack/FlatBuffer 等。
3.2 性能指標
衡量序列化/反序列化主要有三個指標:1)序列化之后的字節大小;2)序列化/反序列化的速度;3)CPU 和內存消耗;
下圖是一些常見的序列化框架性能對比:
?可以看出 Protobuf 無論是在序列化速度上還是字節占比上可以說是完爆同行。不過人外有人,天外有天,聽說 FlatBuffer 比 Protobuf 更加無敵,下圖是來自 Google 的 FlatBuffer 和其他序列化性能對比,光看圖中數據 FB 貌似秒殺 PB 的存在。
3.3 選型考量
在設計和選擇序列化技術時,要進行多方面的考量,主要有以下幾個方面:1)性能:CPU 和字節占用大小是序列化的主要開銷。在基礎的 RPC 通信、存儲系統和高并發業務上應該選擇高性能高壓縮的二進制序列化。一些內部服務、請求較少 Web 的應用可以采用文本的 JSON,瀏覽器直接內置支持 JSON。2)易用性:豐富數據結構和輔助工具能提高易用性,減少業務代碼的開發量?,F在很多序列化框架都支持 List、Map 等多種結構和可讀的打印。3)通用性:現代的服務往往涉及多語言、多平臺,能否支持跨平臺跨語言的互通是序列化選型的基本條件。4)兼容性:現代的服務都是快速迭代和升級,一個好的序列化框架應該有良好的向前兼容性,支持字段的增減和修改等。5)擴展性:序列化框架能否低門檻的支持自定義的格式有時候也是一個比較重要的考慮因素。
4 池子化
池化恐怕是最常用的一種技術了,其本質就是通過創建池子來提高對象復用,減少重復創建、銷毀的開銷。常用的池化技術有內存池、線程池、連接池、對象池等。
4.1 內存池
我們都知道,在 C/C++中分別使用 malloc/free 和 new/delete 進行內存的分配,其底層調用系統調用 sbrk/brk。頻繁的調用系統調用分配釋放內存不但影響性能還容易造成內存碎片,內存池技術旨在解決這些問題。正是這些原因,C/C++中的內存操作并不是直接調用系統調用,而是已經實現了自己的一套內存管理,malloc 的實現主要有三大實現。
1)ptmalloc:glibc 的實現。
2)tcmalloc:Google 的實現。
3)jemalloc:Facebook 的實現。
下面是來自網上的三種 malloc 的比較圖,tcmalloc 和 jemalloc 性能差不多,ptmalloc 的性能不如兩者,我們可以根據需要選用更適合的 malloc,如 redis 和 mysl 都可以指定使用哪個 malloc。至于三者的實現和差異,可以網上查閱。
?雖然標準庫的實現在操作系統內存管理的基礎上再加了一層內存管理,但應用程序通常也會實現自己特定的內存池,如為了引用計數或者專門用于小對象分配。所以看起來內存管理一般分為三個層次。
?4.2 線程池
線程創建是需要分配資源的,這存在一定的開銷,如果我們一個任務就創建一個線程去處理,這必然會影響系統的性能。線程池的可以限制線程的創建數量并重復使用,從而提高系統的性能。
線程池可以分類或者分組,不同的任務可以使用不同的線程組,可以進行隔離以免互相影響。對于分類,可以分為核心和非核心,核心線程池一直存在不會被回收,非核心可能對空閑一段時間后的線程進行回收,從而節省系統資源,等到需要時在按需創建放入池子中。
4.3 連接池
常用的連接池有數據庫連接池、redis 連接池、TCP 連接池等等,其主要目的是通過復用來減少創建和釋放連接的開銷。連接池實現通常需要考慮以下幾個問題:
1)初始化:啟動即初始化和惰性初始化。啟動初始化可以減少一些加鎖操作和需要時可直接使用,缺點是可能造成服務啟動緩慢或者啟動后沒有任務處理,造成資源浪費。惰性初始化是真正有需要的時候再去創建,這種方式可能有助于減少資源占用,但是如果面對突發的任務請求,然后瞬間去創建一堆連接,可能會造成系統響應慢或者響應失敗,通常我們會采用啟動即初始化的方式。
2)連接數目:權衡所需的連接數,連接數太少則可能造成任務處理緩慢,太多不但使任務處理慢還會過度消耗系統資源。
3)連接取出:當連接池已經無可用連接時,是一直等待直到有可用連接還是分配一個新的臨時連接。
4)連接放入:當連接使用完畢且連接池未滿時,將連接放入連接池(包括 3 中創建的臨時連接),否則關閉。
5)連接檢測:長時間空閑連接和失效連接需要關閉并從連接池移除。常用的檢測方法有:使用時檢測和定期檢測。
4.4 對象池
嚴格來說,各種池都是對象池模式的應用,包括前面的這三哥們。對象池跟各種池一樣,也是緩存一些對象從而避免大量創建同一個類型的對象,同時限制了實例的個數。如 redis 中 0-9999 整數對象就通過采用對象池進行共享。在游戲開發中對象池模式經常使用,如進入地圖時怪物和 NPC 的出現并不是每次都是重新創建,而是從對象池中取出。
5 并發化
5.1 請求并發
如果一個任務需要處理多個子任務,可以將沒有依賴關系的子任務并發化,這種場景在后臺開發很常見。如一個請求需要查詢 3 個數據,分別耗時 T1、T2、T3,如果串行調用總耗時 T=T1+T2+T3。對三個任務執行并發,總耗時 T=max(T1,T 2,T3)。同理,寫操作也如此。對于同種請求,還可以同時進行批量合并,減少 RPC 調用次數。
5.2 冗余請求
冗余請求指的是同時向后端服務發送多個同樣的請求,誰響應快就是使用誰,其他的則丟棄。這種策略縮短了客戶端的等待時間,但也使整個系統調用量猛增,一般適用于初始化或者請求少的場景。公司 WNS 的跑馬模塊其實就是這種機制,跑馬模塊為了快速建立長連接同時向后臺多個 ip/port 發起請求,誰快就用誰,這在弱網的移動設備上特別有用,如果使用等待超時再重試的機制,無疑將大大增加用戶的等待時間。
6 異步化
對于處理耗時的任務,如果采用同步等待的方式,會嚴重降低系統的吞吐量,可以通過異步化進行解決。異步在不同層面概念是有一些差異的,在這里我們不討論異步 I/O。
6.1 調用異步化
在進行一個耗時的 RPC 調用或者任務處理時,常用的異步化方式如下:
-
Callback:異步回調通過注冊一個回調函數,然后發起異步任務,當任務執行完畢時會回調用戶注冊的回調函數,從而減少調用端等待時間。這種方式會造成代碼分散難以維護,定位問題也相對困難。
-
Future:當用戶提交一個任務時會立刻先返回一個 Future,然后任務異步執行,后續可以通過 Future 獲取執行結果。對 1.4.1 中請求并發,我們可以使用 Future 實現,偽代碼如下: //異步并發任務 Future<Response> f1 = Executor.submit(query1); Future<Response> f2 = Executor.submit(query2); Future<Response> f3 = Executor.submit(query3); //處理其他事情 doSomething(); //獲取結果 Response res1 = f1.getResult(); Response res2 = f2.getResult(); Response res3 = f3.getResult();
-
CPS (Continuation-passing style)可以對多個異步編程進行編排,組成更復雜的異步處理,并以同步的代碼調用形式實現異步效果。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(); }); }
6.2 流程異步化
一個業務流程往往伴隨著調用鏈路長、后置依賴多等特點,這會同時降低系統的可用性和并發處理能力??梢圆捎脤Ψ顷P鍵依賴進行異步化解決。如企鵝電競開播服務,除了開播寫節目存儲以外,還需要將節目信息同步到神盾推薦平臺、App 首頁和二級頁等。由于同步到外部都不是開播的關鍵邏輯且對一致性要求不是很高,可以對這些后置的同步操作進行異步化,寫完存儲即向 App 返回響應,如下圖所示:
7 緩存
從單核 CPU 到分布式系統,從前端到后臺,緩存無處不在。古有朱元璋“緩稱王”而終得天下,今有不論是芯片制造商還是互聯網公司都同樣采取了“緩稱王”(緩存稱王)的政策才能占據一席之地。緩存是原始數據的一個復制集,其本質就是空間換時間,主要是為了解決高并發讀。
7.1 緩存的使用場景
緩存是空間換時間的藝術,使用緩存能提高系統的性能?!皠啪齐m好,可不要貪杯”,使用緩存的目的是為了提高性價比,而不是一上來就為了所謂的提高性能不計成本的使用緩存,而是要看場景。
適合使用緩存的場景,以之前參與過的項目企鵝電競為例:
1)一旦生成后基本不會變化的數據:如企鵝電競的游戲列表,在后臺創建一個游戲之后基本很少變化,可直接緩存整個游戲列表;
2)讀密集型或存在熱點的數據:典型的就是各種 App 的首頁,如企鵝電競首頁直播列表;
3)計算代價大的數據:如企鵝電競的 Top 熱榜視頻,如 7 天榜在每天凌晨根據各種指標計算好之后緩存排序列表;
4)千人一面的數據:同樣是企鵝電競的 Top 熱榜視頻,除了緩存的整個排序列表,同時直接在進程內按頁緩存了前 N 頁數據組裝后的最終回包結果;
不適合使用緩存的場景:
1)寫多讀少,更新頻繁;
2)對數據一致性要求嚴格;
7.2 緩存的分類
-
進程級緩存:緩存的數據直接在進程地址空間內,這可能是訪問速度最快使用最簡單的緩存方式了。主要缺點是受制于進程空間大小,能緩存的數據量有限,進程重啟緩存數據會丟失。一般通常用于緩存數據量不大的場景。
-
集中式緩存:緩存的數據集中在一臺機器上,如共享內存。這類緩存容量主要受制于機器內存大小,而且進程重啟后數據不丟失。常用的集中式緩存中間件有單機版 redis、memcache 等。
-
分布式緩存:緩存的數據分布在多臺機器上,通常需要采用特定算法(如 Hash)進行數據分片,將海量的緩存數據均勻的分布在每個機器節點上。常用的組件有:Memcache(客戶端分片)、Codis(代理分片)、Redis Cluster(集群分片)。
-
多級緩存:指在系統中的不同層級的進行數據緩存,以提高訪問效率和減少對后端存儲的沖擊。以下圖的企鵝電競的一個多級緩存應用,根據我們的現網統計,在第一級緩存的命中率就已經達 94%,穿透到 grocery 的請求量很小。
?整體工作流程如下:
-
1)請求到達首頁或者直播間服務后,如果在本地緩存命中則直接返回,否則從下一級緩存核心存儲進行查詢并更新本地緩存;
-
2)前端服務緩存沒有命中穿透到核心存儲服務,如果命中則直接返回給前端服務,沒有則請求存儲層 grocery 并更新緩存;
-
3)前兩級 Cache 都沒有命中回源到存儲層 grocery。
7.3 緩存的模式
關于緩存的使用,已經有人總結出了一些模式,主要分為 Cache-Aside 和 Cache-As-SoR 兩類。其中 SoR(system-of-record):表示記錄系統,即數據源,而 Cache 正是 SoR 的復制集。
Cache-Aside:旁路緩存,這應該是最常見的緩存模式了。對于讀,首先從緩存讀取數據,如果沒有命中則回源 SoR 讀取并更新緩存。對于寫操作,先寫 SoR,再寫緩存。這種模式架構圖如下:
邏輯代碼:
//讀操作 data = Cache.get(key); if(data == NULL) {data = SoR.load(key);Cache.set(key, data); }//寫操作 if(SoR.save(key, data)) {Cache.set(key, data); }這種模式用起來簡單,但對應用層不透明,需要業務代碼完成讀寫邏輯。同時對于寫來說,寫數據源和寫緩存不是一個原子操作,可能出現以下情況導致兩者數據不一致:
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 是一個代理。如下圖所示:
?Cache-As-SoR 有三種實現:
1)Read-Through:發生讀操作時,首先查詢 Cache,如果不命中則再由 Cache 回源到 SoR 即存儲端實現 Cache-Aside 而不是業務)。
2)Write-Through:稱為穿透寫模式,由業務先調用寫操作,然后由 Cache 負責寫緩存和 SoR。
3)Write-Behind:稱為回寫模式,發生寫操作時業務只更新緩存并立即返回,然后異步寫 SoR,這樣可以利用合并寫/批量寫提高性能。
7.4 緩存的回收策略
在空間有限、低頻熱點訪問或者無主動更新通知的情況下,需要對緩存數據進行回收,常用的回收策略有以下幾種:
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):最近最少被使用的數據最先被淘汰,即統計每個對象的使用次數,當需要淘汰時,選擇被使用次數最少的淘汰。
7.5 緩存的崩潰與修復
由于在設計不足、請求攻擊(并不一定是惡意攻擊)等會造成一些緩存問題,下面列出了常見的緩存問題和解決方案。
緩存穿透:大量使用不存在的 key 進行查詢時,緩存沒有命中,這些請求都穿透到后端的存儲,最終導致后端存儲壓力過大甚至被壓垮。這種情況原因一般是存儲中數據不存在,主要有兩個解決辦法。
-
1)設置空置或默認值:如果存儲中沒有數據,則設置一個空置或者默認值緩存起來,這樣下次請求時就不會穿透到后端存儲。但這種情況如果遇到惡意攻擊,不斷的偽造不同的 key 來查詢時并不能很好的應對,這時候需要引入一些安全策略對請求進行過濾。
-
2)布隆過濾器:采用布隆過濾器將,將所有可能存在的數據哈希到一個足夠大的 bitmap 中,一個一定不存在的數據會被這個 bitmap 攔截掉,從而避免了對底層數據庫的查詢壓力。
緩存雪崩:指大量的緩存在某一段時間內集體失效,導致后端存儲負載瞬間升高甚至被壓垮。通常是以下原因造成:
-
1)緩存失效時間集中在某段時間,對于這種情況可以采取對不同的 key 使用不同的過期時間,在原來基礎失效時間的基礎上再加上不同的隨機時間;
-
2)采用取模機制的某緩存實例宕機,這種情況移除故障實例后會導致大量的緩存不命中。有兩種解決方案:① 采取主從備份,主節點故障時直接將從實例替換主;② 使用一致性哈希替代取模,這樣即使有實例崩潰也只是少部分緩存不命中。
緩存熱點:雖然緩存系統本身性能很高,但也架不住某些熱點數據的高并發訪問從而造成緩存服務本身過載。假設一下微博以用戶 id 作為哈希 key,突然有一天志玲姐姐宣布結婚了,如果她的微博內容按照用戶 id 緩存在某個節點上,當她的萬千粉絲查看她的微博時必然會壓垮這個緩存節點,因為這個 key 太熱了。這種情況可以通過生成多份緩存到不同節點上,每份緩存的內容一樣,減輕單個節點訪問的壓力。
7.6 緩存的一些好實踐
1)動靜分離:對于一個緩存對象,可能分為很多種屬性,這些屬性中有的是靜態的,有的是動態的。在緩存的時候最好采用動靜分離的方式。如企鵝電競的視頻詳情分為標題、時長、清晰度、封面 URL、點贊數、評論數等,其中標題、時長等屬于靜態屬性,基本不會改變,而點贊數、評論數經常改變,在緩存時這兩部分開,以免因為動態屬性每次的變更要把整個視頻緩存拉出來進行更新一遍,成本很高。
2)慎用大對象:如果緩存對象過大,每次讀寫開銷非常大并且可能會卡住其他請求,特別是在 redis 這種單線程的架構中。典型的情況是將一堆列表掛在某個 value 的字段上或者存儲一個沒有邊界的列表,這種情況下需要重新設計數據結構或者分割 value 再由客戶端聚合。
3)過期設置:盡量設置過期時間減少臟數據和存儲占用,但要注意過期時間不能集中在某個時間段。
4)超時設置:緩存作為加速數據訪問的手段,通常需要設置超時時間而且超時時間不能過長(如 100ms 左右),否則會導致整個請求超時連回源訪問的機會都沒有。
5)緩存隔離:首先,不同的業務使用不同的 key,防止出現沖突或者互相覆蓋。其次,核心和非核心業務進行通過不同的緩存實例進行物理上的隔離。
6)失敗降級:使用緩存需要有一定的降級預案,緩存通常不是關鍵邏輯,特別是對于核心服務,如果緩存部分失效或者失敗,應該繼續回源處理,不應該直接中斷返回。
7)容量控制:使用緩存要進行容量控制,特別是本地緩存,緩存數量太多內存緊張時會頻繁的 swap 存儲空間或 GC 操作,從而降低響應速度。
8)業務導向:以業務為導向,不要為了緩存而緩存。對性能要求不高或請求量不大,分布式緩存甚至數據庫都足以應對時,就不需要增加本地緩存,否則可能因為引入數據節點復制和冪等處理邏輯反而得不償失。
9)監控告警:跟妹紙永遠是對的一樣,總不會錯。對大對象、慢查詢、內存占用等進行監控。
【文章福利】另外小編還整理了一些C/C++后臺開發教學視頻,相關面試題,后臺學習路線圖免費分享,需要的可以自行添加:Q群:720209036 點擊加入~ 群文件共享
小編強力推薦C++后臺開發免費學習地址:C/C++Linux服務器開發高級架構師/C++后臺開發架構師?
8 分片
分片即將一個較大的部分分成多個較小的部分,在這里我們分為數據分片和任務分片。對于數據分片,在本文將不同系統的拆分技術術語(如 region、shard、vnode、partition)等統稱為分片。分片可以說是一箭三雕的技術,將一個大數據集分散在更多節點上,單點的讀寫負載隨之也分散到了多個節點上,同時還提高了擴展性和可用性。
數據分片,小到編程語言標準庫里的集合,大到分布式中間件,無所不在。如我曾經寫過一個線程安全的容器以放置各種對象時,為了減少鎖爭用,對容器進行了分段,每個分段一個鎖,按照哈?;蛘呷∧ο蠓胖玫侥硞€分段中,如 Java 中的 ConcurrentHashMap 也采取了分段的機制。分布式消息中間件 Kafka 中對 topic 也分成了多個 partition,每個 partition 互相獨立可以比并發讀寫。
8.1 分片策略
進行分片時,要盡量均勻的將數據分布在所有節點上以平攤負載。如果分布不均,會導致傾斜使得整個系統性能的下降。常見的分片策略如下:
-
區間分片 基于一段連續關鍵字的分片,保持了排序,適合進行范圍查找,減少了垮分片讀寫。區間分片的缺點是容易造成數據分布不均勻,導致熱點。如直播平臺,如果按 ID 進行區間分片,通常短位 ID 都是一些大主播,如在 100-1000 內 ID 的訪問肯定比十位以上 ID 頻繁。常見的還有按時間范圍分片,則最近時間段的讀寫操作通常比很久之前的時間段頻繁。
-
隨機分片 按照一定的方式(如哈希取模)進行分片,這種方式數據分布比較均勻,不容易出現熱點和并發瓶頸。缺點就是失去了有序相鄰的特性,如進行范圍查詢時會向多個節點發起請求。
-
組合分片:對區間分片和隨機分片的一種折中,采取了兩種方式的組合。通過多個鍵組成復合鍵,其中第一個鍵用于做哈希隨機,其余鍵用于進行區間排序。如直播平臺以主播 id+開播時間(anchor_id,live_time)作為組合鍵,那么可以高效的查詢某主播在某個時間段內的開播記錄。社交場景,如微信朋友圈、QQ 說說、微博等以用戶 id+發布時間(user_id,pub_time)的組合找到用戶某段時間的發表記錄。
8.2 二級索引
二級索引通常用來加速特定值的查找,不能唯一標識一條記錄,使用二級索引需要二次查找。關系型數據庫和一些 K-V 數據庫都支持二級索引,如 mysql 中的輔助索引(非聚簇索引),ES 倒排索引通過 term 找到文檔。
-
本地索引 索引存儲在與關鍵字相同的分區中,即索引和記錄在同一個分區,這樣對于寫操作時都在一個分區里進行,不需要跨分區操作。但是對于讀操作,需要聚合其他分區上的數據。如以王者榮耀短視頻為例,以視頻 vid 作為關鍵索引,視頻標簽(如五殺、三殺、李白、阿珂)作為二級索引,本地索引如下圖所示:
-
全局索引 按索引值本身進行分區,與關鍵字所以獨立。這樣對于讀取某個索引的數據時,都在一個分區里進行,而對于寫操作,需要跨多個分區。仍以上面的例子為例,全局索引如下圖所示:
8.3 路由策略
路由策略決定如何將數據請求發送到指定的節點,包括分片調整后的路由。通常有三種方式:客戶端路由、代理路由和集群路由。
-
客戶端路由 客戶端直接操作分片邏輯,感知分片和節點的分配關系并直接連接到目標節點。Memcache 就是采用這種方式實現的分布式,如下圖所示。
代理層路由 客戶端的請求到發送到代理層,由其將請求轉發到對應的數據節點上。很多分布式系統都采取了這種方式,如業界的基于 redis 實現的分布式存儲 codis(codis-proxy 層),公司內如 CMEM(Access 接入層)、DCache(Proxy+Router)等。如下圖所示 CMEM 架構圖,紅色方框內的 Access 層就是路由代理層。
?集群路由 由集群實現分片路由,客戶端連接任意節點,如果該節點存在請求的分片,則處理;否則將請求轉發到合適的節點或者告訴客戶端重定向到目標節點。如 redis cluster 和公司的 CKV+采用了這種方式,下圖的 CKV+集群路由轉發。
?以上三種路由方式都各優缺點,客戶端路由實現相對簡單但對業務入侵較強。代理層路由對業務透明,但增加了一層網絡傳輸,對性能有一定影響,同時在部署維護上也相對復雜。集群路由對業務透明,且比代理路由少了一層結構,節約成本,但實現更復雜,且不合理的策略會增加多次網絡傳輸。
8.4 動態平衡
在學習平衡二叉樹和紅黑樹的時候我們都知道,由于數據的插入刪除會破壞其平衡性。為了保持樹的平衡,在插入刪除后我們會通過左旋右旋動態調整樹的高度以保持再平衡。在分布式數據存儲也同樣需要再平衡,只不過引起不平衡的因素更多了,主要有以下幾個方面:
1)讀寫負載增加,需要更多 CPU;
2)數據規模增加,需要更多磁盤和內存;
3)數據節點故障,需要其他節點接替;
業界和公司很多產品也都支持動態平衡調整,如 redis cluster 的 resharding,HDFS/kafka 的 rebalance。常見的方式如下:
-
固定分區 創建遠超節點數的分區數,為每個節點分配多個分區。如果新增節點,可從現有的節點上均勻移走幾個分區從而達到平衡,刪除節點反之,如下圖所示。典型的就是一致性哈希,創建 2^32-1 個虛擬節點(vnode)分布到物理節點上。該模式比較簡單,需要在創建的時候就確定分區數,如果設置太小,數據迅速膨脹的話再平衡的代價就很大。如果分區數設置很大,則會有一定的管理開銷。
?動態分區 自動增減分區數,當分區數據增長到一定閥值時,則對分區進行拆分。當分區數據縮小到一定閥值時,對分區進行合并。類似于 B+樹的分裂刪除操作。很多存儲組件都采用了這種方式,如 HBase Region 的拆分合并,TDSQL 的 Set Shard。這種方式的優點是自動適配數據量,擴展性好。使用這種分區需要注意的一點,如果初始化分區為一個,剛上線請求量就很大的話會造成單點負載高,通常采取預先初始化多個分區的方式解決,如 HBase 的預分裂。
8.5 分庫分表
當數據庫的單表/單機數據量很大時,會造成性能瓶頸,為了分散數據庫的壓力,提高讀寫性能,需要采取分而治之的策略進行分庫分表。通常,在以下情況下需要進行分庫分表:
1)單表的數據量達到了一定的量級(如 mysql 一般為千萬級),讀寫的性能會下降。這時索引也會很大,性能不佳,需要分解單表。
2)數據庫吞吐量達到瓶頸,需要增加更多數據庫實例來分擔數據讀寫壓力。
分庫分表按照特定的條件將數據分散到多個數據庫和表中,分為垂直切分和水平切分兩種模式。
-
垂直切分:按照一定規則,如業務或模塊類型,將一個數據庫中的多個表分布到不同的數據庫上。以直播平臺為例,將直播節目數據、視頻點播數據、用戶關注數據分別存儲在不同的數據庫上,如下圖所示:
?優點:
1)切分規則清晰,業務劃分明確;
2)可以按照業務的類型、重要程度進行成本管理,擴展也方便;
3)數據維護簡單;
缺點:
1)不同表分到了不同的庫中,無法使用表連接 Join。不過在實際的業務設計中,也基本不會用到 join 操作,一般都會建立映射表通過兩次查詢或者寫時構造好數據存到性能更高的存儲系統中。
2)事務處理復雜,原本在事務中操作同一個庫的不同表不再支持。如直播結束時更新直播節目同時生成一個直播的點播回放在分庫之后就不能在一個事物中完成,這時可以采用柔性事務或者其他分布式事物方案。
-
水平切分:按照一定規則,如哈?;蛉∧?#xff0c;將同一個表中的數據拆分到多個數據庫上??梢院唵卫斫鉃榘葱胁鸱?#xff0c;拆分后的表結構是一樣的。如直播系統的開播記錄,日積月累,表會越來越大,可以按照主播 id 或者開播日期進行水平切分,存儲到不同的數據庫實例中。優點:1)切分后表結構一樣,業務代碼不需要改動;2)能控制單表數據量,有利于性能提升;缺點:1)Join、count、記錄合并、排序、分頁等問題需要跨節點處理;2)相對復雜,需要實現路由策略;綜上所述,垂直切分和水平切分各有優缺點,通常情況下這兩種模式會一起使用。
8.6 任務分片
記得小時候發新書,老師抱了一堆堆的新書到教室,然后找幾個同學一起分發下去,有的發語文,有的發數學,有的發自然,這就是一種任務分片。車間中的流水線,經過每道工序的并行后最終合成最終的產品,也是一種任務分片。
任務分片將一個任務分成多個子任務并行處理,加速任務的執行,通常涉及到數據分片,如歸并排序首先將數據分成多個子序列,先對每個子序列排序,最終合成一個有序序列。在大數據處理中,Map/Reduce 就是數據分片和任務分片的經典結合。
9 存儲
任何一個系統,從單核 CPU 到分布式,從前端到后臺,要實現各式各樣的功能和邏輯,只有讀和寫兩種操作。而每個系統的業務特性可能都不一樣,有的側重讀、有的側重寫,有的兩者兼備,本節主要探討在不同業務場景下存儲讀寫的一些方法論。
9.1 讀寫分離
大多數業務都是讀多寫少,為了提高系統處理能力,可以采用讀寫分離的方式將主節點用于寫,從節點用于讀,如下圖所示。
?讀寫分離架構有以下幾個特點:1)數據庫服務為主從架構,可以為一主一從或者一主多從;2)主節點負責寫操作,從節點負責讀操作;3)主節點將數據復制到從節點;基于基本架構,可以變種出多種讀寫分離的架構,如主-主-從、主-從-從。主從節點也可以是不同的存儲,如 mysql+redis。
讀寫分離的主從架構一般采用異步復制,會存在數據復制延遲的問題,適用于對數據一致性要求不高的業務??刹捎靡韵聨讉€方式盡量避免復制滯后帶來的問題。
1)寫后讀一致性:即讀自己的寫,適用于用戶寫操作后要求實時看到更新。典型的場景是,用戶注冊賬號或者修改賬戶密碼后,緊接著登錄,此時如果讀請求發送到從節點,由于數據可能還沒同步完成,用戶登錄失敗,這是不可接受的。針對這種情況,可以將自己的讀請求發送到主節點上,查看其他用戶信息的請求依然發送到從節點。
2)二次讀取:優先讀取從節點,如果讀取失敗或者跟蹤的更新時間小于某個閥值,則再從主節點讀取。
3)關鍵業務讀寫主節點,非關鍵業務讀寫分離。
4)單調讀:保證用戶的讀請求都發到同一個從節點,避免出現回滾的現象。如用戶在 M 主節點更新信息后,數據很快同步到了從節點 S1,用戶查詢時請求發往 S1,看到了更新的信息。接著用戶再一次查詢,此時請求發到數據同步沒有完成的從節點 S2,用戶看到的現象是剛才的更新的信息又消失了,即以為數據回滾了。
9.2 動靜分離
動靜分離將經常更新的數據和更新頻率低的數據進行分離。最常見于 CDN,一個網頁通常分為靜態資源(圖片/js/css 等)和動態資源(JSP、PHP 等),采取動靜分離的方式將靜態資源緩存在 CDN 邊緣節點上,只需請求動態資源即可,減少網絡傳輸和服務負載。
在數據庫和 KV 存儲上也可以采取動態分離的方式,如 7.6 提到的點播視頻緩存的動靜分離。在數據庫中,動靜分離更像是一種垂直切分,將動態和靜態的字段分別存儲在不同的庫表中,減小數據庫鎖的粒度,同時可以分配不同的數據庫資源來合理提升利用率。
9.3 冷熱分離
冷熱分離可以說是每個存儲產品和海量業務的必備功能,Mysql、ElasticSearch、CMEM、Grocery 等都直接或間接支持冷熱分離。將熱數據放到性能更好的存儲設備上,冷數據下沉到廉價的磁盤,從而節約成本。企鵝電競為了節省在騰訊云成本,直播回放按照主播粉絲數和時間等條件也采用了冷熱分離,下圖是 ES 冷熱分離的一個實現架構圖。
9.4 重寫輕讀
重寫輕度個人理解可能有兩個含義:1)關鍵寫,降低讀的關鍵性,如異步復制,保證主節點寫成功即可,從節點的讀可容忍同步延遲。2)寫重邏輯,讀輕邏輯,將計算的邏輯從讀轉移到寫。適用于讀請求的時候還要進行計算的場景,常見的如排行榜是在寫的時候構建而不是在讀請求的時候再構建。
在微博、朋友圈等社交產品場景中都有類似關注或朋友的功能。以朋友圈模擬為例(具體我也不知道朋友圈是怎么做的),如果用戶進入朋友圈時看到的朋友消息列表是在請求的時候遍歷其朋友的新消息再按時間排序組裝出來的,這顯然很難滿足朋友圈這么大的海量請求??梢圆扇≈貙戄p讀的方式,在發朋友圈的時候就把列表構造好,然后直接讀就可以了。
仿照 Actor 模型,為用戶建立一個信箱,用戶發朋友圈后寫完自己的信箱就返回,然后異步的將消息推送到其朋友的信箱,這樣朋友讀取他的信箱時就是其朋友圈的消息列表,如下圖所示:
?上圖僅僅是為了展示重寫輕度的思路,在實際應用中還有些其他問題。如:1)寫擴散:這是個寫擴散的行為,如果一個大戶的朋友很多,這寫擴散的代價也是很大的,而且可能有些人萬年不看朋友圈甚至屏蔽了朋友。需要采取一些其他的策略,如朋友數在某個范圍內是才采取這種方式,數量太多采取推拉結合和分析一些活躍指標等。2)信箱容量:一般來說查看朋友圈不會不斷的往下翻頁查看,這時候應該限制信箱存儲條目數,超出的條目從其他存儲查詢。
9.5 數據異構
數據異構主要是按照不同的維度建立索引關系以加速查詢。如京東、天貓等網上商城,一般按照訂單號進行了分庫分表。由于訂單號不在同一個表中,要查詢一個買家或者商家的訂單列表,就需要查詢所有分庫然后進行數據聚合。可以采取構建異構索引,在生成訂單的時同時創建買家和商家到訂單的索引表,這個表可以按照用戶 id 進行分庫分表。
10 隊列
在系統應用中,不是所有的任務和請求必須實時處理,很多時候數據也不需要強一致性而只需保持最終一致性,有時候我們也不需要知道系統模塊間的依賴,在這些場景下隊列技術大有可為。
10.1 應用場景
隊列的應用場景很廣泛,總結起來主要有以下幾個方面:
-
異步處理:業務請求的處理流程通常很多,有些流程并不需要在本次請求中立即處理,這時就可以采用異步處理。如直播平臺中,主播開播后需要給粉絲發送開播通知,可以將開播事件寫入到消息隊列中,然后由專門的 daemon 來處理發送開播通知,從而提高開播的響應速度。
-
流量削峰:高并發系統的性能瓶頸一般在 I/O 操作上,如讀寫數據庫。面對突發的流量,可以使用消息隊列進行排隊緩沖。以企鵝電競為例,每隔一段時間就會有大主播入駐,如夢淚等。這個時候會有大量用戶的訂閱主播,訂閱的流程需要進行多個寫操作,這時先只寫用戶關注了哪個主播存儲。然后在進入消息隊列暫存,后續再寫主播被誰關注和其他存儲。
-
系統解耦:有些基礎服務被很多其他服務依賴,如企鵝電競的搜索、推薦等系統需要開播事件。而開播服務本身并不關心誰需要這些數據,只需處理開播的事情就行了,依賴服務(包括第一點說的發送開播通知的 daemon)可以訂閱開播事件的消息隊列進行解耦。
-
數據同步:消息隊列可以起到數據總線的作用,特別是在跨系統進行數據同步時。拿我以前參與過開發的一個分布式緩存系統為例,通過 RabbitMQ 在寫 Mysql 時將數據同步到 Redis,從而實現一個最終一致性的分布式緩存。
-
柔性事務:傳統的分布式事務采用兩階段協議或者其優化變種實現,當事務執行時都需要爭搶鎖資源和等待,在高并發場景下會嚴重降低系統的性能和吞吐量,甚至出現死鎖?;ヂ摼W的核心是高并發和高可用,一般將傳統的事務問題轉換為柔性事務。下圖是阿里基于消息隊列的一種分布式事務實現(詳情查看:企業 IT 架構轉型之道 阿里巴巴中臺戰略思想與架構實戰,微信讀書有電子版):
?其核心原理和流程是:
1)分布式事務發起方在執行第一個本地事務前,向 MQ 發送一條事務消息并保存到服務端,MQ 消費者無法感知和消費該消息 ①②。
2)事務消息發送成功后開始進行單機事務操作 ③:
a)如果本地事務執行成功,則將 MQ 服務端的事務消息更新為正常狀態 ④;
b)如果本地事務執行時因為宕機或者網絡問題沒有及時向 MQ 服務端反饋,則之前的事務消息會一直保存在 MQ。MQ 服務端會對事務消息進行定期掃描,如果發現有消息保存時間超過了一定的時間閥值,則向 MQ 生產端發送檢查事務執行狀態的請求 ⑤;
c)檢查本地事務結果后 ⑥,如果事務執行成功,則將之前保存的事務消息更新為正常狀態,否則告知 MQ 服務端進行丟棄;
3)消費者獲取到事務消息設置為正常狀態后,則執行第二個本地事務 ⑧。如果執行失敗則通知 MQ 發送方對第一個本地事務進行回滾或正向補償。
10.2 應用分類
-
緩沖隊列:隊列的基本功能就是緩沖排隊,如 TCP 的發送緩沖區,網絡框架通常還會再加上應用層的緩沖區。使用緩沖隊列應對突發流量時,使處理更加平滑,從而保護系統,上過 12306 買票的都懂。
?在大數據日志系統中,通常需要在日志采集系統和日志解析系統之間增加日志緩沖隊列,以防止解析系統高負載時阻塞采集系統甚至造成日志丟棄,同時便于各自升級維護。下圖天機閣數據采集系統中,就采用 Kafka 作為日志緩沖隊列。
?請求隊列:對用戶的請求進行排隊,網絡框架一般都有請求隊列,如 spp 在 proxy 進程和 work 進程之間有共享內存隊列,taf 在網絡線程和 Servant 線程之間也有隊列,主要用于流量控制、過載保護和超時丟棄等。
-
任務隊列:將任務提交到隊列中異步執行,最常見的就是線程池的任務隊列。
-
消息隊列 用于消息投遞,主要有點對點和發布訂閱兩種模式,常見的有 RabbitMQ、RocketMQ、Kafka 等,下圖是常用消息隊列的對比:
?總結
本文探討和總結了后臺開發設計高性能服務的常用方法和技術,并通過思維導圖總結了成一套方法論。當然這不是高性能的全部,甚至只是鳳毛菱角。每個具體的領域都有自己的高性能之道,如網絡編程的 I/O 模型和 C10K 問題,業務邏輯的數據結構和算法設計,各種中間件的參數調優等。文中也描述了一些項目的實踐,如有不合理的地方或者有更好的解決方案,請各位同仁賜教。
參考資料
推薦一個零聲教育C/C++后臺開發的免費公開課程,個人覺得老師講得不錯,分享給大家:C/C++后臺開發高級架構師,內容包括Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK等技術內容,立即學習
原文:后臺服務架構高性能設計之道
總結
以上是生活随笔為你收集整理的后台服务架构高性能设计之道的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【弘成基】运用资料整理
- 下一篇: 天猫登录html代码,天猫静态页面