持續原創輸出,點選上方藍字關注我
目錄
前言 為什麼 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
相連。
此時的雜湊表和鏈式雜湊的結構如下圖:
從上圖可以看到,entry1
、entry3
、entry3
都儲存在雜湊桶 1 中,導致了雜湊衝突。但是此時的entry1
中的*next
指標指向了entry2
,同樣entry2
中的*next
指標指向了entry3
。這樣下來即使雜湊桶中有很多個元素,也能通過這樣的方式連線起來,稱作雜湊衝突鏈
。
這裡存在一個問題:連結串列的查詢效率很低,如果雜湊桶中元素很多,查詢起來會很「慢」,顯然這個對於
Redis
來說是不能接受的。
Redis
使用了一個很巧妙的方式:「漸進式 rehash」。那麼什麼是漸進是rehash
呢?
想要理解漸進式rehash
,首先需要理解下的rehash
的過程。
rehash
也就是增加現有的雜湊桶數量,讓逐漸增多的entry
元素能在更多的桶之間分散儲存,減少單個桶中的元素數量,從而減少單個桶中的衝突。
為了使rehash
操作更高效,Redis
預設使用了兩個全域性雜湊表:雜湊表1
和雜湊表2
。一開始,當你剛插入資料時,預設使用雜湊表1
,此時的雜湊表2
並沒有被分配空間。隨著資料逐步增多,Redis
開始執行rehash
,這個過程分為三步:
給 雜湊表2
分配更大的空間,例如是當前雜湊表1
大小的兩倍把 雜湊表1
中的資料重新對映並拷貝到雜湊表2
中釋放 雜湊表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) |
不同操作的複雜度?
集合型別的操作型別很多,有讀寫單個集合元素的,例如 HGET
、HSET
,也有操作多個元素的,例如SADD
,還有對整個集合進行遍歷操作的,例如 SMEMBERS
。這麼多操作,它們的複雜度也各不相同。而複雜度的高低又是我們選擇集合型別的重要依據。
下文列舉了一些集合操作的複雜度,總共三點,僅供參考。
1. 單元素操作由底層資料結構決定
每一種集合型別對單元素的增刪改查操作這些操作的複雜度由集合採用的資料結構決定。例如,HGET
、HSET
和HDEL
是對雜湊表做操作,所以它們的複雜度都是O(1)
;Set
型別用雜湊表作為底層資料結構時,它的SADD
、SREM
、SRANDMEMBER
複雜度也是 O(1)
。
有些集合型別還支援一條命令同時對多個元素的操作,比如Hash
型別的HMGET
和HMSET
。此時的操作複雜度則是O(N)
。
2. 範圍操作非常耗時,應該避免
範圍操作是指集合型別中的遍歷操作,可以返回集合中的所有資料或者部分資料。比如List
型別的HGETALL
和Set
型別的SMEMBERS
,這類操作的複雜度為O(N)
,比較耗時,應該避免。
不過Redis
提供了Scan
系列操作,比如HSCAN
、SSCSCAN
和ZSCAN
,這類操作實現了漸進式遍歷,每次只返回有限數量的資料。這樣一來,相比於HGETALL
、SMEMBERS
這類操作來說,就避免了一次性返回所有元素而導致的 Redis
阻塞。
3. 統計操作通常比較高效
統計操作是指對集合中的所有元素個數的記錄,例如LLEN
和SCARD
。這類操作複雜度只有O(1)
,這是因為當集合型別採用壓縮列表、雙向連結串列、整數陣列這些資料結構時,這些結構中專門記錄了元素的個數統計,因此可以高效地完成相關操作。
總結
Redis
之所以這麼快,不僅僅因為全部操作都在記憶體中,還有底層資料結構的支援,但是資料結構雖好,每種資料結構也有各種「慢」的情況,Redis
結合各種資料結構的利弊,完善了整個執行機制。