單執行緒的Redis有哪些慢動作?

愛撒謊的男孩發表於2020-11-23

持續原創輸出,點選上方藍字關注我

目錄

  • 前言
  • 為什麼 Redis 這麼火?
  • 鍵和值的儲存形式?
  • 為什麼雜湊表操作變慢了?
  • 集合的操作效率?
    • 有哪些資料結構?
    • 不同操作的複雜度?
  • 總結

前言

現在一提到Redis的第一反應就是單執行緒,但是Redis真的快嗎?真的是單執行緒嗎?

你有沒有深入瞭解一下Redis,看看它的底層有哪些"慢動作"呢?

為什麼 Redis 這麼火?

Redis作為一個記憶體資料庫,它接收一個key到讀取資料幾乎是微妙級別,一個字詮釋了它火的原因。另一方面就歸功於它的資料結構了,你知道Redis有哪些資料結構嗎?

很多人可能會說不就是String(字串)、List(列表)、Hash(雜湊)、Set(集合)和 Sorted Set(有序集合)這五種嗎?我想大家可能有一種誤區,我說的是底層資料結構,而你說僅僅是資料的儲存形式而已。

那麼Redis底層有哪幾種資料結構呢?和幾種資料儲存形式的關係又是什麼呢?彆著急,先通過一張圖瞭解下,如下圖:

通過上圖可以知道只有String對應的是一種資料結構,其他的資料型別對應的都是兩種資料結構,我們通常將這四種資料型別稱為集合型別,它們的特點是一個鍵對應了一個集合的資料

既然資料本身是通過資料結構儲存的,那麼鍵和值是什麼儲存形式呢?

鍵和值的儲存形式?

為了實現鍵和值的快速訪問,Redis使用的是雜湊表來存放鍵,使用雜湊桶存放值。

一個雜湊表其實就是一個陣列,陣列的每個元素稱之為雜湊桶

所以,一個雜湊表是由多個雜湊桶組成,每個雜湊桶中儲存了鍵值對資料。

雜湊桶中儲存的並不是值,而是指向值的指標

這也解釋了為什麼雜湊桶能夠儲存集合型別的資料了,也就是說不管是String還是集合型別,雜湊桶儲存的都是指向具體的值的指標,具體的結構如下圖:

從上圖可以看出,每個entry中儲存的是*key*value分別指向了鍵和值,這樣即使儲存的值是集合型別也能通過指標 *value找到。

鍵是儲存在雜湊表中,雜湊表的時間複雜度是O(1),也就是無論多少個鍵,總能通過一次計算就找到對應的鍵。

但是問題來了,當你往Redis中寫入大量的資料就有可能發現操作變了,這就是一個典型的問題:雜湊衝突

為什麼雜湊表操作變慢了?

既然底層用了雜湊表,則雜湊衝突是不可避免的,那什麼是雜湊衝突呢?

Redis中的雜湊衝突則是兩個或者多個key通過計算對應關係,正好落在了同一個雜湊桶中。

這樣則導致了不同的key查詢到的值是相同的,但是這種問題在Redis中顯然是不存在的,那麼Redis用了什麼方法解決了雜湊衝突呢?

Redis底層使用了鏈式雜湊的方式解決了雜湊衝突,即是同一個雜湊桶中的多個元素用一個連結串列儲存,他們之間用指標*next相連。

此時的雜湊表和鏈式雜湊的結構如下圖:

從上圖可以看到,entry1entry3entry3都儲存在雜湊桶 1 中,導致了雜湊衝突。但是此時的entry1中的*next指標指向了entry2,同樣entry2中的*next指標指向了entry3。這樣下來即使雜湊桶中有很多個元素,也能通過這樣的方式連線起來,稱作雜湊衝突鏈

這裡存在一個問題:連結串列的查詢效率很低,如果雜湊桶中元素很多,查詢起來會很,顯然這個對於Redis來說是不能接受的。

Redis使用了一個很巧妙的方式:漸進式 rehash。那麼什麼是漸進是rehash呢?

想要理解漸進式rehash,首先需要理解下的rehash的過程。

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

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

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

以上這個過程結束,就可以釋放掉雜湊表1的資料而使用雜湊表2了,此時的雜湊表1可以留作下次的rehash備用。

此時這裡存在一個問題:rehash整個過程的第 2 步涉及到大量的拷貝,一次性的拷貝資料肯定會造成執行緒阻塞,無法服務其他的請求。此時的Redis就無法快速訪問資料了。

為了避免一次性拷貝資料導致執行緒阻塞,Redis使用了漸進式rehash

漸進式rehash則是rehash的第 2 步拷貝資料分攤到每個請求中,Redis 仍然正常服務,只不過在處理每次請求的時候,從雜湊表1索引1的位置將所有的entry拷貝到雜湊表2中,下一個請求則從索引1的下一個的位置開始。

通過漸進式 rehash 巧妙的將一次性開銷分攤到各個請求處理的過程中,避免了一次性的耗時操作。

此時可能有人提出疑問了:如果沒有請求,那麼Redis就不會rehash了嗎?

Redis底層其實還會開啟一個定時任務,會定時的拷貝資料,即使沒有請求,rehash也會定時的在執行。

集合的操作效率?

如果是string,找到雜湊桶中的entry則能正常的進行增刪改查了,但是如果是集合呢?即使通過指標找到了entry中的value,但是此時是一個集合,又是一個不同的資料結構,肯定會有不同的複雜度了。

集合的操作效率肯定是和集合底層的資料結構相關,比如使用雜湊表實現的集合肯定要比使用連結串列實現的結合訪問效率要高。

接下來就來說說集合的底層資料結構和操作複雜度。

有哪些資料結構?

本文的第一張圖已經列出了集合的底層資料結構,主要有五種:整數陣列雙向連結串列雜湊表壓縮列表跳錶

以上這五種資料結構都是比較常見的,如果讀者不是很瞭解具體的結構請閱讀相關的書籍,我就不再贅述了。

五種資料結構按照查詢時間的複雜度分類如下:

資料結構 時間複雜度
雜湊表 O(1)
跳錶 O(logN)
雙向連結串列 O(N)
壓縮連結串列 O(N)
整數陣列 O(N)

不同操作的複雜度?

集合型別的操作型別很多,有讀寫單個集合元素的,例如 HGETHSET,也有操作多個元素的,例如SADD,還有對整個集合進行遍歷操作的,例如 SMEMBERS。這麼多操作,它們的複雜度也各不相同。而複雜度的高低又是我們選擇集合型別的重要依據。

下文列舉了一些集合操作的複雜度,總共三點,僅供參考。

1. 單元素操作由底層資料結構決定

每一種集合型別對單元素的增刪改查操作這些操作的複雜度由集合採用的資料結構決定。例如,HGETHSETHDEL 是對雜湊表做操作,所以它們的複雜度都是O(1)Set型別用雜湊表作為底層資料結構時,它的SADDSREMSRANDMEMBER 複雜度也是 O(1)

有些集合型別還支援一條命令同時對多個元素的操作,比如Hash型別的HMGETHMSET。此時的操作複雜度則是O(N)

2. 範圍操作非常耗時,應該避免

範圍操作是指集合型別中的遍歷操作,可以返回集合中的所有資料或者部分資料。比如List型別的HGETALLSet 型別的SMEMBERS,這類操作的複雜度為O(N),比較耗時,應該避免。

不過Redis提供了Scan系列操作,比如HSCANSSCSCANZSCAN,這類操作實現了漸進式遍歷,每次只返回有限數量的資料。這樣一來,相比於HGETALLSMEMBERS 這類操作來說,就避免了一次性返回所有元素而導致的 Redis 阻塞。

3. 統計操作通常比較高效

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

總結

Redis之所以這麼快,不僅僅因為全部操作都在記憶體中,還有底層資料結構的支援,但是資料結構雖好,每種資料結構也有各種的情況,Redis結合各種資料結構的利弊,完善了整個執行機制。

相關文章