Redis的底層數據結構(轉載)

Redis 中有各種自定義的數據結構,來實現了各種功能,下面一一進行說明。
簡單動態字符串SDS
 
 
Redis 沒有直接使用 C 語言的字符串,而是構建了自己的抽象類型簡單動態字符串(simple dynamic string)。
在 Redis 中,對於所有鍵,都是字符串類型,其底層實現是 SDS,而鍵值對的值,其實最終都是以字符串爲粒度的,底層都是 SDS 實現。(比如列表,其實列表中每一項都是字符串以 SDS 實現的)。
SDS結構
SDS 結構中,包含 char 類型的數組 buf ,每個位置存儲字符,最後一個位置存儲空字符 ‘\0’。另外,還有 free 屬性和 len 屬性。free 屬性的值代表未使用空間的大小,len 屬性代表目前保存的字符串的實際長度,結尾的 ‘\0’ 空字符不計算在內。
SDS 的優勢:
C 語言的字符串不會記錄自己的長度,而是需要進行遍歷獲得,時間複雜度爲 O(n) ,而 SDS 已經封裝了 len 屬性,直接讀取 len 的值就可以獲得長度,不需要遍歷,時間複雜度 O(1) 。
C 語言字符串修改時,有可能發生緩衝區溢出;而 SDS 要修改時,API 會先檢查 SDS 的空間是否滿足修改的要求,如果不滿足,會將 SDS 的空間擴展至執行修改的所需的大小,然後才執行實際的修改操作。
SDS的優化策略
空間預分配
 
空間預分配,用於優化 SDS 的字符串增長操作,當 SDS 的 API 對一個 SDS 進行修改,並且需要對 SDS 進行空間擴展的時候,程序不僅會爲 SDS 分配修改所必須要的空間,還會爲 SDS 分配額外的未使用空間。(這個有點類似於 Java 中的 ArrayList 的空間每次增長擴大爲之前 1.5 倍大小,進行額外的空間預分配)。
具體的分配規則:
- 如果修改後的 SDS 長度 len 小於 1MB,那麼程序分配和 len 屬性相等的未使用空間,此時 free 和 len 的值相同。所以此時數組的實際長度爲 free + len + 1byte(額外的空字符 1 個字節)。
- 如果修改後的 SDS 長度大於 1MB,那麼程序分配 1MB 的未使用空間。實際長度爲 len + 1MB + 1byte。
在擴展 SDS 之前,會檢查未使用空間是否夠用,如果足夠,就不用內存重分配,直接使用剩餘空間即可。
惰性空間釋放
 
惰性空間釋放,用於優化 SDS 的字符串縮短操作,當 SDS 的 API 對一個 SDS 進行縮短時,並不會立即使用內存重分配來回收多出來的字節,而是使用 free 屬性將這些字節的數量記錄下來,等待將來使用。
通過此策略,可以避免內存重分配,同時將來增長操作也有空間。
同時 SDS 也有相應的 API ,用來真正釋放未使用空間,不用擔心內存的浪費。
二進制存儲
 
在 C 語言字符串中,’\0’ 空字符會被認爲是字符串的結束,如果二進制數據中有該字符的存在,會被認爲是字符串的結尾。而 SDS 由於有 len 屬性的存在,使用 len 來判斷字符串是否結束,而不是空字符。這樣就避免了二進制數據的問題,可以用來保存圖片,音頻,視頻等文件的二進制數據。
鏈表
 
C 語言中沒有內置鏈表的數據結構,Redis 實現了自己的鏈表結構。Redis 中列表的底層實現之一就是鏈表。
Redis鏈表
每個鏈表節點都有指向前置節點和後置節點的指針,是一個雙向鏈表。每個鏈表結構,有表頭表尾指針和鏈表長度等信息。
另外表頭節點和前置和表尾節點的後置都是 NULL ,所以是無環鏈表。
字典Map
 
字典是用來保存鍵值對的抽象數據類型。C 語言中沒有內置這種數據結構,Redis 實現了自己的字典結構。
字典在 Redis 中應用很廣泛,Redis 底層數據庫就是用字典來實現的。任意一個鍵值對,無論是什麼類型,都是存放在數據庫的字典中的。
另外,字典還是哈希對象的底層實現之一。
結構如下:
Redis字典結構
字典的 ht[0] 和 h[1] 在 rehash 時使用。
字典的實現
uploading.4e448015.gif轉存失敗重新上傳取消
字典的實現可以參考 Java 中 HashMap 的實現原理:Java集合框架——HashMap源碼分析
新增時,先根據鍵值對的鍵計算出哈希值,然後根據 sizemask 屬性和哈希值,計算索引值——即落入數組中的哪個位置。之後如果有一個位置多個鍵值對要存入時,組成單向鏈表即可。
這裏和 HashMap 的不同之處在於,鏈表添加時總是添加在表頭位置。因爲 dictEntry 節點組成的鏈表沒有指向鏈表表尾的指針,爲了速度考慮,總是將新節點加在鏈表的表頭位置。(爲什麼要這樣,而不是遍歷完整個鏈表後加在鏈表尾部,不遍歷出現重複鍵怎麼辦?)
rehash
 
rehash 也可以參考 Java 中 HashMap 的原理。
負載因子 = 哈希表中已保存的節點數量 / 哈希表數組大小。
當哈希表中存放的鍵值對不斷增多或減少,爲了讓負載因子在一個合理的範圍內,需要對大小進行擴展或者收縮。(這裏類似 HashMap 中的重新散列方法)
1. 字典的 ht[1] 分配空間,空間的大小由 ht[0] 已經使用的鍵值對數量以及執行的擴張和收縮來決定。
- 擴展操作,那麼 ht[1] 分配的空間大小應是比當前 ht[0].used 值的二倍大的第一個 2 的整數冪。(比如當前使用空間 14,那麼找 28 的下一個 2 的整數冪,爲 32)
- 收縮操作,取 ht[0].used 的第一個大於等於的 2 的整數冪。(比如 14,那麼就是 16)
2. 將 ht[0] 中的所有鍵值對,rehash 到 ht[1] 上面:根據新的大小來重新計算所有鍵的哈希和索引,映射到新數組的指定位置上。
3. ht[0] 的所有鍵值對都遷移到 ht[1] 之後,釋放 ht[0] ,然後將 ht[1] 設置爲 ht[0] ,然後在 ht[1] 處新創建空白哈希表,爲下一次 rehash 做準備。
 
擴展和收縮的條件:
擴展的條件
 
    服務器沒有執行 BGSAVE 或者 BGREWRITEAOF 命令,並且哈希表的負載因子大於等於 1 。
    服務器正在執行 BGSAVE 或者 BGREWRITEAOP 命令,並且哈希表的負載因子大於等於 5 。
    這兩種情況根據是否有後臺命令執行來區分,是因爲在執行 BGSAVE 或者 BGREWRITEAOF 的過程中,Redis 需要創建當前服務器進程的子進程,而大多數操作系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率。所以在子進程存在期間,服務器會提高執行擴展操作所需的負載因子,儘可能避免在子進程存在期間進行哈希表的擴展操作,來避免不必要的內存寫入操作,最大限度的節省內存。
 
收縮的條件
 
當哈希表的負載因子小於 0.1 時,自動開始對哈希表進行收縮操作。
漸進式rehash
 
如果鍵值對量巨大時,一次性全部 rehash 必然造成一段時間的停止服務。所以要分多次、漸進式的將鍵值對從 ht[0] 慢慢的 rehash 到 ht[1] 中。
具體過程:
1. 爲 ht[1] 分配空間,同時有 ht[0] 和 ht[1] 兩個哈希表。
2. 在字典中維持一個索引計數器變量 rehashindex ,並將其置爲 0 ,表示 rehash 正式開始。
3. 在 rehash 期間,每次對字典執行添加、刪除、查找或者更新操作時,程序除了執行指定的操作之外,還會順便將 ht[0] 哈希表在 rehashindex 索引上的所有鍵值對 rehash 到 ht[1] 上,當 rehash 工作完成之後,程序將 rehashindex 的值加一。
4. 隨着字典操作的不斷進行,最終在某個時間點,ht[0] 的所有鍵值對都被 rehash 到 ht[1] ,這時程序將 rehashindex 的值置爲 -1 ,表示 rehash 工作完成。
漸進式 rehash 的過程中,更新刪除查找等都會在兩個哈希表上進行,比如查找,先在 ht[0] 中查找,如果沒找到,就去 ht[1] 中查找。而新增操作,直接新增在 ht[1] 中,ht[0] 不會進行任何的新增操作。保證 ht[0] 的數量只減不增,最終變爲空表。
跳躍表
 
跳躍表是一種有序數據結構,通過在每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。Redis 使用跳躍表作爲有序集合鍵的底層實現之一。
跳躍表在 Redis 中,只有兩個地方用到:一個是實現有序集合對象,另一個是在集羣節點中用作內部數據結構。
跳躍表
跳躍表中:
head:指向跳躍表的表頭節點。
tail:指向跳躍表的表尾節點。
level:記錄當前跳躍表中,層數最高的節點的層數(表頭節點的層數不計算)。
length:記錄跳躍表的長度,即包含節點的數量。
level:每一層都有前進指針和跨度,從頭到尾遍歷時,訪問會沿着層的前進指針進行。
BW:後退指針,指向前一個節點,從尾到頭遍歷時使用。
score:分值,跳躍表中的分值按從小到大排列。
obj:成員對象,各個節點保存有各個成員對象。
整數集合
 
整數集合是集合鍵的底層實現之一。當一個集合只包含整數值元素,並且這個集合的元素數量不多時,Redis 就會使用整數集合作爲集合鍵的底層實現。
整數集合是 Redis 保存整數值的集合的抽象數據結構,可以保存 int16_t ,int32_t ,int64_t 的整數值,並且集合中不會出現重複元素。
底層由數組實現,整數集合的每個元素都是數組的一個數組項,各個項在數組中按從小到大排列。length 屬性記錄了包含的元素數量,即數組的長度。
升級
uploading.4e448015.gif轉存失敗重新上傳取消
當一個新元素添加到整數集合中時,如果新元素類型比整數集合現有的所有元素的類型都要長時,整數集合要先進行升級,然後才能將新元素添加到整數集合中。
1. 根據新元素類型,擴展整數集合底層數組的大小,併爲新元素分配空間。
2. 將底層數組現有的所有元素都轉換成新元素相同的類型,並將類型轉換後的元素放置到正確位置上,而且放置過程中需要維持底層數組的有序。
3. 將新元素添加到底層數組中。
因爲引發升級的新元素的長度肯定比現有所有元素都大,纔會出現升級的情況,所以這個值要麼大於所有元素,放置的位置就對應新數組的末尾;要麼小於所有元素,放置的位置在數組的開頭。
升級可以提高靈活性,不用擔心類型錯誤,可以隨意添加不同類型的元素。另外,可以節約內存,只在有需要的時候進行升級。
另外,整數集合不支持降級操作。
壓縮列表
 
壓縮列表(ziplist)是列表鍵和哈希鍵的底層實現之一。當一個列表鍵只包含少量列表項並且每個都是小整數值或者長度比較短的字符串時,Redis 就採用壓縮列表做底層實現。當一個哈希鍵只包含少量鍵值對,並且每個鍵值對的鍵和值也是小整數值或者長度比較短的字符串時,Redis 就採用壓縮列表做底層實現
 
壓縮列表是 Redis 爲了節約內存而實現的,是一系列特殊編碼的連續內存塊組成的順序型數據結構。
壓縮列表
zlbytes :4 字節。記錄整個壓縮列表佔用的內存字節數,在內存重分配或者計算 zlend 的位置時使用。
zltail :4 字節。記錄壓縮列表表尾節點記錄壓縮列表的起始地址有多少個字節,可以通過該屬性直接確定表尾節點的地址,無需遍歷。
zllen :2 字節。記錄了壓縮列表包含的節點數量,由於只有 2 字節大小,那麼小於 65535 時,表示節點數量。等於 65535 時,需要遍歷得到總數。
entry :列表節點,長度不定,由內容決定。
zlend :1 字節,特殊值 0xFF ,用於標記壓縮列表的結束。
 
壓縮列表節點保存一個字節數組或者一個整數值。
字節數組可以是下列值:
- 長度小於等於 2^6-1 字節的字節數組
- 長度小於等於 2^14-1 字節的字節數組
- 長度小於等於 2^32-1 字節的字節數組
整數可以是六種長度:
- 4 位長,介於 0 到 12 之間的無符號整數
- 1 字節長的有符號整數
- 3 字節長的有符號整數
- int16_t 類型整數
- int32_t 類型整數
- int64_t 類型整數
每個壓縮列表節點的結構如圖:
壓縮列表節點結構
previous_entry_length 屬性以字節爲單位,記錄了壓縮列表中前一個節點的長度。該屬性的長度可以是 1 字節或者 5 字節。如果前一個節點的長度小於 254 字節,那麼該屬性長度爲 1 字節,保存小於 254 的值。如果前一節點的長度大於等於 254 字節,那麼長度需要爲 5 字節,屬性的第一字節會被設置爲 0xFE (254) 之後的 4 個字節保存其長度。
壓縮列表的從表尾到表頭遍歷:
1. 首先,有指向壓縮列表表尾節點起始地址的指針 p1 (指向表尾節點的指針可以通過指向壓縮列表起始地址的指針加上 zltail 屬性的值得出);
2. 通過用 p1 減去節點的 previous_entry_length 屬性,得到前一個節點的起始地址的指針。
3. 如此循環,最終從表尾遍歷到表頭節點。
encoding 屬性記錄了節點的 content 屬性所保存的數據的類型和長度:
- 一字節、兩字節或五字節長,值的最高位爲 00、01 或者 10 的是字節數組編碼,字節數組的長度由編碼除去最高兩位之後的其他位記錄;
- 一字節長,值的最高位以 11 開頭的是整數編碼,這種編碼表示保存是整數值,整數值的類型和長度由其他位記錄。
 
出現新增或刪除節點導致 previous_entry_length 1 字節或者 5 字節的長度變化,是連鎖更新的問題,但出現機率比較小,而且數量不多的情況下不會對性能造成影響。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章