重學Redis數據結構

基礎數據結構

Redis 有 5 種基礎數據結構,分別爲:string、list 、set 、hash 和 zset 。熟練掌握這5種基本數據結構的使用是 Redis 最基礎也最重要的部分。當然,如果你還掌握了 Bit array 和 HyperLogLog ,那你將會是整條該最亮的仔。

在進入正題之前,你需要清楚一點:Redis 所有的數據結構都是以唯一的 key 字符串作爲名稱,然後通過這個唯一 key 值來獲取相應的 value 數據。差異就在於 value 的結構不一樣。這裏的 value結構就是上面提到的內容。

string

string 是 Redis 最常用並且最簡單的數據結構。一個常見的用途就是緩存用戶信息。我們將用戶信息使用 JSON 序列化成字符串,然後將序列化後的字符串使用 string 結構緩存起來。同樣,取用戶信息會經過一次反序列化的過程。當然你也可以使用 hash 結構,至於兩者的區別在本文末尾進行描述。

Redis 的字符串是動態字符串,是可以修改的字符串,內部結構實現上類似於 Java 的 ArrayList,採用預分配冗餘空間的方式來減少擴容次數。如果你熟悉 Java 的話,你此刻應該清楚 string 的低層數據結構是怎麼樣的了。如圖所示:

在這裏插入圖片描述

SDS

Redis 的字符串叫做「SDS」,也就是 Simple Dynamic String。它的結構是一個帶長度信息的字節數組。

struct SDS<T> {
 T capacity; // 數組容量,記住這個類型T,後面要考
 T len; // 數組長度
 byte flags; // 特殊標識位,不理睬它
 byte[] content; // 數組內容
}

結合SDS的結構體和上述結構圖,我們可以很清楚的知道,content 裏面存儲了真正的字符串內容,是以字節數組的形式存儲的,這一點很重要,先記住,後面要考。capacity 表示所分配數組的長度,len 表示字符串的實際長度。

前面我們就提到,string 是可以修改的字符串,那它就要支持 append 操作。我們來看看它的源碼:

sds sdscatlen(sds s, const void *t, size_t len) {
 size_t curlen = sdslen(s);
 // 按需調整空間,如果 capacity 不夠容納追加的內容,就會重新分配字節數組並複製原字符串的內容到新數組中
 s = sdsMakeRoomFor(s,len);
 if (s == NULL) return NULL; // 內存不足
 memcpy(s+curlen, t, len); // 追加目標字符串的內容到字節數組中
 sdssetlen(s, curlen+len); // 設置追加後的長度值
 s[curlen+len] = '\0'; // 讓字符串以\0 結尾
 return s;
}

我們需要清楚的一點是 Redis 規定字符串的長度不得超過 512M 字節。創建字符串時 len 和 capacity 一樣長,不會多分配冗餘空間,這是因爲絕大多數場景下我們不會使用 append 操作來修改字符串。

embstr vs raw

Redis 的字符串有兩種存儲方式,長度不超過 44 字節時,使用 emb 形式存儲,當長度超過 44 字節時,使用 raw 形式存儲。

這兩種方式有何不同呢?爲什麼分界線是 44 呢?

在說明這個問題之前,我們需要先了解一下 Redis 對象頭結構體,所有的 Redis 對象都有下面的這個結構頭:

struct RedisObject {
 int4 type; // 4bits
 int4 encoding; // 4bits
 int24 lru; // 24bits
 int32 refcount; // 4bytes
 void *ptr; // 8bytes,64-bit system
} robj;

關於對象頭的信息這裏就不詳細展開了,但是從上面的結構中,我們可以計算出對象頭需要的存儲空間爲 16 字節。

接着我們再看 SDS 結構體的大小,前面我們已經給出了SDS的結構體,關於 capacity 和 len 的類型 T 這一點,是由於 Redis 爲了對內存做極致的優化,在字符串比較短時使用 byte 和 short,不同長度的字符串使用不同的類型來表示。所以我們就能得到SDS所需要的最少存儲空間。意味着分配一個字符串的最小空間佔用爲 19 字節 (16+3)。結構如下:

struct SDS {
 int8 capacity; // 1byte
 int8 len; // 1byte
 int8 flags; // 1byte
 byte[] content; // 內聯數組,長度爲 capacity
}

embstr 和 raw 在內存上的結構如下圖所示:

在這裏插入圖片描述

embstr 它將 RedisObject 對象頭和 SDS 對 象連續存在一起,使用 malloc 方法一次分配。而 raw 存儲形式不一樣,它需要兩次 malloc,兩個對象頭在內存地址上一般是不連續的。

而內存分配器 jemalloc/tcmalloc 等分配內存大小的單位都是 2、4、8、16、32、64 等等,爲了能容納一個完整的 embstr 對象,jemalloc 最少會分配 32 字節的空間(19>16),如果字符串再稍微長一點,那就是 64 字節的空間。如果總體超出了 64 字節,Redis 認爲它是一個大字符串,不再使用 emdstr 形式存儲,而該用 raw 形式。

當內存分配器分配了 64 空間時,那這個字符串的長度最大可以是多少呢?這個長度就是 44。那爲什麼是 44 呢?

前面我們提到 SDS 結構體中的 content 中的字符串是以字節 \0 結尾的字符串,之所以多出這樣一個字節,是爲了便於直接使用 glibc 的字符串處理函數。留給 content 的長度最多隻有 44(64-19-1) 字節了。

字符串在長度小於 1M 之前,擴容空間採用加倍策略,也就是保留 100% 的冗餘空間。當長度超過 1M 之後,爲了避免加倍後的冗餘空間過大而導致浪費,每次擴容只會多分 配 1M 大小的冗餘空間。

list

list 它是鏈表而不是數組,既然它的底層實現是基於鏈表的數據結構,那麼它就具備了鏈表的有點和缺點。插入、刪除時間複雜度O(1),索引查詢時間複雜度O(n)。關於時間複雜度有興趣的同學可以瞭解自行一下算法和數據結構相關的內容。

Redis 底層存儲的還不是一個簡單的 linkedlist,而是稱之爲快速鏈表 quicklist 的一個結構。quicklist 是 ziplist 和 linkedlist 的混合體。

ziplist

壓縮列表是一塊連續的內存空間,結構體如下:

struct ziplist<T> {
 int32 zlbytes; // 整個壓縮列表佔用字節數
 int32 zltail_offset; // 最後一個元素距離壓縮列表起始位置的偏移量,用於快速定位到最後一個節點
 int16 zllength; // 元素個數
 T[] entries; // 元素內容列表,挨個挨個緊湊存儲
 int8 zlend; // 標誌壓縮列表的結束,值恆爲 0xFF
}
struct entry {
 int<var> prevlen; // 前一個 entry 的字節長度
 int<var> encoding; // 元素類型編碼
 optional byte[] content; // 元素內容
}

在這裏插入圖片描述

ztail_offset 是支持雙向遍歷的關鍵,用來快速定位到最後一 個元素。prevlen 它是一個變長的整數,當字符串長度小於 254 時,使用一個字節表示;如果達到或超出 254 那就使用 5 個字節來表示。這意味着如果某個 entry 經過了修改操作從 253 字節變成了 254 字節,那麼它的下一個 entry 的 prevlen 字段就要更新,從 1 個字節擴展到 5 個字節;那麼下下個entry 的 prevlen 字段還得繼續更新,形成了級聯更新。

quicklist

quicklist 是 ziplist 和 linkedlist 的混合體,它將 linkedlist 按段切分,每一段使用 ziplist 來緊湊存儲,多個 ziplist 之間使用雙向指針串接起來。

在這裏插入圖片描述

quicklist 內部默認單個 ziplist 長度爲 8k 字節,超出了這個字節數,就會新起一個 ziplist。

爲什麼不只使用 ziplist 或者是 linkedlist ,而是要結合起來使用呢?

如果只使用 ziplist,由於它是連續的固定空間,新增元素是必定要頻繁擴容,如果 ziplist 佔據內存太大,重新分配內存和拷貝內存就會有很大的消耗。還有更新會涉及級聯更新,這個也是不可忽視的消耗。

如果只使用 linkedlist ,普通的鏈表每個元素都需要附加兩個指針,會比較浪費空間,而且會加重內存的碎片化,導致剩餘內存足夠的情況下無法分配空間。

所以 Redis 將 linkedlist 和 ziplist 結合起來組成了 quicklist。也就是將多個 ziplist 使用雙向指針串起來使用。這樣既滿足了快速的插入刪除性能,又不會出現太大的空間冗餘,雖然碎片化無法徹底解決,但相對僅使用 linkedlist 來說,已經好很多了。

所以說 Redis 爲了內存利用率可謂是絞盡腦汁了。

hash

Redis 的字典相當於 Java 語言裏面的 HashMap,熟悉 Java 的同學肯定十分了解 HashMap 的數據結構了,它是由數組+鏈表的數據結構,Redis 裏的實現也是一樣的,在發生 hash 碰撞時,就會將碰撞的元素使用鏈表串接起來。大概結構如下圖所示:

在這裏插入圖片描述

dict

在這裏插入圖片描述

dict 結構內部包含兩個 hashtable,通常情況下只有一個 hashtable 是有值的。但是在 dict 擴容縮容時,需要分配新的 hashtable,然後進行漸進式搬遷,這時候兩個 hashtable 存儲的分別是舊的 hashtable 和新的 hashtable。待搬遷結束後,舊的 hashtable 被刪除,新的 hashtable 取而代之。

漸進式 rehash

大家可以想一下,如果 Redis 採用 Java 中 HashMap 的 rehash 方式的話,在 rehash 期間是同步的,對於單線程的 Redis 來說,就意味着無法對外提供服務,如果 dicti 很大的話,阻塞時間將會很長,這對於作爲存儲系統的 Redis 來說是無法接受的。所以 Redis 使用漸進式 rehash 小步搬遷。

擴容

正常情況下,當 hash 表中元素的個數等於第一維數組的長度時,就會開始擴容,擴容 的新數組是原數組大小的 2 倍。

縮容

當 hash 表因爲元素的逐漸刪除變得越來越稀疏時,Redis 會對 hash 表進行縮容來減少 hash 表的第一維數組空間佔用。縮容的條件是元素個數低於數組長度的 10%。

set

Redis 的集合相當於 Java 語言裏面的 HashSet,它的 內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL。

zset

zset 可能是 Redis 提供的最爲特色的數據結構,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以給每個 value 賦予一個 score,代表這個 value 的排序權重。zset 的內部實現是一個 hash 字典加一個跳躍列表 (skiplist)。

在這裏插入圖片描述

Redis 的跳躍表共有 64 層,kv 之間使用指針串起來形成了雙向鏈表結構,它們是有序排列的,從小到大。不同的 kv 層高可能不一樣,層數越高的 kv 越少。同一層的 kv 會使用指針串起來。每一個層元素的遍歷都是從 kv header 出發。

查找過程

在這裏插入圖片描述

我們要定位到那個紫色的 kv,需要從 header 的最高層開始遍歷找到第一個節點 (最後一個比「我」小的元素),然後從這個節點開始降一層再遍歷找到第二個節點 (最後一個比「我」小的元素),然後一直降到最底層進行遍歷就找到了期望的節點 (最底層的最後一個比我「小」的元素)。

如果 score 值都一樣呢?

Redis 自然考慮到了這一點,所以 zset 的排序元素不只看 score 值,如果 score 值相同還需要再比較 value 值 (字符串比較)。

我們將中間經過的一系列節點稱之爲「搜索路徑」,有了這個搜索路徑,我們就可以插入這個新節點了。

插入過程

首先我們在搜索合適插入點的過程中將「搜索路徑」摸出來了,然後就可以開始創建新 節點了,創建的時候需要給這個節點隨機分配一個層數,再將搜索路徑上的節點和這個新節 點通過前向後向指針串起來。如果分配的新節點的高度高於當前跳躍列表的最大高度,就需 要更新一下跳躍列表的最大高度。

刪除過程

刪除過程和插入過程類似,都需先把這個「搜索路徑」找出來。然後對於每個層的相關 節點都重排一下前向後向指針就可以了。同時還要注意更新一下最高層數 maxLevel。

更新過程

當我們調用 zadd 方法時,如果對應的 value 不存在,那就是插入過程。如果這個 value 已經存在了,只是調整一下 score 的值,那就需要走一個更新的流程。一個簡單的策略就是先刪除這個元素,再插入這個元素,需要經過兩次路徑搜索。Redis 就是這麼幹的。

那排名 rank 是如何算出來的?

Redis 在 skiplist 的 forward 指針上進行了優化,給每一個 forward 指針都增加了 span 屬 性,span 是「跨度」的意思,表示從前一個節點沿着當前層的 forward 指針跳到當前這個節 點中間會跳過多少個節點。Redis 在插入刪除操作時會小心翼翼地更新 span 值的大小。

struct zslforward {
 zslnode* item;
 long span; // 跨度
}
struct zsl {
 String value;
 double score;
 zslforward*[] forwards; // 多層連接指針
 zslnode* backward; // 回溯指針
}

參考

[1] Redis深度歷險:核心原理和應用實踐.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章