1. 程式人生 > 資訊 >我們是如何計算出宇宙中有多少個黑洞的

我們是如何計算出宇宙中有多少個黑洞的

1.鍵值資料庫的基本架構

不同鍵值資料庫支援的key型別一般差異不大,而value型別則有較大差別。我們在對鍵值資料庫進行選型時,一個重要的考慮因素是它支援的value型別。例如,Memcached支援的value型別僅為String型別,而Redis支援的value型別包括了String、雜湊表、列表、集合等。Redis能夠在實際業務場景中得到廣泛的應用,就是得益於支援多樣化型別的value。

在實際的業務場景中,我們經常會碰到這種情況:查詢一個使用者在一段時間內的訪問記錄。這種操作在鍵值資料庫中屬於SCAN操作,即根據一段key的範圍返回相應的value值。因此,PUT/GET/DELETE/SCAN是一個鍵值資料庫的基本操作集合。

大體來說,一個鍵值資料庫包括了訪問框架、索引模組、操作模組和儲存模組四部分。

訪問模式通常有兩種:一種是通過函式庫呼叫的方式供外部應用使用,比如libsimplekv.so,就是以動態連結庫的形式連結到我們自己的程式中,提供鍵值儲存功能;另一種是通過網路框架以Socket通訊的形式對外提供鍵值對操作,這種形式可以提供廣泛的鍵值儲存服務。

實際的鍵值資料庫也基本採用上述兩種方式,例如,RocksDB以動態連結庫的形式使用,而Memcached和Redis則是通過網路框架訪問。

鍵值資料庫網路框架接收到網路包,並按照相應的協議進行解析之後,就可以知道,客戶端想寫入一個鍵值對,並開始實際的寫入流程。此時,我們會遇到一個系統設計上的問題,簡單來說,就是網路連線的處理、網路請求的解析,以及資料存取的處理,是用一個執行緒、多個執行緒,還是多個程序來互動處理呢?該如何進行設計和取捨呢?我們一般把這個問題稱為I/O模型設計。不同的I/O模型對鍵值資料庫的效能和可擴充套件性會有不同的影響。

定位鍵值對的位置

當SimpleKV解析了客戶端發來的請求,知道了要進行的鍵值對操作,此時,SimpleKV需要查詢所要操作的鍵值對是否存在,這依賴於鍵值資料庫的索引模組。索引的作用是讓鍵值資料庫根據key找到相應value的儲存位置,進而執行操作。

索引的型別有很多,常見的有雜湊表、B+樹、字典樹等。不同的索引結構在效能、空間消耗、併發控制等方面具有不同的特徵。如果你看過其他鍵值資料庫,就會發現,不同鍵值資料庫採用的索引並不相同,例如,Memcached和Redis採用雜湊表作為key-value索引,而RocksDB則採用跳錶作為記憶體中key-value的索引。

一般而言,記憶體鍵值資料庫(例如Redis)採用雜湊表作為索引,很大一部分原因在於,其鍵值資料基本都是儲存在記憶體中的,而記憶體的高效能隨機訪問特性可以很好地與雜湊表O(1)的操作複雜度相匹配。

Redis採用一些常見的高效索引結構作為某些value型別的底層資料結構,這一技術路線為Redis實現高效能訪問提供了良好的支撐。

SimpleKV的儲存模組

SimpleKV採用了常用的記憶體分配器glibc的malloc和free,因此,SimpleKV並不需要特別考慮記憶體空間的管理問題。但是,鍵值資料庫的鍵值對通常大小不一,glibc的分配器在處理隨機的大小記憶體塊分配時,表現並不好。一旦儲存的鍵值對資料規模過大,就可能會造成較嚴重的記憶體碎片問題。

因此,分配器是鍵值資料庫中的一個關鍵因素。對於以記憶體儲存為主的Redis而言,這點尤為重要。Redis的記憶體分配器提供了多種選擇,分配效率也不一樣。

從SimpleKV演進到Redis,有以下幾個重要變化:

  1. Redis主要通過網路框架進行訪問,而不再是動態庫了,這也使得Redis可以作為一個基礎性的網路服務進行訪問,擴大了Redis的應用範圍。
  2. Redis資料模型中的value型別很豐富,因此也帶來了更多的操作介面,例如面向列表的LPUSH/LPOP,面向集合的SADD/SREM等。在下節課,我將和你聊聊這些value模型背後的資料結構和操作效率,以及它們對Redis效能的影響。
  3. Redis的持久化模組能支援兩種方式:日誌(AOF)和快照(RDB),這兩種持久化方式具有不同的優劣勢,影響到Redis的訪問效能和可靠性。
  4. SimpleKV是個簡單的單機鍵值資料庫,但是,Redis支援高可靠叢集和高可擴充套件叢集,因此,Redis中包含了相應的叢集功能支撐模組。

2.Redis資料結構

簡單來說,底層資料結構一共有6種,分別是簡單動態字串、雙向連結串列、壓縮列表、雜湊表、跳錶和整數陣列。它們和資料型別的對應關係如下圖所示:

可以看到,String型別的底層實現只有一種資料結構,也就是簡單動態字串。而List、Hash、Set和Sorted Set這四種資料型別,都有兩種底層實現結構。通常情況下,我們會把這四種類型稱為集合型別,它們的特點是一個鍵對應了一個集合的資料

雜湊表

一個雜湊表,其實就是一個數組,陣列的每個元素稱為一個雜湊桶。所以,我們常說,一個雜湊表是由多個雜湊桶組成的,每個雜湊桶中儲存了鍵值對資料。

其實,雜湊桶中的元素儲存的並不是值本身,而是指向具體值的指標。這也就是說,不管值是String,還是集合型別,雜湊桶中的元素都是指向它們的指標。

雜湊桶中的entry元素中儲存了key和value指標,分別指向了實際的鍵和值,這樣一來,即使值是一個集合,也可以通過*value指標被查詢到。

因為這個雜湊表儲存了所有的鍵值對,所以,我也把它稱為全域性雜湊表。雜湊表的最大好處很明顯,就是讓我們可以用O(1)的時間複雜度來快速查詢到鍵值對——我們只需要計算鍵的雜湊值,就可以知道它所對應的雜湊桶位置,然後就可以訪問相應的entry元素。

如果你只是瞭解了雜湊表的O(1)複雜度和快速查詢特性,那麼,當你往Redis中寫入大量資料後,就可能發現操作有時候會突然變慢了。這其實是因為你忽略了一個潛在的風險點,那就是雜湊表的衝突問題和rehash可能帶來的操作阻塞

雜湊衝突:當你往雜湊表中寫入更多資料時,雜湊衝突是不可避免的問題。這裡的雜湊衝突,也就是指,兩個key的雜湊值和雜湊桶計算對應關係時,正好落在了同一個雜湊桶中。

Redis解決雜湊衝突的方式,就是鏈式雜湊。鏈式雜湊也很容易理解,就是指同一個雜湊桶中的多個元素用一個連結串列來儲存,它們之間依次用指標連線。

雜湊衝突鏈上的元素只能通過指標逐一查詢再操作。如果雜湊表裡寫入的資料越來越多,雜湊衝突可能也會越來越多,這就會導致某些雜湊衝突鏈過長,進而導致這個鏈上的元素查詢耗時長,效率降低。

所以,Redis會對雜湊表做rehash操作。rehash也就是增加現有的雜湊桶數量,讓逐漸增多的entry元素能在更多的桶之間分散儲存,減少單個桶中的元素數量,從而減少單個桶中的衝突。

其實,為了使rehash操作更高效,Redis預設使用了兩個全域性雜湊表:雜湊表1和雜湊表2。一開始,當你剛插入資料時,預設使用雜湊表1,此時的雜湊表2並沒有被分配空間。隨著資料逐步增多,Redis開始執行rehash,這個過程分為三步:

  1. 給雜湊表2分配更大的空間,例如是當前雜湊表1大小的兩倍;
  2. 把雜湊表1中的資料重新對映並拷貝到雜湊表2中;
  3. 釋放雜湊表1的空間。

到此,我們就可以從雜湊表1切換到雜湊表2,用增大的雜湊表2儲存更多資料,而原來的雜湊表1留作下一次rehash擴容備用。

這個過程看似簡單,但是第二步涉及大量的資料拷貝,如果一次性把雜湊表1中的資料都遷移完,會造成Redis執行緒阻塞,無法服務其他請求。此時,Redis就無法快速訪問資料了。

漸進式rehash

簡單來說就是在第二步拷貝資料時,Redis仍然正常處理客戶端請求,每處理一個請求時,從雜湊表1中的第一個索引位置開始,順帶著將這個索引位置上的所有entries拷貝到雜湊表2中;等處理下一個請求時,再順帶拷貝雜湊表1中的下一個索引位置的entries。

這樣就巧妙地把一次性大量拷貝的開銷,分攤到了多次處理請求的過程中,避免了耗時操作,保證了資料的快速訪問。

集合資料操作效率

對於String型別來說,找到雜湊桶就能直接增刪改查了,所以,雜湊表的O(1)操作複雜度也就是它的複雜度了。

一個集合型別的值,第一步是通過全域性雜湊表找到對應的雜湊桶位置,第二步是在集合中再增刪改查。

集合的操作效率,首先,與集合的底層資料結構有關。例如,使用雜湊表實現的集合,要比使用連結串列實現的集合訪問效率更高。其次,操作效率和這些操作本身的執行特點有關,比如讀寫一個元素的操作要比讀寫所有元素的效率高。

集合型別的底層資料結構主要有5種:整數陣列、雙向連結串列、雜湊表、壓縮列表和跳錶。

整數陣列和雙向連結串列也很常見,它們的操作特徵都是順序讀寫,也就是通過陣列下標或者連結串列的指標逐個元素訪問,操作複雜度基本是O(N),操作效率比較低。

壓縮列表實際上類似於一個數組,陣列中的每一個元素都對應儲存一個數據。和陣列不同的是,壓縮列表在表頭有三個欄位zlbytes、zltail和zllen,分別表示列表長度、列表尾的偏移量和列表中的entry個數;壓縮列表在表尾還有一個zlend,表示列表結束。

在壓縮列表中,如果我們要查詢定位第一個元素和最後一個元素,可以通過表頭三個欄位的長度直接定位,複雜度是O(1)。而查詢其他元素時,就沒有這麼高效了,只能逐個查詢,此時的複雜度就是O(N)了。

跳錶

有序連結串列只能逐一查詢元素,導致操作起來非常緩慢,於是就出現了跳錶。具體來說,跳錶在連結串列的基礎上,增加了多級索引,通過索引位置的幾個跳轉,實現資料的快速定位,如下圖所示:

為了提高查詢速度,我們來增加一級索引:從第一個元素開始,每兩個元素選一個出來作為索引。這些索引再通過指標指向原始的連結串列。例如,從前兩個元素中抽取元素1作為一級索引,從第三、四個元素中抽取元素11作為一級索引。此時,我們只需要4次查詢就能定位到元素33了。

如果我們還想再快,可以再增加二級索引:從一級索引中,再抽取部分元素作為二級索引。例如,從一級索引中抽取1、27、100作為二級索引,二級索引指向一級索引。這樣,我們只需要3次查詢,就能定位到元素33了。

可以看到,這個查詢過程就是在多級索引上跳來跳去,最後定位到元素。這也正好符合“跳”表的叫法。當資料量很大時,跳錶的查詢複雜度就是O(logN)。

按照查詢的時間複雜度給這些資料結構分類:

不同操作的複雜度

第一,單元素操作,是指每一種集合型別對單個數據實現的增刪改查操作。例如,Hash型別的HGET、HSET和HDEL,Set型別的SADD、SREM、SRANDMEMBER等。這些操作的複雜度由集合採用的資料結構決定,例如,HGET、HSET和HDEL是對雜湊表做操作,所以它們的複雜度都是O(1);Set型別用雜湊表作為底層資料結構時,它的SADD、SREM、SRANDMEMBER複雜度也是O(1)。

第二,範圍操作,是指集合型別中的遍歷操作,可以返回集合中的所有資料,比如Hash型別的HGETALL和Set型別的SMEMBERS,或者返回一個範圍內的部分資料,比如List型別的LRANGE和ZSet型別的ZRANGE。這類操作的複雜度一般是O(N),比較耗時,我們應該儘量避免。

不過,Redis從2.8版本開始提供了SCAN系列操作(包括HSCAN,SSCAN和ZSCAN),這類操作實現了漸進式遍歷,每次只返回有限數量的資料。這樣一來,相比於HGETALL、SMEMBERS這類操作來說,就避免了一次性返回所有元素而導致的Redis阻塞。

第三,統計操作,是指集合型別對集合中所有元素個數的記錄,例如LLEN和SCARD。這類操作複雜度只有O(1),這是因為當集合型別採用壓縮列表、雙向連結串列、整數陣列這些資料結構時,這些結構中專門記錄了元素的個數統計,因此可以高效地完成相關操作。

第四,例外情況,是指某些資料結構的特殊記錄,例如壓縮列表和雙向連結串列都會記錄表頭和表尾的偏移量。這樣一來,對於List型別的LPOP、RPOP、LPUSH、RPUSH這四個操作來說,它們是在列表的頭尾增刪元素,這就可以通過偏移量直接定位,所以它們的複雜度也只有O(1),可以實現快速操作。

Redis之所以能快速操作鍵值對,一方面是因為O(1)複雜度的雜湊表被廣泛使用,包括String、Hash和Set,它們的操作複雜度基本由雜湊表決定,另一方面,Sorted Set也採用了O(logN)複雜度的跳錶。不過,集合型別的範圍操作,因為要遍歷底層資料結構,複雜度通常是O(N)。這裡,我的建議是:用其他命令來替代,例如可以用SCAN來代替,避免在Redis內部產生費時的全集合遍歷操作。

當然,我們不能忘了複雜度較高的List型別,它的兩種底層實現結構:雙向連結串列和壓縮列表的操作複雜度都是O(N)。因此,我的建議是:因地制宜地使用List型別。例如,既然它的POP/PUSH效率很高,那麼就將它主要用於FIFO佇列場景,而不是作為一個可以隨機讀寫的集合。

Redis的List底層使用壓縮列表本質上是將所有元素緊挨著儲存,所以分配的是一塊連續的記憶體空間,雖然資料結構本身沒有時間複雜度的優勢,但是這樣節省空間而且也能避免一些記憶體碎片。


3.高效能IO模型:單執行緒Redis

Redis的網路IO和鍵值對讀寫是由一個執行緒來完成的,這也是Redis對外提供鍵值儲存服務的主要流程。但Redis的其他功能,比如持久化、非同步刪除、叢集資料同步等,其實是由額外的執行緒執行的。

Redis採用單執行緒的原因

多執行緒程式設計模式面臨的共享資源的併發訪問控制問題。

一個關鍵的瓶頸在於,系統中通常會存在被多執行緒同時訪問的共享資源,比如一個共享的資料結構。當有多個執行緒要修改這個共享資源時,為了保證共享資源的正確性,就需要有額外的機制進行保證,而這個額外的機制,就會帶來額外的開銷。

併發訪問控制一直是多執行緒開發中的一個難點問題,如果沒有精細的設計,比如說,只是簡單地採用一個粗粒度互斥鎖,就會出現不理想的結果:即使增加了執行緒,大部分執行緒也在等待獲取訪問共享資源的互斥鎖,並行變序列,系統吞吐率並沒有隨著執行緒的增加而增加。

而且,採用多執行緒開發一般會引入同步原語來保護共享資源的併發訪問,這也會降低系統程式碼的易除錯性和可維護性。為了避免這些問題,Redis直接採用了單執行緒模式。

單執行緒Redis為什麼那麼快?

一方面,Redis的大部分操作在記憶體上完成,再加上它採用了高效的資料結構,例如雜湊表和跳錶,這是它實現高效能的一個重要原因。另一方面,就是Redis採用了多路複用機制,使其在網路IO操作中能併發處理大量的客戶端請求,實現高吞吐率。

基於多路複用的高效能I/O模型

Linux中的IO多路複用機制是指一個執行緒處理多個IO流,就是我們經常聽到的select/epoll機制。簡單來說,在Redis只執行單執行緒的情況下,該機制允許核心中,同時存在多個監聽套接字和已連線套接字。核心會一直監聽這些套接字上的連線請求或資料請求。一旦有請求到達,就會交給Redis執行緒處理,這就實現了一個Redis執行緒處理多個IO流的效果。

下圖就是基於多路複用的Redis IO模型。圖中的多個FD就是剛才所說的多個套接字。Redis網路框架呼叫epoll機制,讓核心監聽這些套接字。此時,Redis執行緒不會阻塞在某一個特定的監聽或已連線套接字上,也就是說,不會阻塞在某一個特定的客戶端請求處理上。正因為此,Redis可以同時和多個客戶端連線並處理請求,從而提升併發性。

為了在請求到達時能通知到Redis執行緒,select/epoll提供了基於事件的回撥機制,即針對不同事件的發生,呼叫相應的處理函式。

select/epoll一旦監測到FD上有請求到達時,就會觸發相應的事件。這些事件會被放進一個事件佇列,Redis單執行緒對該事件佇列不斷進行處理。這樣一來,Redis無需一直輪詢是否有請求實際發生,這就可以避免造成CPU資源浪費。同時,Redis在對事件佇列中的事件進行處理時,會呼叫相應的處理函式,這就實現了基於事件的回撥。因為Redis一直在對事件佇列進行處理,所以能及時響應客戶端請求,提升Redis的響應效能。

即使你的應用場景中部署了不同的作業系統,多路複用機制也是適用的。因為這個機制的實現有很多種,既有基於Linux系統下的select和epoll實現,也有基於FreeBSD的kqueue實現,以及基於Solaris的evport實現,這樣,你可以根據Redis實際執行的作業系統,選擇相應的多路複用實現。

總結:Redis單執行緒是指它對網路IO和資料讀寫的操作採用了一個執行緒,而採用單執行緒的一個核心原因是避免多執行緒開發的併發控制問題。單執行緒的Redis也能獲得高效能,跟多路複用的IO模型密切相關,因為這避免了accept()和send()/recv()潛在的網路IO操作阻塞點。

Redis單執行緒處理IO請求效能瓶頸主要包括2個方面:

1.任意一個請求在server中一旦發生耗時,都會影響整個server的效能,也就是說後面的請求都要等前面這個耗時請求處理完成,自己才能被處理到。耗時的操作包括以下幾種:

a. 操作bigkey:寫入一個bigkey在分配記憶體時需要消耗更多的時間,同樣,刪除bigkey釋放記憶體同樣會產生耗時;

b. 使用複雜度過高的命令:例如SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大,例如lrange key 0 -1一次查詢全量資料;

c. 大量key集中過期:Redis的過期機制也是在主執行緒中執行的,大量key集中過期會導致處理一個請求時,耗時都在刪除過期key,耗時變長;

d. 淘汰策略:淘汰策略也是在主執行緒執行的,當記憶體超過Redis記憶體上限後,每次寫入都需要淘汰一些key,也會造成耗時變長;

e. AOF刷盤開啟always機制:每次寫入都需要把這個操作刷到磁碟,寫磁碟的速度遠比寫記憶體慢,會拖慢Redis的效能;

f. 主從全量同步生成RDB:雖然採用fork子程序生成資料快照,但fork這一瞬間也是會阻塞整個執行緒的,例項越大,阻塞時間越久;

2.併發量非常大時,單執行緒讀寫客戶端IO資料存在效能瓶頸,雖然採用IO多路複用機制,但是讀寫客戶端資料依舊是同步IO,只能單執行緒依次讀取客戶端的資料,無法利用到CPU多核。

針對問題1,一方面需要業務人員去規避,一方面Redis在4.0推出了lazy-free機制,把bigkey釋放記憶體的耗時操作放在了非同步執行緒中執行,降低對主執行緒的影響。

針對問題2,Redis在6.0推出了多執行緒,可以在高併發場景下利用CPU多核多執行緒讀寫客戶端資料,進一步提升server效能,當然,只是針對客戶端的讀寫是並行的,每個命令的真正操作依舊是單執行緒的。


4.AOF日誌

說到日誌,我們比較熟悉的是資料庫的寫前日誌(Write Ahead Log, WAL),也就是說,在實際寫資料前,先把修改的資料記到日誌檔案中,以便故障時進行恢復。不過,AOF日誌正好相反,它是寫後日志,“寫後”的意思是Redis是先執行命令,把資料寫入記憶體,然後才記錄日誌。

傳統資料庫的日誌,例如redo log(重做日誌),記錄的是修改後的資料,而AOF裡記錄的是Redis收到的每一條命令,這些命令是以文字形式儲存的。

寫後日志這種方式,就是先讓系統執行命令,只有命令能執行成功,才會被記錄到日誌中,否則,系統就會直接向客戶端報錯。所以,Redis使用寫後日志這一方式的一大好處是,可以避免出現記錄錯誤命令的情況。

AOF也有兩個潛在的風險:

首先,如果剛執行完一個命令,還沒有來得及記日誌就宕機了,那麼這個命令和相應的資料就有丟失的風險。如果此時Redis是用作快取,還可以從後端資料庫重新讀入資料進行恢復,但是,如果Redis是直接用作資料庫的話,此時,因為命令沒有記入日誌,所以就無法用日誌進行恢復了。

其次,AOF雖然避免了對當前命令的阻塞,但可能會給下一個操作帶來阻塞風險。這是因為,AOF日誌也是在主執行緒中執行的,如果在把日誌檔案寫入磁碟時,磁碟寫壓力大,就會導致寫盤很慢,進而導致後續的操作也無法執行了。

三種寫回策略

其實,對於這個問題,AOF機制給我們提供了三個選擇,也就是AOF配置項appendfsync的三個可選值。

  1. Always,同步寫回:每個寫命令執行完,立馬同步地將日誌寫回磁碟;
  2. Everysec,每秒寫回:每個寫命令執行完,只是先把日誌寫到AOF檔案的記憶體緩衝區,每隔一秒把緩衝區中的內容寫入磁碟;
  3. No,作業系統控制的寫回:每個寫命令執行完,只是先把日誌寫到AOF檔案的記憶體緩衝區,由作業系統決定何時將緩衝區內容寫回磁碟。

想要獲得高效能,就選擇No策略;如果想要得到高可靠性保證,就選擇Always策略;如果允許資料有一點丟失,又希望效能別受太大影響的話,那麼就選擇Everysec策略。

但是,按照系統的效能需求選定了寫回策略,並不是“高枕無憂”了。畢竟,AOF是以檔案的形式在記錄接收到的所有寫命令。隨著接收的寫命令越來越多,AOF檔案會越來越大。這也就意味著,我們一定要小心AOF檔案過大帶來的效能問題。

這裡的“效能問題”,主要在於以下三個方面:一是,檔案系統本身對檔案大小有限制,無法儲存過大的檔案;二是,如果檔案太大,之後再往裡面追加命令記錄的話,效率也會變低;三是,如果發生宕機,AOF中記錄的命令要一個個被重新執行,用於故障恢復,如果日誌檔案太大,整個恢復過程就會非常緩慢,這就會影響到Redis的正常使用。

所以,我們就要採取一定的控制手段,這個時候,AOF重寫機制就登場了。

AOF重寫機制

簡單來說,AOF重寫機制就是在重寫時,Redis根據資料庫的現狀建立一個新的AOF檔案,也就是說,讀取資料庫中的所有鍵值對,然後對每一個鍵值對用一條命令記錄它的寫入。比如說,當讀取了鍵值對“testkey”: “testvalue”之後,重寫機制會記錄set testkey testvalue這條命令。這樣,當需要恢復時,可以重新執行該命令,實現“testkey”: “testvalue”的寫入。

為什麼重寫機制可以把日誌檔案變小呢? 實際上,重寫機制具有“多變一”功能。所謂的“多變一”,也就是說,舊日誌檔案中的多條命令,在重寫後的新日誌中變成了一條命令。

我們知道,AOF檔案是以追加的方式,逐一記錄接收到的寫命令的。當一個鍵值對被多條寫命令反覆修改時,AOF檔案會記錄相應的多條命令。但是,在重寫的時候,是根據這個鍵值對當前的最新狀態,為它生成對應的寫入命令。這樣一來,一個鍵值對在重寫日誌中只用一條命令就行了,而且,在日誌恢復時,只用執行這條命令,就可以直接完成這個鍵值對的寫入了。

AOF重寫會阻塞嗎?

和AOF日誌由主執行緒寫回不同,重寫過程是由後臺執行緒bgrewriteaof來完成的,這也是為了避免阻塞主執行緒,導致資料庫效能下降。

我把重寫的過程總結為“一個拷貝,兩處日誌”。

“一個拷貝”就是指,每次執行重寫時,主執行緒fork出後臺的bgrewriteaof子程序。此時,fork會把主執行緒的記憶體拷貝一份給bgrewriteaof子程序,這裡面就包含了資料庫的最新資料。然後,bgrewriteaof子程序就可以在不影響主執行緒的情況下,逐一把拷貝的資料寫成操作,記入重寫日誌。

“兩處日誌”又是什麼呢?

因為主執行緒未阻塞,仍然可以處理新來的操作。此時,如果有寫操作,第一處日誌就是指正在使用的AOF日誌,Redis會把這個操作寫到它的緩衝區。這樣一來,即使宕機了,這個AOF日誌的操作仍然是齊全的,可以用於恢復。

而第二處日誌,就是指新的AOF重寫日誌。這個操作也會被寫到重寫日誌的緩衝區。這樣,重寫日誌也不會丟失最新的操作。等到拷貝資料的所有操作記錄重寫完成後,重寫日誌記錄的這些最新操作也會寫入新的AOF檔案,以保證資料庫最新狀態的記錄。此時,我們就可以用新的AOF檔案替代舊檔案了。

總結來說,每次AOF重寫時,Redis會先執行一個記憶體拷貝,用於重寫;然後,使用兩個日誌保證在重寫過程中,新寫入的資料不會丟失。而且,因為Redis採用額外的執行緒進行資料重寫,所以,這個過程並不會阻塞主執行緒。

AOF工作原理:
1、Redis 執行 fork() ,現在同時擁有父程序和子程序。
2、子程序開始將新 AOF 檔案的內容寫入到臨時檔案。
3、對於所有新執行的寫入命令,父程序一邊將它們累積到一個記憶體快取中,一邊將這些改動追加到現有 AOF 檔案的末尾,這樣樣即使在重寫的中途發生停機,現有的 AOF 檔案也還是安全的。
4、當子程序完成重寫工作時,它給父程序傳送一個訊號,父程序在接收到訊號之後,將記憶體快取中的所有資料追加到新 AOF 檔案的末尾。
5、Redis 原子地用新檔案替換舊檔案,之後所有命令都會直接追加到新 AOF 檔案的末尾。


5.RDB記憶體快照

所謂記憶體快照,就是指記憶體中的資料在某一個時刻的狀態記錄。這就類似於照片,當你給朋友拍照時,一張照片就能把朋友一瞬間的形象完全記下來。

對Redis來說,它實現類似照片記錄效果的方式,就是把某一時刻的狀態以檔案的形式寫到磁碟上,也就是快照。這樣一來,即使宕機,快照檔案也不會丟失,資料的可靠性也就得到了保證。這個快照檔案就稱為RDB檔案,其中,RDB就是Redis DataBase的縮寫。

和AOF相比,RDB記錄的是某一時刻的資料,並不是操作,所以,在做資料恢復時,我們可以直接把RDB檔案讀入記憶體,很快地完成恢復。

Redis的資料都在記憶體中,為了提供所有資料的可靠性保證,它執行的是全量快照,也就是說,把記憶體中的所有資料都記錄到磁碟中。

Redis提供了兩個命令來生成RDB檔案,分別是save和bgsave。

save:在主執行緒中執行,會導致阻塞;

bgsave:建立一個子程序,專門用於寫入RDB檔案,避免了主執行緒的阻塞,這也是Redis RDB檔案生成的預設配置。

好了,這個時候,我們就可以通過bgsave命令來執行全量快照,這既提供了資料的可靠性保證,也避免了對Redis的效能影響。

快照時資料能修改嗎?

在給別人拍照時,一旦對方動了,那麼這張照片就拍糊了,我們就需要重拍,所以我們當然希望對方保持不動。對於記憶體快照而言,我們也不希望資料“動”。

為了快照而暫停寫操作,肯定是不能接受的。所以這個時候,Redis就會藉助作業系統提供的寫時複製技術(Copy-On-Write, COW),在執行快照的同時,正常處理寫操作。

簡單來說,bgsave子程序是由主執行緒fork生成的,可以共享主執行緒的所有記憶體資料。bgsave子程序執行後,開始讀取主執行緒的記憶體資料,並把它們寫入RDB檔案。

此時,如果主執行緒對這些資料也都是讀操作(例如圖中的鍵值對A),那麼,主執行緒和bgsave子程序相互不影響。但是,如果主執行緒要修改一塊資料(例如圖中的鍵值對C),那麼,這塊資料就會被複制一份,生成該資料的副本。然後,bgsave子程序會把這個副本資料寫入RDB檔案,而在這個過程中,主執行緒仍然可以直接修改原來的資料。

這既保證了快照的完整性,也允許主執行緒同時對資料進行修改,避免了對正常業務的影響。

到這裡,我們就解決了對“哪些資料做快照”以及“做快照時資料能否修改”這兩大問題:Redis會使用bgsave對當前記憶體中的所有資料做快照,這個操作是子程序在後臺完成的,這就允許主執行緒同時可以修改資料。

頻繁執行全量快照帶來的開銷

一方面,頻繁將全量資料寫入磁碟,會給磁碟帶來很大壓力,多個快照競爭有限的磁碟頻寬,前一個快照還沒有做完,後一個又開始做了,容易造成惡性迴圈。

另一方面,bgsave子程序需要通過fork操作從主執行緒創建出來。雖然,子程序在建立後不會再阻塞主執行緒,但是,fork這個建立過程本身會阻塞主執行緒,而且主執行緒的記憶體越大,阻塞時間越長。如果頻繁fork出bgsave子程序,這就會頻繁阻塞主執行緒了。

此時,我們可以做增量快照,所謂增量快照,就是指,做了一次全量快照後,後續的快照只對修改的資料進行快照記錄,這樣可以避免每次全量快照的開銷。

在第一次做完全量快照後,T1和T2時刻如果再做快照,我們只需要將被修改的資料寫入快照檔案就行。但是,這麼做的前提是,我們需要記住哪些資料被修改了。你可不要小瞧這個“記住”功能,它需要我們使用額外的元資料資訊去記錄哪些資料被修改了,這會帶來額外的空間開銷問題。

雖然跟AOF相比,快照的恢復速度快,但是,快照的頻率不好把握,如果頻率太低,兩次快照間一旦宕機,就可能有比較多的資料丟失。如果頻率太高,又會產生額外開銷。

Redis 4.0中提出了一個混合使用AOF日誌和記憶體快照的方法。簡單來說,記憶體快照以一定的頻率執行,在兩次快照之間,使用AOF日誌記錄這期間的所有命令操作。

這樣一來,快照不用很頻繁地執行,這就避免了頻繁fork對主執行緒的影響。而且,AOF日誌也只用記錄兩次快照間的操作,也就是說,不需要記錄所有操作了,因此,就不會出現檔案過大的情況了,也可以避免重寫開銷。

關於AOF和RDB的選擇問題的三點建議:

  1. 資料不能丟失時,記憶體快照和AOF的混合使用是一個很好的選擇;
  2. 如果允許分鐘級別的資料丟失,可以只使用RDB;
  3. 如果只用AOF,優先使用everysec的配置選項,因為它在可靠性和效能之間取了一個平衡。

6.主從庫同步實現資料一致

讀操作:主庫、從庫都可以接收;
寫操作:首先到主庫執行,然後,主庫將寫操作同步給從庫。

當我們啟動多個Redis例項的時候,它們相互之間就可以通過replicaof(Redis 5.0之前使用slaveof)命令形成主庫和從庫的關係,之後會按照三個階段完成資料的第一次同步。

第一階段是主從庫間建立連線、協商同步的過程,主要是為全量複製做準備。在這一步,從庫和主庫建立起連線,並告訴主庫即將進行同步,主庫確認回覆後,主從庫間就可以開始同步了。

具體來說,從庫給主庫傳送psync命令,表示要進行資料同步,主庫根據這個命令的引數來啟動複製。psync命令包含了主庫的runID和複製進度offset兩個引數。

runID,是每個Redis例項啟動時都會自動生成的一個隨機ID,用來唯一標記這個例項。當從庫和主庫第一次複製時,因為不知道主庫的runID,所以將runID設為“?”。
offset,此時設為-1,表示第一次複製。

主庫收到psync命令後,會用FULLRESYNC響應命令帶上兩個引數:主庫runID和主庫目前的複製進度offset,返回給從庫。從庫收到響應後,會記錄下這兩個引數。

這裡有個地方需要注意,FULLRESYNC響應表示第一次複製採用的全量複製,也就是說,主庫會把當前所有的資料都複製給從庫。
在第二階段,主庫將所有資料同步給從庫。從庫收到資料後,在本地完成資料載入。這個過程依賴於記憶體快照生成的RDB檔案。

在主庫將資料同步給從庫的過程中,主庫不會被阻塞,仍然可以正常接收請求。否則,Redis的服務就被中斷了。但是,這些請求中的寫操作並沒有記錄到剛剛生成的RDB檔案中。為了保證主從庫的資料一致性,主庫會在記憶體中用專門的replication buffer,記錄RDB檔案生成後收到的所有寫操作。

最後,也就是第三個階段,主庫會把第二階段執行過程中新收到的寫命令,再發送給從庫。具體的操作是,當主庫完成RDB檔案傳送後,就會把此時replication buffer中的修改操作發給從庫,從庫再重新執行這些操作。這樣一來,主從庫就實現同步了。

主從級聯模式分擔全量複製時的主庫壓力

一次全量複製中,對於主庫來說,需要完成兩個耗時的操作:生成RDB檔案和傳輸RDB檔案。

如果從庫數量很多,而且都要和主庫進行全量複製的話,就會導致主庫忙於fork子程序生成RDB檔案,進行資料全量同步。fork這個操作會阻塞主執行緒處理正常請求,從而導致主庫響應應用程式的請求速度變慢。此外,傳輸RDB檔案也會佔用主庫的網路頻寬,同樣會給主庫的資源使用帶來壓力。那麼,有沒有好的解決方法可以分擔主庫壓力呢?

我們可以通過“主-從-從”模式將主庫生成RDB和傳輸RDB的壓力,以級聯的方式分散到從庫上。

簡單來說,我們在部署主從叢集的時候,可以手動選擇一個從庫(比如選擇記憶體資源配置較高的從庫),用於級聯其他的從庫。然後,我們可以再選擇一些從庫(例如三分之一的從庫),在這些從庫上執行如下命令,讓它們和剛才所選的從庫,建立起主從關係。

這樣一來,這些從庫就會知道,在進行同步時,不用再和主庫進行互動了,只要和級聯的從庫進行寫操作同步就行了,這就可以減輕主庫上的壓力,如下圖所示:

基於長連線的命令傳播

一旦主從庫完成了全量複製,它們之間就會一直維護一個網路連線,主庫會通過這個連線將後續陸續收到的命令操作再同步給從庫,這個過程也稱為基於長連線的命令傳播,可以避免頻繁建立連線的開銷。

在Redis 2.8之前,如果主從庫在命令傳播時出現了網路閃斷,那麼,從庫就會和主庫重新進行一次全量複製,開銷非常大。

從Redis 2.8開始,網路斷了之後,主從庫會採用增量複製的方式繼續同步。聽名字大概就可以猜到它和全量複製的不同:全量複製是同步所有資料,而增量複製只會把主從庫網路斷連期間主庫收到的命令,同步給從庫。

當主從庫斷連後,主庫會把斷連期間收到的寫操作命令,寫入replication buffer,同時也會把這些操作命令也寫入repl_backlog_buffer這個緩衝區。

repl_backlog_buffer是一個環形緩衝區,主庫會記錄自己寫到的位置,從庫則會記錄自己已經讀到的位置。

主從庫的連線恢復之後,從庫首先會給主庫傳送psync命令,並把自己當前的slave_repl_offset發給主庫,主庫會判斷自己的master_repl_offset和slave_repl_offset之間的差距。

在網路斷連階段,主庫可能會收到新的寫操作命令,所以,一般來說,master_repl_offset會大於slave_repl_offset。此時,主庫只用把master_repl_offset和slave_repl_offset之間的命令操作同步給從庫就行。

增量複製的過程:

因為repl_backlog_buffer是一個環形緩衝區,所以在緩衝區寫滿後,主庫會繼續寫入,此時,就會覆蓋掉之前寫入的操作。如果從庫的讀取速度比較慢,就有可能導致從庫還未讀取的操作被主庫新寫的操作覆蓋了,這會導致主從庫間的資料不一致。

因此,我們要想辦法避免這一情況,一般而言,我們可以調整repl_backlog_size這個引數。這個引數和所需的緩衝空間大小有關。緩衝空間的計算公式是:緩衝空間大小 = 主庫寫入命令速度 * 操作大小 - 主從庫間網路傳輸命令速度 * 操作大小。在實際應用中,考慮到可能存在一些突發的請求壓力,我們通常需要把這個緩衝空間擴大一倍,即repl_backlog_size = 緩衝空間大小 * 2,這也就repl_backlog_size的最終值。


7.哨兵機制:主庫掛了,如何不間斷服務

哨兵其實就是一個執行在特殊模式下的Redis程序,主從庫例項執行的同時,它也在執行。哨兵主要負責的就是三個任務:監控、選主(選擇主庫)和通知。

監控是指哨兵程序在執行時,週期性地給所有的主從庫傳送PING命令,檢測它們是否仍然線上執行。如果從庫沒有在規定時間內響應哨兵的PING命令,哨兵就會把它標記為“下線狀態”;同樣,如果主庫也沒有在規定時間內響應哨兵的PING命令,哨兵就會判定主庫下線,然後開始自動切換主庫的流程。

這個流程首先是執行哨兵的第二個任務,選主。主庫掛了以後,哨兵就需要從很多個從庫裡,按照一定的規則選擇一個從庫例項,把它作為新的主庫。這一步完成後,現在的叢集裡就有了新主庫。

然後,哨兵會執行最後一個任務:通知。在執行通知任務時,哨兵會把新主庫的連線資訊發給其他從庫,讓它們執行replicaof命令,和新主庫建立連線,並進行資料複製。同時,哨兵會把新主庫的連線資訊通知給客戶端,讓它們把請求操作發到新主庫上。

在這三個任務中,通知任務相對來說比較簡單,哨兵只需要把新主庫資訊發給從庫和客戶端,讓它們和新主庫建立連線就行,並不涉及決策的邏輯。但是,在監控和選主這兩個任務中,哨兵需要做出兩個決策:

在監控任務中,哨兵需要判斷主庫是否處於下線狀態;
在選主任務中,哨兵也要決定選擇哪個從庫例項作為主庫。

主觀下線和客觀下線

哨兵程序會使用PING命令檢測它自己和主、從庫的網路連線情況,用來判斷例項的狀態。如果哨兵發現主庫或從庫對PING命令的響應超時了,那麼,哨兵就會先把它標記為“主觀下線”。

哨兵機制通常會採用多例項組成的叢集模式進行部署,這也被稱為哨兵叢集。引入多個哨兵例項一起來判斷,就可以避免單個哨兵因為自身網路狀況不好,而誤判主庫下線的情況。同時,多個哨兵的網路同時不穩定的概率較小,由它們一起做決策,誤判率也能降低。

在判斷主庫是否下線時,不能由一個哨兵說了算,只有大多數的哨兵例項,都判斷主庫已經“主觀下線”了,主庫才會被標記為“客觀下線”,這個叫法也是表明主庫下線成為一個客觀事實了。這個判斷原則就是:少數服從多數。同時,這會進一步觸發哨兵開始主從切換流程。

如何選定新主庫?

一般來說,我把哨兵選擇新主庫的過程稱為“篩選+打分”。簡單來說,我們在多個從庫中,先按照一定的篩選條件,把不符合條件的從庫去掉。然後,我們再按照一定的規則,給剩下的從庫逐個打分,將得分最高的從庫選為新主庫。

在選主時,除了要檢查從庫的當前線上狀態,還要判斷它之前的網路連線狀態。如果從庫總是和主庫斷連,而且斷連次數超出了一定的閾值,我們就有理由相信,這個從庫的網路狀況並不是太好,就可以把這個從庫篩掉了。

具體怎麼判斷呢?你使用配置項down-after-milliseconds * 10。其中,down-after-milliseconds是我們認定主從庫斷連的最大連線超時時間。如果在down-after-milliseconds毫秒內,主從節點都沒有通過網路聯絡上,我們就可以認為主從節點斷連了。如果發生斷連的次數超過了10次,就說明這個從庫的網路狀況不好,不適合作為新主庫。

接下來就要給剩餘的從庫打分了。我們可以分別按照三個規則依次進行三輪打分,這三個規則分別是從庫優先順序、從庫複製進度以及從庫ID號。只要在某一輪中,有從庫得分最高,那麼它就是主庫了,選主過程到此結束。如果沒有出現得分最高的從庫,那麼就繼續進行下一輪。
第一輪:

使用者可以通過slave-priority配置項,給不同的從庫設定不同優先順序。

第二輪:

主從庫同步時有個命令傳播的過程。在這個過程中,主庫會用master_repl_offset記錄當前的最新寫操作在repl_backlog_buffer中的位置,而從庫會用slave_repl_offset這個值記錄當前的複製進度。

此時,我們想要找的從庫,它的slave_repl_offset需要最接近master_repl_offset。如果在所有從庫中,有從庫的slave_repl_offset最接近master_repl_offset,那麼它的得分就最高,可以作為新主庫。

第三輪:
每個例項都會有一個ID,這個ID就類似於這裡的從庫的編號。目前,Redis在選主庫時,有一個預設的規定:在優先順序和複製進度都相同的情況下,ID號最小的從庫得分最高,會被選為新主庫。

我們再回顧下這個流程。首先,哨兵會按照線上狀態、網路狀態,篩選過濾掉一部分不符合要求的從庫,然後,依次按照優先順序、複製進度、ID號大小再對剩餘的從庫進行打分,只要有得分最高的從庫出現,就把它選為新主庫。


08-哨兵叢集:哨兵掛了,主從庫還能切換嗎?

基於pub/sub機制的哨兵叢集組成

哨兵例項之間可以相互發現,要歸功於Redis提供的pub/sub機制,也就是釋出/訂閱機制。
哨兵只要和主庫建立起了連線,就可以在主庫上釋出訊息了,比如說釋出它自己的連線資訊(IP和埠)。同時,它也可以從主庫上訂閱訊息,獲得其他哨兵釋出的連線資訊。當多個哨兵例項都在主庫上做了釋出和訂閱操作後,它們之間就能知道彼此的IP地址和埠。
除了哨兵例項,我們自己編寫的應用程式也可以通過Redis進行訊息的釋出和訂閱。所以,為了區分不同應用的訊息,Redis會以頻道的形式,對這些訊息進行分門別類的管理。所謂的頻道,實際上就是訊息的類別。當訊息類別相同時,它們就屬於同一個頻道。反之,就屬於不同的頻道。只有訂閱了同一個頻道的應用,才能通過釋出的訊息進行資訊交換。

在主從叢集中,主庫上有一個名為“sentinel:hello”的頻道,不同哨兵就是通過它來相互發現,實現互相通訊的。

哨兵除了彼此之間建立起連線形成叢集外,還需要和從庫建立連線。這是因為,在哨兵的監控任務中,它需要對主從庫都進行心跳判斷,而且在主從庫切換完成後,它還需要通知從庫,讓它們和新主庫進行同步。

那麼,哨兵是如何知道從庫的IP地址和埠的呢?

這是由哨兵向主庫傳送INFO命令來完成的。就像下圖所示,哨兵2給主庫傳送INFO命令,主庫接受到這個命令後,就會把從庫列表返回給哨兵。接著,哨兵就可以根據從庫列表中的連線資訊,和每個從庫建立連線,並在這個連線上持續地對從庫進行監控。哨兵1和3可以通過相同的方法和從庫建立連線。

基於pub/sub機制的客戶端事件通知

從本質上說,哨兵就是一個執行在特定模式下的Redis例項,只不過它並不服務請求操作,只是完成監控、選主和通知的任務。所以,每個哨兵例項也提供pub/sub機制,客戶端可以從哨兵訂閱訊息。哨兵提供的訊息訂閱頻道有很多,不同頻道包含了主從庫切換過程中的不同關鍵事件。

頻道有這麼多,一下子全部學習容易丟失重點。為了減輕你的學習壓力,我把重要的頻道彙總在了一起,涉及幾個關鍵事件,包括主庫下線判斷、新主庫選定、從庫重新配置。

知道了這些頻道之後,你就可以讓客戶端從哨兵這裡訂閱訊息了。具體的操作步驟是,客戶端讀取哨兵的配置檔案後,可以獲得哨兵的地址和埠,和哨兵建立網路連線。然後,我們可以在客戶端執行訂閱命令,來獲取不同的事件訊息。

有了pub/sub機制,哨兵和哨兵之間、哨兵和從庫之間、哨兵和客戶端之間就都能建立起連線了,再加上我們上節課介紹主庫下線判斷和選主依據,哨兵叢集的監控、選主和通知三個任務就基本可以正常工作了。

由哪個哨兵執行主從切換?

確定由哪個哨兵執行主從切換的過程,和主庫“客觀下線”的判斷過程類似,也是一個“投票仲裁”的過程。

一個哨兵獲得了仲裁所需的贊成票數後,就可以標記主庫為“客觀下線”。這個所需的贊成票數是通過哨兵配置檔案中的quorum配置項設定的。例如,現在有5個哨兵,quorum配置的是3,那麼,一個哨兵需要3張贊成票,就可以標記主庫為“客觀下線”了。這3張贊成票包括哨兵自己的一張贊成票和另外兩個哨兵的贊成票。

此時,這個哨兵就可以再給其他哨兵傳送命令,表明希望由自己來執行主從切換,並讓所有其他哨兵進行投票。這個投票過程稱為“Leader選舉”。因為最終執行主從切換的哨兵稱為Leader,投票過程就是確定Leader。
在投票過程中,任何一個想成為Leader的哨兵,要滿足兩個條件:第一,拿到半數以上的贊成票;第二,拿到的票數同時還需要大於等於哨兵配置檔案中的quorum值。以3個哨兵為例,假設此時的quorum設定為2,那麼,任何一個想成為Leader的哨兵只要拿到2張贊成票,就可以了。

需要注意的是,如果哨兵叢集只有2個例項,此時,一個哨兵要想成為Leader,必須獲得2票,而不是1票。所以,如果有個哨兵掛掉了,那麼,此時的叢集是無法進行主從庫切換的。因此,通常我們至少會配置3個哨兵例項。這一點很重要,你在實際應用時可不能忽略了。

總結

支援哨兵叢集的這些關鍵機制:
基於pub/sub機制的哨兵叢集組成過程;
基於INFO命令的從庫列表,這可以幫助哨兵和從庫建立連線;
基於哨兵自身的pub/sub功能,這實現了客戶端和哨兵之間的事件通知。


9.切片叢集

切片叢集,也叫分片叢集,就是指啟動多個Redis例項組成一個叢集,然後按照一定的規則,把收到的資料劃分成多份,每一份用一個例項來儲存。

縱向擴充套件:升級單個Redis例項的資源配置,包括增加記憶體容量、增加磁碟容量、使用更高配置的CPU。就像下圖中,原來的例項記憶體是8GB,硬碟是50GB,縱向擴充套件後,記憶體增加到24GB,磁碟增加到150GB。

橫向擴充套件:橫向增加當前Redis例項的個數,就像下圖中,原來使用1個8GB記憶體、50GB磁碟的例項,現在使用三個相同配置的例項。

首先,縱向擴充套件的好處是,實施起來簡單、直接。不過,這個方案也面臨兩個潛在的問題。
第一個問題是,當使用RDB對資料進行持久化時,如果資料量增加,需要的記憶體也會增加,主執行緒fork子程序時就可能會阻塞(比如剛剛的例子中的情況)。不過,如果你不要求持久化儲存Redis資料,那麼,縱向擴充套件會是一個不錯的選擇。

不過,這時,你還要面對第二個問題:縱向擴充套件會受到硬體和成本的限制。這很容易理解,畢竟,把記憶體從32GB擴充套件到64GB還算容易,但是,要想擴充到1TB,就會面臨硬體容量和成本上的限制了。

與縱向擴充套件相比,橫向擴充套件是一個擴充套件性更好的方案。這是因為,要想儲存更多的資料,採用這種方案的話,只用增加Redis的例項個數就行了,不用擔心單個例項的硬體和成本限制。在面向百萬、千萬級別的使用者規模時,橫向擴充套件的Redis切片叢集會是一個非常好的選擇。

資料切片和例項的對應分佈關係

Redis Cluster方案採用雜湊槽(Hash Slot,接下來我會直接稱之為Slot),來處理資料和例項之間的對映關係。在Redis Cluster方案中,一個切片叢集共有16384個雜湊槽,這些雜湊槽類似於資料分割槽,每個鍵值對都會根據它的key,被對映到一個雜湊槽中。

具體的對映過程分為兩大步:首先根據鍵值對的key,按照CRC16演算法計算一個16 bit的值;然後,再用這個16bit值對16384取模,得到0~16383範圍內的模數,每個模數代表一個相應編號的雜湊槽。關於CRC16演算法,不是這節課的重點,你簡單看下連結中的資料就可以了。

我們在部署Redis Cluster方案時,可以使用cluster create命令建立叢集,此時,Redis會自動把這些槽平均分佈在叢集例項上。例如,如果叢集中有N個例項,那麼,每個例項上的槽個數為16384/N個。

當然, 我們也可以使用cluster meet命令手動建立例項間的連線,形成叢集,再使用cluster addslots命令,指定每個例項上的雜湊槽個數。

在手動分配雜湊槽時,需要把16384個槽都分配完,否則Redis叢集無法正常工作。

客戶端如何定位資料?

在定位鍵值對資料時,它所處的雜湊槽是可以通過計算得到的,這個計算可以在客戶端傳送請求時來執行。但是,要進一步定位到例項,還需要知道雜湊槽分佈在哪個例項上。

一般來說,客戶端和叢集例項建立連線後,例項就會把雜湊槽的分配資訊發給客戶端。但是,在叢集剛剛建立的時候,每個例項只知道自己被分配了哪些雜湊槽,是不知道其他例項擁有的雜湊槽資訊的。

那麼,客戶端為什麼可以在訪問任何一個例項時,都能獲得所有的雜湊槽資訊呢?這是因為,Redis例項會把自己的雜湊槽資訊發給和它相連線的其它例項,來完成雜湊槽分配資訊的擴散。當例項之間相互連線後,每個例項就有所有雜湊槽的對映關係了。

客戶端收到雜湊槽資訊後,會把雜湊槽資訊快取在本地。當客戶端請求鍵值對時,會先計算鍵所對應的雜湊槽,然後就可以給相應的例項傳送請求了。

但是,在叢集中,例項和雜湊槽的對應關係並不是一成不變的,最常見的變化有兩個:

在叢集中,例項有新增或刪除,Redis需要重新分配雜湊槽;
為了負載均衡,Redis需要把雜湊槽在所有例項上重新分佈一遍。

Redis Cluster方案提供了一種重定向機制,所謂的“重定向”,就是指,客戶端給一個例項傳送資料讀寫操作時,這個例項上並沒有相應的資料,客戶端要再給一個新例項傳送操作命令。

MOVED重定向命令的使用方法

可以看到,由於負載均衡,Slot 2中的資料已經從例項2遷移到了例項3,但是,客戶端快取仍然記錄著“Slot 2在例項2”的資訊,所以會給例項2傳送命令。例項2給客戶端返回一條MOVED命令,把Slot 2的最新位置(也就是在例項3上),返回給客戶端,客戶端就會再次向例項3傳送請求,同時還會更新本地快取,把Slot 2與例項的對應關係更新過來。

ASK重定向命令得使用方法

在下圖中,Slot 2正在從例項2往例項3遷移,key1和key2已經遷移過去,key3和key4還在例項2。客戶端向例項2請求key2後,就會收到例項2返回的ASK命令。

ASK命令表示兩層含義:第一,表明Slot資料還在遷移中;第二,ASK命令把客戶端所請求資料的最新例項地址返回給客戶端,此時,客戶端需要給例項3傳送ASKING命令,然後再發送操作命令。

和MOVED命令不同,ASK命令並不會更新客戶端快取的雜湊槽分配資訊。所以,在上圖中,如果客戶端再次請求Slot 2中的資料,它還是會給例項2傳送請求。這也就是說,ASK命令的作用只是讓客戶端能給新例項傳送一次請求,而不像MOVED命令那樣,會更改本地快取,讓後續所有命令都發往新例項。


10.典型問題講解

問題1:AOF重寫過程中有沒有其他潛在的阻塞風險?

風險一:Redis主執行緒fork建立bgrewriteaof子程序時,核心需要建立用於管理子程序的相關資料結構,這些資料結構在作業系統中通常叫作程序控制塊(Process Control Block,簡稱為PCB)。核心要把主執行緒的PCB內容拷貝給子程序。這個建立和拷貝過程由核心執行,是會阻塞主執行緒的。而且,在拷貝過程中,子程序要拷貝父程序的頁表,這個過程的耗時和Redis例項的記憶體大小有關。如果Redis例項記憶體大,頁表就會大,fork執行時間就會長,這就會給主執行緒帶來阻塞風險。

風險二:bgrewriteaof子程序會和主執行緒共享記憶體。當主執行緒收到新寫或修改的操作時,主執行緒會申請新的記憶體空間,用來儲存新寫或修改的資料,如果操作的是bigkey,也就是資料量大的集合型別資料,那麼,主執行緒會因為申請大空間而面臨阻塞風險。因為作業系統在分配記憶體空間時,有查詢和鎖的開銷,這就會導致阻塞。

問題2:AOF 重寫為什麼不共享使用 AOF 本身的日誌?

如果都用AOF日誌的話,主執行緒要寫,bgrewriteaof子程序也要寫,這兩者會競爭檔案系統的鎖,這就會對Redis主執行緒的效能造成影響。

問題3:為什麼主從庫間的複製不使用 AOF?

答案:有兩個原因。

1 RDB檔案是二進位制檔案,無論是要把RDB寫入磁碟,還是要通過網路傳輸RDB,IO效率都比記錄和傳輸AOF的高。
2 在從庫端進行恢復時,用RDB的恢復效率要高於用AOF。

問題4:為什麼Redis不直接用一個表,把鍵值對和例項的對應關係記錄下來?

如果使用表記錄鍵值對和例項的對應關係,一旦鍵值對和例項的對應關係發生了變化(例如例項有增減或者資料重新分佈),就要修改表。如果是單執行緒操作表,那麼所有操作都要序列執行,效能慢;如果是多執行緒操作表,就涉及到加鎖開銷。此外,如果資料量非常大,使用表記錄鍵值對和例項的對應關係,需要的額外儲存空間也會增加。

基於雜湊槽計算時,雖然也要記錄雜湊槽和例項的對應關係,但是雜湊槽的個數要比鍵值對的個數少很多,無論是修改雜湊槽和例項的對應關係,還是使用額外空間儲存雜湊槽和例項的對應關係,都比直接記錄鍵值對和例項的關係的開銷小得多。

問題5:Redis什麼時候做rehash?

Redis會使用裝載因子(load factor)來判斷是否需要做rehash。裝載因子的計算方式是,雜湊表中所有entry的個數除以雜湊表的雜湊桶個數。Redis會根據裝載因子的兩種情況,來觸發rehash操作:

1 裝載因子≥1,同時,雜湊表被允許進行rehash;
2 裝載因子≥5。

在第一種情況下,如果裝載因子等於1,同時我們假設,所有鍵值對是平均分佈在雜湊表的各個桶中的,那麼,此時,雜湊表可以不用鏈式雜湊,因為一個雜湊桶正好儲存了一個鍵值對。

但是,如果此時再有新的資料寫入,雜湊表就要使用鏈式雜湊了,這會對查詢效能產生影響。在進行RDB生成和AOF重寫時,雜湊表的rehash是被禁止的,這是為了避免對RDB和AOF重寫造成影響。如果此時,Redis沒有在生成RDB和重寫AOF,那麼,就可以進行rehash。否則的話,再有資料寫入時,雜湊表就要開始使用查詢較慢的鏈式雜湊了。

在第二種情況下,也就是裝載因子大於等於5時,就表明當前儲存的資料量已經遠遠大於雜湊桶的個數,雜湊桶裡會有大量的鏈式雜湊存在,效能會受到嚴重影響,此時,就立馬開始做rehash。

剛剛說的是觸發rehash的情況,如果裝載因子小於1,或者裝載因子大於1但是小於5,同時雜湊表暫時不被允許進行rehash(例如,例項正在生成RDB或者重寫AOF),此時,雜湊表是不會進行rehash操作的。

問題6:採用漸進式hash時,如果例項暫時沒有收到新請求,是不是就不做rehash了?

其實不是的。Redis會執行定時任務,定時任務中就包含了rehash操作。所謂的定時任務,就是按照一定頻率(例如每100ms/次)執行的任務。

在rehash被觸發後,即使沒有收到新請求,Redis也會定時執行一次rehash操作,而且,每次執行時長不會超過1ms,以免對其他任務造成影響。

問題7:寫時複製的底層實現機制

對Redis來說,主執行緒fork出bgsave子程序後,bgsave子程序實際是複製了主執行緒的頁表。這些頁表中,就儲存了在執行bgsave命令時,主執行緒的所有資料塊在記憶體中的實體地址。這樣一來,bgsave子程序生成RDB時,就可以根據頁表讀取這些資料,再寫入磁碟中。如果此時,主執行緒接收到了新寫或修改操作,那麼,主執行緒會使用寫時複製機制。具體來說,寫時複製就是指,主執行緒在有寫操作時,才會把這個新寫或修改後的資料寫入到一個新的實體地址中,並修改自己的頁表對映。

我來藉助下圖中的例子,具體展示一下寫時複製的底層機制。

bgsave子程序複製主執行緒的頁表以後,假如主執行緒需要修改虛頁7裡的資料,那麼,主執行緒就需要新分配一個物理頁(假設是物理頁53),然後把修改後的虛頁7裡的資料寫到物理頁53上,而虛頁7裡原來的資料仍然儲存在物理頁33上。這個時候,虛頁7到物理頁33的對映關係,仍然保留在bgsave子程序中。所以,bgsave子程序可以無誤地把虛頁7的原始資料寫入RDB檔案。

問題8:replication buffer和repl_backlog_buffer的區別

在進行主從複製時,Redis會使用replication buffer和repl_backlog_buffer,有些同學可能不太清楚它們的區別,我再解釋下。

總的來說,replication buffer是主從庫在進行全量複製時,主庫上用於和從庫連線的客戶端的buffer,而repl_backlog_buffer是為了支援從庫增量複製,主庫上用於持續儲存寫操作的一塊專用buffer。

Redis主從庫在進行復制時,當主庫要把全量複製期間的寫操作命令發給從庫時,主庫會先建立一個客戶端,用來連線從庫,然後通過這個客戶端,把寫操作命令發給從庫。在記憶體中,主庫上的客戶端就會對應一個buffer,這個buffer就被稱為replication buffer。Redis通過client_buffer配置項來控制這個buffer的大小。主庫會給每個從庫建立一個客戶端,所以replication buffer不是共享的,而是每個從庫都有一個對應的客戶端。

repl_backlog_buffer是一塊專用buffer,在Redis伺服器啟動後,開始一直接收寫操作命令,這是所有從庫共享的。主庫和從庫會各自記錄自己的複製進度,所以,不同的從庫在進行恢復時,會把自己的複製進度(slave_repl_offset)發給主庫,主庫就可以和它獨立同步。