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