天天看點

異步機制:如何避免單線程模型的阻塞?

Redis之是以被廣泛應用,很重要的一個原因就是它支援高性能通路。也正因為這樣,我們必須要重視所有可能影響Redis性能的因素(例如指令操作、系統配置、關鍵機制、硬體配置等),不僅要知道具體的機制,盡可能避免性能異常的情況出現,還要提前準備好應對異常的方案。

是以,從這節課開始,我會用6節課的時間介紹影響Redis性能的5大方面的潛在因素,分别是:

  • Redis内部的阻塞式操作;
  • CPU核和NUMA架構的影響;
  • Redis關鍵系統配置;
  • Redis記憶體碎片;
  • Redis緩沖區。

這節課,我們就先學習了解下Redis内部的阻塞式操作以及應對的方法。

在第三講中,我們學習過,Redis的網絡IO和鍵值對讀寫是由主線程完成的。那麼,如果在主線程上執行的操作消耗的時間太長,就會引起主線程阻塞。但是,Redis既有服務用戶端請求的鍵值對增删改查操作,也有保證可靠性的持久化操作,還有進行主從複制時的資料同步操作,等等。操作這麼多,究竟哪些會引起阻塞呢?

别着急,接下來,我就帶你分門别類地梳理下這些操作,并且找出阻塞式操作。

Redis執行個體有哪些阻塞點?

Redis執行個體在運作時,要和許多對象進行互動,這些不同的互動就會涉及不同的操作,下面我們來看看和Redis執行個體互動的對象,以及互動時會發生的操作。

  • 用戶端:網絡IO,鍵值對增删改查操作,資料庫操作;
  • 磁盤:生成RDB快照,記錄AOF日志,AOF日志重寫;
  • 主從節點:主庫生成、傳輸RDB檔案,從庫接收RDB檔案、清空資料庫、加載RDB檔案;
  • 切片叢集執行個體:向其他執行個體傳輸哈希槽資訊,資料遷移。

為了幫助你了解,我再畫一張圖來展示下這4類互動對象和具體的操作之間的關系。

異步機制:如何避免單線程模型的阻塞?

接下來,我們來逐個分析下在這些互動對象中,有哪些操作會引起阻塞。

1.和用戶端互動時的阻塞點

網絡IO有時候會比較慢,但是Redis使用了IO多路複用機制,避免了主線程一直處在等待網絡連接配接或請求到來的狀态,是以,網絡IO不是導緻Redis阻塞的因素。

鍵值對的增删改查操作是Redis和用戶端互動的主要部分,也是Redis主線程執行的主要任務。是以,複雜度高的增删改查操作肯定會阻塞Redis。

那麼,怎麼判斷操作複雜度是不是高呢?這裡有一個最基本的标準,就是看操作的複雜度是否為O(N)。

Redis中涉及集合的操作複雜度通常為O(N),我們要在使用時重視起來。例如集合元素全量查詢操作HGETALL、SMEMBERS,以及集合的聚合統計操作,例如求交、并和差集。這些操作可以作為Redis的第一個阻塞點:集合全量查詢和聚合操作。

除此之外,集合自身的删除操作同樣也有潛在的阻塞風險。你可能會認為,删除操作很簡單,直接把資料删除就好了,為什麼還會阻塞主線程呢?

其實,删除操作的本質是要釋放鍵值對占用的記憶體空間。你可不要小瞧記憶體的釋放過程。釋放記憶體隻是第一步,為了更加高效地管理記憶體空間,在應用程式釋放記憶體時,作業系統需要把釋放掉的記憶體塊插入一個空閑記憶體塊的連結清單,以便後續進行管理和再配置設定。這個過程本身需要一定時間,而且會阻塞目前釋放記憶體的應用程式,是以,如果一下子釋放了大量記憶體,空閑記憶體塊連結清單操作時間就會增加,相應地就會造成Redis主線程的阻塞。

那麼,什麼時候會釋放大量記憶體呢?其實就是在删除大量鍵值對資料的時候,最典型的就是删除包含了大量元素的集合,也稱為bigkey删除。為了讓你對bigkey的删除性能有一個直覺的印象,我測試了不同元素數量的集合在進行删除操作時所消耗的時間,如下表所示:

異步機制:如何避免單線程模型的阻塞?

從這張表裡,我們可以得出三個結論:

  1. 當元素數量從10萬增加到100萬時,4大集合類型的删除時間的增長幅度從5倍上升到了近20倍;
  2. 集合元素越大,删除所花費的時間就越長;
  3. 當删除有100萬個元素的集合時,最大的删除時間絕對值已經達到了1.98s(Hash類型)。Redis的響應時間一般在微秒級别,是以,一個操作達到了近2s,不可避免地會阻塞主線程。

經過剛剛的分析,很顯然,bigkey删除操作就是Redis的第二個阻塞點。删除操作對Redis執行個體性能的負面影響很大,而且在實際業務開發時容易被忽略,是以一定要重視它。

既然頻繁删除鍵值對都是潛在的阻塞點了,那麼,在Redis的資料庫級别操作中,清空資料庫(例如FLUSHDB和FLUSHALL操作)必然也是一個潛在的阻塞風險,因為它涉及到删除和釋放所有的鍵值對。是以,這就是Redis的第三個阻塞點:清空資料庫。

2.和磁盤互動時的阻塞點

我之是以把Redis與磁盤的互動單獨列為一類,主要是因為磁盤IO一般都是比較費時費力的,需要重點關注。

幸運的是,Redis開發者早已認識到磁盤IO會帶來阻塞,是以就把Redis進一步設計為采用子程序的方式生成RDB快照檔案,以及執行AOF日志重寫操作。這樣一來,這兩個操作由子程序負責執行,慢速的磁盤IO就不會阻塞主線程了。

但是,Redis直接記錄AOF日志時,會根據不同的寫回政策對資料做落盤儲存。一個同步寫磁盤的操作的耗時大約是1~2ms,如果有大量的寫操作需要記錄在AOF日志中,并同步寫回的話,就會阻塞主線程了。這就得到了Redis的第四個阻塞點了:AOF日志同步寫。

3.主從節點互動時的阻塞點

在主從叢集中,主庫需要生成RDB檔案,并傳輸給從庫。主庫在複制的過程中,建立和傳輸RDB檔案都是由子程序來完成的,不會阻塞主線程。但是,對于從庫來說,它在接收了RDB檔案後,需要使用FLUSHDB指令清空目前資料庫,這就正好撞上了剛才我們分析的第三個阻塞點。

此外,從庫在清空目前資料庫後,還需要把RDB檔案加載到記憶體,這個過程的快慢和RDB檔案的大小密切相關,RDB檔案越大,加載過程越慢,是以,加載RDB檔案就成為了Redis的第五個阻塞點。

4.切片叢集執行個體互動時的阻塞點

最後,當我們部署Redis切片叢集時,每個Redis執行個體上配置設定的哈希槽資訊需要在不同執行個體間進行傳遞,同時,當需要進行負載均衡或者有執行個體增删時,資料會在不同的執行個體間進行遷移。不過,哈希槽的資訊量不大,而資料遷移是漸進式執行的,是以,一般來說,這兩類操作對Redis主線程的阻塞風險不大。

不過,如果你使用了Redis Cluster方案,而且同時正好遷移的是bigkey的話,就會造成主線程的阻塞,因為Redis Cluster使用了同步遷移。我将在第33講中向你介紹不同切片叢集方案對資料遷移造成的阻塞的解決方法,這裡你隻需要知道,當沒有bigkey時,切片叢集的各執行個體在進行互動時不會阻塞主線程,就可以了。

好了,你現在已經了解了Redis的各種關鍵操作,以及其中的阻塞式操作,我們來總結下剛剛找到的五個阻塞點:

  • 集合全量查詢和聚合操作;
  • bigkey删除;
  • 清空資料庫;
  • AOF日志同步寫;
  • 從庫加載RDB檔案。

如果在主線程中執行這些操作,必然會導緻主線程長時間無法服務其他請求。為了避免阻塞式操作,Redis提供了異步線程機制。所謂的異步線程機制,就是指,Redis會啟動一些子線程,然後把一些任務交給這些子線程,讓它們在背景完成,而不再由主線程來執行這些任務。使用異步線程機制執行操作,可以避免阻塞主線程。

不過,這個時候,問題來了:這五大阻塞式操作都可以被異步執行嗎?

哪些阻塞點可以異步執行?

在分析阻塞式操作的異步執行的可行性之前,我們先來了解下異步執行對操作的要求。

如果一個操作能被異步執行,就意味着,它并不是Redis主線程的關鍵路徑上的操作。我再解釋下關鍵路徑上的操作是啥。這就是說,用戶端把請求發送給Redis後,等着Redis傳回資料結果的操作。

這麼說可能有點抽象,我畫一張圖檔來解釋下。

異步機制:如何避免單線程模型的阻塞?

主線程接收到操作1後,因為操作1并不用給用戶端傳回具體的資料,是以,主線程可以把它交給背景子線程來完成,同時隻要給用戶端傳回一個“OK”結果就行。在子線程執行操作1的時候,用戶端又向Redis執行個體發送了操作2,而此時,用戶端是需要使用操作2傳回的資料結果的,如果操作2不傳回結果,那麼,用戶端将一直處于等待狀态。

在這個例子中,操作1就不算關鍵路徑上的操作,因為它不用給用戶端傳回具體資料,是以可以由背景子線程異步執行。而操作2需要把結果傳回給用戶端,它就是關鍵路徑上的操作,是以主線程必須立即把這個操作執行完。

對于Redis來說,讀操作是典型的關鍵路徑操作,因為用戶端發送了讀操作之後,就會等待讀取的資料傳回,以便進行後續的資料處理。而Redis的第一個阻塞點“集合全量查詢和聚合操作”都涉及到了讀操作,是以,它們是不能進行異步操作了。

我們再來看看删除操作。删除操作并不需要給用戶端傳回具體的資料結果,是以不算是關鍵路徑操作。而我們剛才總結的第二個阻塞點“bigkey删除”,和第三個阻塞點“清空資料庫”,都是對資料做删除,并不在關鍵路徑上。是以,我們可以使用背景子線程來異步執行删除操作。

對于第四個阻塞點“AOF日志同步寫”來說,為了保證資料可靠性,Redis執行個體需要保證AOF日志中的操作記錄已經落盤,這個操作雖然需要執行個體等待,但它并不會傳回具體的資料結果給執行個體。是以,我們也可以啟動一個子線程來執行AOF日志的同步寫,而不用讓主線程等待AOF日志的寫完成。

最後,我們再來看下“從庫加載RDB檔案”這個阻塞點。從庫要想對用戶端提供資料存取服務,就必須把RDB檔案加載完成。是以,這個操作也屬于關鍵路徑上的操作,我們必須讓從庫的主線程來執行。

對于Redis的五大阻塞點來說,除了“集合全量查詢和聚合操作”和“從庫加載RDB檔案”,其他三個阻塞點涉及的操作都不在關鍵路徑上,是以,我們可以使用Redis的異步子線程機制來實作bigkey删除,清空資料庫,以及AOF日志同步寫。

那麼,Redis實作的異步子線程機制具體是怎麼執行呢?

異步的子線程機制

Redis主線程啟動後,會使用作業系統提供的pthread_create函數建立3個子線程,分别由它們負責AOF日志寫操作、鍵值對删除以及檔案關閉的異步執行。

主線程通過一個連結清單形式的任務隊列和子線程進行互動。當收到鍵值對删除和清空資料庫的操作時,主線程會把這個操作封裝成一個任務,放入到任務隊列中,然後給用戶端傳回一個完成資訊,表明删除已經完成。

但實際上,這個時候删除還沒有執行,等到背景子線程從任務隊列中讀取任務後,才開始實際删除鍵值對,并釋放相應的記憶體空間。是以,我們把這種異步删除也稱為惰性删除(lazy free)。此時,删除或清空操作不會阻塞主線程,這就避免了對主線程的性能影響。

和惰性删除類似,當AOF日志配置成everysec選項後,主線程會把AOF寫日志操作封裝成一個任務,也放到任務隊列中。背景子線程讀取任務後,開始自行寫入AOF日志,這樣主線程就不用一直等待AOF日志寫完了。

下面這張圖展示了Redis中的異步子線程執行機制,你可以再看下,加深印象。

異步機制:如何避免單線程模型的阻塞?

這裡有個地方需要你注意一下,異步的鍵值對删除和資料庫清空操作是Redis 4.0後提供的功能,Redis也提供了新的指令來執行這兩個操作。

  • 鍵值對删除:當你的集合類型中有大量元素(例如有百萬級别或千萬級别元素)需要删除時,我建議你使用UNLINK指令。
  • 清空資料庫:可以在FLUSHDB和FLUSHALL指令後加上ASYNC選項,這樣就可以讓背景子線程異步地清空資料庫,如下所示:
FLUSHDB ASYNC
FLUSHALL AYSNC      

小結

這節課,我們學習了Redis執行個體運作時的4大類互動對象:用戶端、磁盤、主從庫執行個體、切片叢集執行個體。基于這4大類互動對象,我們梳理了會導緻Redis性能受損的5大阻塞點,包括集合全量查詢和聚合操作、bigkey删除、清空資料庫、AOF日志同步寫,以及從庫加載RDB檔案。

在這5大阻塞點中,bigkey删除、清空資料庫、AOF日志同步寫不屬于關鍵路徑操作,可以使用異步子線程機制來完成。Redis在運作時會建立三個子線程,主線程會通過一個任務隊列和三個子線程進行互動。子線程會根據任務的具體類型,來執行相應的異步操作。

  • 集合全量查詢和聚合操作:可以使用SCAN指令,分批讀取資料,再在用戶端進行聚合計算;
  • 從庫加載RDB檔案:把主庫的資料量大小控制在2~4GB左右,以保證RDB檔案能以較快的速度加載。