redis_02 _ 資料結構:快速的Redis有哪些慢操作?
一提到Redis,我們的腦子裡馬上就會出現一個詞:“快。”但是你有沒有想過,Redis的快,到底是快在哪裡呢?實際上,這裡有一個重要的表現:它接收到一個鍵值對操作後,能以微秒級別的速度找到資料,並快速完成操作。
資料庫這麼多,為啥Redis能有這麼突出的表現呢?一方面,這是因為它是記憶體資料庫,所有操作都在記憶體上完成,記憶體的訪問速度本身就很快。另一方面,這要歸功於它的資料結構。這是因為,鍵值對是按一定的資料結構來組織的,操作鍵值對最終就是對資料結構進行增刪改查操作,所以高效的資料結構是Redis快速處理資料的基礎。這節課,我就來和你聊聊資料結構。
說到這兒,你肯定會說:“這個我知道,不就是String(字串)、List(列表)、Hash(雜湊)、Set(集合)和Sorted Set(有序集合)嗎?”其實,這些只是Redis鍵值對中值的資料型別,也就是資料的儲存形式。而這裡,我們說的資料結構,是要去看看它們的底層實現。
簡單來說,底層資料結構一共有6種,分別是簡單動態字串、雙向連結串列、壓縮列表、雜湊表、跳錶和整數陣列。它們和資料型別的對應關係如下圖所示:
可以看到,String型別的底層實現只有一種資料結構,也就是簡單動態字串。而List、Hash、Set和Sorted Set這四種資料型別,都有兩種底層實現結構。通常情況下,我們會把這四種類型稱為集合型別,它們的特點是一個鍵對應了一個集合的資料。
看到這裡,其實有些問題已經值得我們去考慮了:
- 這些資料結構都是值的底層實現,鍵和值本身之間用什麼結構組織?
- 為什麼集合型別有那麼多的底層結構,它們都是怎麼組織資料的,都很快嗎?
- 什麼是簡單動態字串,和常用的字串是一回事嗎?
接下來,我就和你聊聊前兩個問題。這樣,你不僅可以知道Redis“快”的基本原理,還可以藉此理解Redis中有哪些潛在的“慢操作”,最大化Redis的效能優勢。而關於簡單動態字串,我會在後面的課程中再和你討論。
我們先來看看鍵和值之間是用什麼結構組織的。
鍵和值用什麼結構組織?
為了實現從鍵到值的快速訪問,Redis使用了一個雜湊表來儲存所有鍵值對。
一個雜湊表,其實就是一個數組,陣列的每個元素稱為一個雜湊桶。所以,我們常說,一個雜湊表是由多個雜湊桶組成的,每個雜湊桶中儲存了鍵值對資料。
看到這裡,你可能會問了:“如果值是集合型別的話,作為陣列元素的雜湊桶怎麼來儲存呢?”其實,雜湊桶中的元素儲存的並不是值本身,而是指向具體值的指標。這也就是說,不管值是String,還是集合型別,雜湊桶中的元素都是指向它們的指標。
在下圖中,可以看到,雜湊桶中的entry元素中儲存了*key
和*value
指標,分別指向了實際的鍵和值,這樣一來,即使值是一個集合,也可以通過*value
指標被查詢到。
因為這個雜湊表儲存了所有的鍵值對,所以,我也把它稱為全域性雜湊表。雜湊表的最大好處很明顯,就是讓我們可以用O(1)的時間複雜度來快速查詢到鍵值對——我們只需要計算鍵的雜湊值,就可以知道它所對應的雜湊桶位置,然後就可以訪問相應的entry元素。
你看,這個查詢過程主要依賴於雜湊計算,和資料量的多少並沒有直接關係。也就是說,不管雜湊表裡有10萬個鍵還是100萬個鍵,我們只需要一次計算就能找到相應的鍵。
但是,如果你只是瞭解了雜湊表的O(1)複雜度和快速查詢特性,那麼,當你往Redis中寫入大量資料後,就可能發現操作有時候會突然變慢了。這其實是因為你忽略了一個潛在的風險點,那就是雜湊表的衝突問題和rehash可能帶來的操作阻塞。
為什麼雜湊表操作變慢了?
當你往雜湊表中寫入更多資料時,雜湊衝突是不可避免的問題。這裡的雜湊衝突,也就是指,兩個key的雜湊值和雜湊桶計算對應關係時,正好落在了同一個雜湊桶中。
畢竟,雜湊桶的個數通常要少於key的數量,這也就是說,難免會有一些key的雜湊值對應到了同一個雜湊桶中。
Redis解決雜湊衝突的方式,就是鏈式雜湊。鏈式雜湊也很容易理解,就是指同一個雜湊桶中的多個元素用一個連結串列來儲存,它們之間依次用指標連線。
如下圖所示:entry1、entry2和entry3都需要儲存在雜湊桶3中,導致了雜湊衝突。此時,entry1元素會通過一個*next
指標指向entry2,同樣,entry2也會通過*next
指標指向entry3。這樣一來,即使雜湊桶3中的元素有100個,我們也可以通過entry元素中的指標,把它們連起來。這就形成了一個連結串列,也叫作雜湊衝突鏈。
但是,這裡依然存在一個問題,雜湊衝突鏈上的元素只能通過指標逐一查詢再操作。如果雜湊表裡寫入的資料越來越多,雜湊衝突可能也會越來越多,這就會導致某些雜湊衝突鏈過長,進而導致這個鏈上的元素查詢耗時長,效率降低。對於追求“快”的Redis來說,這是不太能接受的。
所以,Redis會對雜湊表做rehash操作。rehash也就是增加現有的雜湊桶數量,讓逐漸增多的entry元素能在更多的桶之間分散儲存,減少單個桶中的元素數量,從而減少單個桶中的衝突。那具體怎麼做呢?
其實,為了使rehash操作更高效,Redis預設使用了兩個全域性雜湊表:雜湊表1和雜湊表2。一開始,當你剛插入資料時,預設使用雜湊表1,此時的雜湊表2並沒有被分配空間。隨著資料逐步增多,Redis開始執行rehash,這個過程分為三步:
- 給雜湊表2分配更大的空間,例如是當前雜湊表1大小的兩倍;
- 把雜湊表1中的資料重新對映並拷貝到雜湊表2中;
- 釋放雜湊表1的空間。
到此,我們就可以從雜湊表1切換到雜湊表2,用增大的雜湊表2儲存更多資料,而原來的雜湊表1留作下一次rehash擴容備用。
這個過程看似簡單,但是第二步涉及大量的資料拷貝,如果一次性把雜湊表1中的資料都遷移完,會造成Redis執行緒阻塞,無法服務其他請求。此時,Redis就無法快速訪問資料了。
為了避免這個問題,Redis採用了漸進式rehash。
簡單來說就是在第二步拷貝資料時,Redis仍然正常處理客戶端請求,每處理一個請求時,從雜湊表1中的第一個索引位置開始,順帶著將這個索引位置上的所有entries拷貝到雜湊表2中;等處理下一個請求時,再順帶拷貝雜湊表1中的下一個索引位置的entries。如下圖所示:
這樣就巧妙地把一次性大量拷貝的開銷,分攤到了多次處理請求的過程中,避免了耗時操作,保證了資料的快速訪問。
好了,到這裡,你應該就能理解,Redis的鍵和值是怎麼通過雜湊表組織的了。對於String型別來說,找到雜湊桶就能直接增刪改查了,所以,雜湊表的O(1)操作複雜度也就是它的複雜度了。
但是,對於集合型別來說,即使找到雜湊桶了,還要在集合中再進一步操作。接下來,我們來看集合型別的操作效率又是怎樣的。
集合資料操作效率
和String型別不同,一個集合型別的值,第一步是通過全域性雜湊表找到對應的雜湊桶位置,第二步是在集合中再增刪改查。那麼,集合的操作效率和哪些因素相關呢?
首先,與集合的底層資料結構有關。例如,使用雜湊表實現的集合,要比使用連結串列實現的集合訪問效率更高。其次,操作效率和這些操作本身的執行特點有關,比如讀寫一個元素的操作要比讀寫所有元素的效率高。
接下來,我們就分別聊聊集合型別的底層資料結構和操作複雜度。
有哪些底層資料結構?
剛才,我也和你介紹過,集合型別的底層資料結構主要有5種:整數陣列、雙向連結串列、雜湊表、壓縮列表和跳錶。
其中,雜湊表的操作特點我們剛剛已經學過了;整數陣列和雙向連結串列也很常見,它們的操作特徵都是順序讀寫,也就是通過陣列下標或者連結串列的指標逐個元素訪問,操作複雜度基本是O(N),操作效率比較低;壓縮列表和跳錶我們平時接觸得可能不多,但它們也是Redis重要的資料結構,所以我來重點解釋一下。
壓縮列表實際上類似於一個數組,陣列中的每一個元素都對應儲存一個數據。和陣列不同的是,壓縮列表在表頭有三個欄位zlbytes、zltail和zllen,分別表示列表長度、列表尾的偏移量和列表中的entry個數;壓縮列表在表尾還有一個zlend,表示列表結束。
在壓縮列表中,如果我們要查詢定位第一個元素和最後一個元素,可以通過表頭三個欄位的長度直接定位,複雜度是O(1)。而查詢其他元素時,就沒有這麼高效了,只能逐個查詢,此時的複雜度就是O(N)了。
我們再來看下跳錶。
有序連結串列只能逐一查詢元素,導致操作起來非常緩慢,於是就出現了跳錶。具體來說,跳錶在連結串列的基礎上,增加了多級索引,通過索引位置的幾個跳轉,實現資料的快速定位,如下圖所示:
如果我們要在連結串列中查詢33這個元素,只能從頭開始遍歷連結串列,查詢6次,直到找到33為止。此時,複雜度是O(N),查詢效率很低。
為了提高查詢速度,我們來增加一級索引:從第一個元素開始,每兩個元素選一個出來作為索引。這些索引再通過指標指向原始的連結串列。例如,從前兩個元素中抽取元素1作為一級索引,從第三、四個元素中抽取元素11作為一級索引。此時,我們只需要4次查詢就能定位到元素33了。
如果我們還想再快,可以再增加二級索引:從一級索引中,再抽取部分元素作為二級索引。例如,從一級索引中抽取1、27、100作為二級索引,二級索引指向一級索引。這樣,我們只需要3次查詢,就能定位到元素33了。
可以看到,這個查詢過程就是在多級索引上跳來跳去,最後定位到元素。這也正好符合“跳”表的叫法。當資料量很大時,跳錶的查詢複雜度就是O(logN)。
好了,我們現在可以按照查詢的時間複雜度給這些資料結構分下類了:
不同操作的複雜度
集合型別的操作型別很多,有讀寫單個集合元素的,例如HGET、HSET,也有操作多個元素的,例如SADD,還有對整個集合進行遍歷操作的,例如SMEMBERS。這麼多操作,它們的複雜度也各不相同。而複雜度的高低又是我們選擇集合型別的重要依據。
我總結了一個“四句口訣”,希望能幫助你快速記住集合常見操作的複雜度。這樣你在使用過程中,就可以提前規避高複雜度操作了。
- 單元素操作是基礎;
- 範圍操作非常耗時;
- 統計操作通常高效;
- 例外情況只有幾個。
第一,單元素操作,是指每一種集合型別對單個數據實現的增刪改查操作。例如,Hash型別的HGET、HSET和HDEL,Set型別的SADD、SREM、SRANDMEMBER等。這些操作的複雜度由集合採用的資料結構決定,例如,HGET、HSET和HDEL是對雜湊表做操作,所以它們的複雜度都是O(1);Set型別用雜湊表作為底層資料結構時,它的SADD、SREM、SRANDMEMBER複雜度也是O(1)。
這裡,有個地方你需要注意一下,集合型別支援同時對多個元素進行增刪改查,例如Hash型別的HMGET和HMSET,Set型別的SADD也支援同時增加多個元素。此時,這些操作的複雜度,就是由單個元素操作複雜度和元素個數決定的。例如,HMSET增加M個元素時,複雜度就從O(1)變成O(M)了。
第二,範圍操作,是指集合型別中的遍歷操作,可以返回集合中的所有資料,比如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的底層資料結構,這既包括了Redis中用來儲存每個鍵和值的全域性雜湊表結構,也包括了支援集合型別實現的雙向連結串列、壓縮列表、整數陣列、雜湊表和跳錶這五大底層結構。
Redis之所以能快速操作鍵值對,一方面是因為O(1)複雜度的雜湊表被廣泛使用,包括String、Hash和Set,它們的操作複雜度基本由雜湊表決定,另一方面,Sorted Set也採用了O(logN)複雜度的跳錶。不過,集合型別的範圍操作,因為要遍歷底層資料結構,複雜度通常是O(N)。這裡,我的建議是:用其他命令來替代,例如可以用SCAN來代替,避免在Redis內部產生費時的全集合遍歷操作。
當然,我們不能忘了複雜度較高的List型別,它的兩種底層實現結構:雙向連結串列和壓縮列表的操作複雜度都是O(N)。因此,我的建議是:因地制宜地使用List型別。例如,既然它的POP/PUSH效率很高,那麼就將它主要用於FIFO佇列場景,而不是作為一個可以隨機讀寫的集合。
Redis資料型別豐富,每個型別的操作繁多,我們通常無法一下子記住所有操作的複雜度。所以,最好的辦法就是掌握原理,以不變應萬變。這裡,你可以看到,一旦掌握了資料結構基本原理,你可以從原理上推斷不同操作的複雜度,即使這個操作你不一定熟悉。這樣一來,你不用死記硬背,也能快速合理地做出選擇了。
每課一問
整數陣列和壓縮列表在查詢時間複雜度方面並沒有很大的優勢,那為什麼Redis還會把它們作為底層資料結構呢?
資料結構是瞭解Redis效能的必修課,如果你身邊還有不太清楚資料結構的朋友,歡迎你把今天的內容分享給他/她,期待你在留言區和我交流討論。