Redis 数据结构 :SDS、链表、字典、跳表、整数集合、压缩列表
文章目錄
- SDS
- 結構分析
- 內存策略
- 空間預分配
- 惰性空間釋放
- 總結
- 鏈表
- 結構分析
- 總結
- 字典
- 結構分析
- rehash
- 漸進式rehash
- 總結
- 跳表
- 結構分析
- 總結
- 整數集合
- 結構分析
- 升級
- 降級
- 總結
- 壓縮列表
- 結構分析
- 連鎖更新
- 總結
SDS
結構分析
由于C字符串存在大量問題,所以在Redis中,并沒有使用C風格字符串,而是自己構建了一個簡單動態字符串即SDS(simple dynamic string)
struct sdshdr { // buf 中已占用空間的長度int len;// buf 中剩余可用空間的長度int free;// 數據空間char buf[]; };為解決C字符串緩沖區溢出問題以及長度計算問題,SDS中引入了len來統計當前已使用空間長度,free來計算剩余的空間長度
C字符串的主要缺陷就是因為它沒有記錄自己的長度,而如果在需要了解長度時,就只能通過O(N)的效率進行一次遍歷
并且因為C字符串沒有統計剩余空間的字段,也沒有容量字段,所以很容易就會因為strcat等函數造成緩沖區的溢出,為彌補這一缺陷,redis在sds中增加了free字段
通過標記剩余空間,當對SDS進行插入操作時,就會提前判斷當前剩余空間是否足夠,如果不足則會先進行空間的拓展,再進行插入,這樣就解決了緩沖區溢出的問題
內存策略
由于Redis作為一個高效的內存數據庫,用于速度要求嚴苛,插入刪除頻繁
的場景,為了提高內存分配的效率,防止大量使用內存重分配而調用系統函數導致的性能損失問題(用戶態和內核態的切換),Redis主要依靠空間預分配和惰性空間釋放來解決這個問題
空間預分配
為減少空間分配的次數,當需要進行空間拓展時,不僅僅會為SDS分配修改所必須要的空間,并且會為SDS預分配額外的未使用空間。
預分配未使用空間的策略如下
- 當SDS修改后的長度小于1MB時,將會預分配大小和當前len一樣的空間(free = len),也就是使空間增長一倍,來減少因為初始時申請大空間導致的連續分配問題
- 當SDS修改后的長度大于等于1MB時,每次分配都會分配1MB的空間,防止空間的浪費。
惰性空間釋放
當我們對SDS進行刪除操作時,并不會立即回收刪除后空余的空間,而是將空余空間以free字段記錄下來,以備后面使用。
這樣做的目的在于防止因為空間縮短后因為再度插入導致的空間拓展問題。
并且如果有需求需要真正釋放空間,Redis也提供了對應的API,所以不必擔心會因為惰性的空間釋放而導致的內存浪費問題。
總結
比起 C 字符串, SDS 具有以下優點:
鏈表
結構分析
typedef struct listNode {// 前置節點struct listNode *prev;// 后置節點struct listNode *next;// 節點的值void *value; } listNode;/** 雙端鏈表迭代器*/ typedef struct listIter {// 當前迭代到的節點listNode *next;// 迭代的方向int direction; } listIter;/** 雙端鏈表結構*/ typedef struct list {// 表頭節點listNode *head;// 表尾節點listNode *tail;// 節點值復制函數void *(*dup)(void *ptr);// 節點值釋放函數void (*free)(void *ptr);// 節點值對比函數int (*match)(void *ptr, void *key);// 鏈表所包含的節點數量unsigned long len;} list;
從上面的結構可以看出,Redis的鏈表是一個帶頭尾的雙端無環鏈表,并且通過len字段記錄了鏈表節點的長度
同時為了實現多態與泛型,鏈表中還提供了dup,free,match屬性來設置相關的函數,使得鏈表支持不同類型的值的存儲
總結
- 鏈表被廣泛用于實現 Redis 的各種功能, 比如列表鍵, 發布與訂閱, 慢查詢, 監視器, 等等。
- 每個鏈表節點由一個 listNode 結構來表示, 每個節點都有一個指向前置節點和后置節點的指針, 所以 Redis 的鏈表實現是雙端鏈表。
- 每個鏈表使用一個 list 結構來表示, 這個結構帶有表頭節點指針、表尾節點指針、以及鏈表長度等信息。
- 因為鏈表表頭節點的前置節點和表尾節點的后置節點都指向 NULL , 所以 Redis 的鏈表實現是無環鏈表。
- 通過為鏈表設置不同的類型特定函數, Redis 的鏈表可以用于保存各種不同類型的值。
字典
結構分析
Redis的字典底層采用了哈希表來進行實現。
首先看看字典底層哈希表的結構
typedef struct dictht {// 哈希表數組dictEntry **table;// 哈希表大小unsigned long size;// 哈希表大小掩碼,用于計算索引值// 總是等于 size - 1unsigned long sizemask;// 該哈希表已有節點的數量unsigned long used;} dictht;
哈希表中記錄了當前的總長度,已有節點,以及當前索引大小(用于哈希函數來計算節點位置)
為解決哈希沖突,Redis字典采用了鏈地址法來構造了哈希桶的結構,也就是哈希數組中的每個元素都是一個鏈表。
下面來看看哈希節點的結構
typedef struct dictEntry {// 鍵void *key;// 值union {void *val;uint64_t u64;int64_t s64;} v;// 指向下個哈希表節點,形成鏈表struct dictEntry *next; } dictEntry;可以看到,為保證鍵值對適用于多重類型,key值使用的時void的形式,而value使用了64位有符號整型和64位無符號整型,void指針的一個聯合體,每個節點使用next來鏈接成一個鏈表
typedef struct dictType {// 計算哈希值的函數unsigned int (*hashFunction)(const void *key);// 復制鍵的函數void *(*keyDup)(void *privdata, const void *key);// 復制值的函數void *(*valDup)(void *privdata, const void *obj);// 對比鍵的函數int (*keyCompare)(void *privdata, const void *key1, const void *key2);// 銷毀鍵的函數void (*keyDestructor)(void *privdata, void *key);// 銷毀值的函數void (*valDestructor)(void *privdata, void *obj);} dictType;為保證字典具有多態及泛型,dictType中提供了如哈希函數以及K-V的各種操作函數,使得字典適用于多重情景
rehash
/** 字典*/ typedef struct dict {// 類型特定函數dictType *type;// 私有數據void *privdata;// 哈希表dictht ht[2];// rehash 索引// 當 rehash 不在進行時,值為 -1int rehashidx; /* rehashing not in progress if rehashidx == -1 */// 目前正在運行的安全迭代器的數量int iterators; /* number of iterators currently running */} dict;從字典的結構中,我們可以看到里面同時存放了兩個哈希表,以及一個rehashidx屬性。
這就牽扯到了字典的核心之一,rehash。
Redis作為一個插入頻繁且對效率要求高的數據庫,當插入的數據過多時,就會因為哈希表中的負載因子過高而導致查詢或者插入的效率降低,此時就需要通過rehash來進行重新擴容并重新映射。
但是如果只是用一個哈希表,映射時就會導致數據庫暫時不可用,作為一個使用頻繁的數據庫,短期的停機幾乎是不可容許的問題,所以Redis設計時采用了雙哈希的結構,并采用了漸進式rehash的方法來解決這個問題。
rehash的步驟如下
- 為ht[1]的哈希表分配空間
- 將ht[0]中的鍵值對重新映射到ht[1]上
- 當ht[0]的數據遷移完成,此時ht[0]為一個空表,此時釋放ht[0],并讓ht[1]成為新的ht[0],再為ht[1]創建一個新的空白哈希表,為下一次的rehash做準備
漸進式rehash
由于數據庫中可能存在大量的數據,而rehash的時候又過長,為了避免因為rehash造成的服務器停機,rehash的過程并不是一次完成的,而是一個多次的,漸進式的過程。
在漸進式rehash的時候,由于數據不斷的進行遷移,無法確定數據處于哪一個表上, 此時如果進行插入、刪除、查找的操作時就會在兩個表上進行,如果在一個表中沒找到對應數據,就會到另一個表中繼續查找。
并且如果此時新插入節點,都會統一的防止在新表ht[1]中,防止對ht[0]的rehash造成干擾,保證ht[0]節點的只減少不增加
總結
- 字典被廣泛用于實現 Redis 的各種功能, 其中包括數據庫和哈希鍵。
- Redis 中的字典使用哈希表作為底層實現, 每個字典帶有兩個哈希表, 一個用于平時使用, 另一個僅在進行 rehash 時使用。
- 當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
- 哈希表使用鏈地址法來解決鍵沖突, 被分配到同一個索引上的多個鍵值對會連接成一個單向鏈表。
- 在對哈希表進行擴展或者收縮操作時, 程序需要將現有哈希表包含的所有鍵值對 rehash 到新哈希表里面, 并且這個 rehash 過程并不是一次性地完成的, 而是漸進式地完成的。
跳表
跳表是一個較為少見的數據結構,如果不了解的可以看看我之前的博客
看了這篇博客,還敢說你不懂跳表嗎?
由于跳表的實現簡單且性能可與平衡樹相媲美,對于大量插入刪除的數據庫來說,跳表只需要進行簡單的鏈表插入和索引的選拔,而不像平衡樹一樣需要進行整體平衡的維持。并且由于在范圍查找上的效率遠遠強于平衡樹,所以Redis底層選取跳表來作為有序集合的底層之一。
結構分析
typedef struct zskiplistNode {// 成員對象robj *obj;// 分值double score;// 后退指針struct zskiplistNode *backward;// 層struct zskiplistLevel {// 前進指針struct zskiplistNode *forward;// 跨度unsigned int span;} level[];} zskiplistNode;跳躍表的查詢從最頂層出發,通過前進指針來往后查找,通過比較節點的分數來判斷當前節點是否與索引匹配,如果查找不到則進入下層繼續查找,并記錄下跨越的層數span來進行排位。
同時為了處理特殊情況,還準備了一個后退指針來進行從表尾到表頭的遍歷,但是與前進不同,后退指針并不存在跳躍,而是只能一個一個向后查詢
跳躍表通過保存表頭和表尾節點,來快速訪問表頭和表尾。并且保存了節點的數量來實現O(1)的長度計算
為了避免因為層數過高導致的大量空間損失,Redis跳躍表的節點高度最高位32層。
總結
- 跳躍表是有序集合的底層實現之一, 除此之外它在 Redis 中沒有其他應用。
- Redis 的跳躍表實現由 zskiplist 和 zskiplistNode 兩個結構組成, 其中 zskiplist 用于保存跳躍表信息(比如表頭節點、表尾節點、長度), 而 zskiplistNode 則用于表示跳躍表節點。
- 每個跳躍表節點的層高都是 1 至 32 之間的隨機數。
- 在同一個跳躍表中, 多個節點可以包含相同的分值, 但每個節點的成員對象必須是唯一的。
- 跳躍表中的節點按照分值大小進行排序, 當分值相同時, 節點按照成員對象的大小進行排序。
整數集合
整數集合時集合鍵的底層實現之一,當集合中的元素全部都是整數值的時候,并且集合中元素不多時,Redis就會使用整數集合來作為集合鍵的底層結構。整數集合具有去重且排序的特性
結構分析
typedef struct intset {// 編碼方式uint32_t encoding;// 集合包含的元素數量uint32_t length;// 保存元素的數組int8_t contents[];} intset;在上面的結構中,雖然content數組的類型是一個8bit的整型,但是數據真正存儲的方式并不是這個類型,而是根據encoding來決定具體的類型,8bit只是作為一個基本單位來進行使用。
例如此時encoding設置為INTSET_ENC_INT16時,數組存儲的格式就有每個元素16bit
如果encoding設置為INTSET_ENC_INT64時,數組存儲的格式就有每個元素64bit
升級
升級的流程
新元素的插入位置
由于會引起升級的元素的類型都必頂比數組中的所有數據都大,所以也就決定了其要么比所有數據都大,要么比所有數據都小(負數),所以插入位置只能是首部和尾部
- 當新元素比所有數據都大時在尾部插入
- 當新元素比所有數據都小時在首部插入
如果插入一個32位的數據,則引起全體升級
分配底層空間
整體升級并挪動位置
插入元素
降級
在整數列表中升級是一個不可逆的過程,即使將所有高類型的數據刪除了,也不會進行降級。
理由是防止因為降級后再次升級帶來的大量數據挪動的問題,在保證了效率的同時,也帶來了一定程度上的空間浪費(非必要時盡量不要升級)
升級帶來的好處
總結
- 整數集合是集合鍵的底層實現之一。
- 整數集合的底層實現為數組, 這個數組以有序、無重復的方式保存集合元素, 在有需要時, 程序會根據新添加元素的類型, 改變這個數組的類型。
- 升級操作為整數集合帶來了操作上的靈活性, 并且盡可能地節約了內存。
- 整數集合只支持升級操作, 不支持降級操作。
壓縮列表
壓縮列表(ziplist)是列表鍵和哈希鍵的底層實現之一。
當一個列表鍵只包含少量列表項, 并且每個列表項要么就是小整數值, 要么就是長度比較短的字符串, 那么 Redis 就會使用壓縮列表來做列表鍵的底層實現。主要核心就是為了節約空間
結構分析
例如這張圖,可以看出當前包含三個節點,總空間為0x50(十進制80),到尾部的偏移量為0x3c(十進制60),節點數量為0x3(十進制3)
每個壓縮列表節點可以由一個整數或者一個字節數組組成
整數的類型可以是以下六種之一:
- 4位的介于0-12的無符號整數
- 1字節的有符號整數
- 3字節的有符號整數
- int16_t
- int32_t
- int64_t
字節數組可以是以下三種之一:
- 長度小于等于63(2^6 -1)字節的字節數組
- 長度小于等于16383(2^14 -1)字節的字節數組
- 長度小于等于4294967295(2^32 -1)字節的字節數組
而壓縮列表節點又有三個屬性組成,分別是previous_entry_length,encoding,content。
previous_entry_length
這個屬性記錄了壓縮列表前一個節點的長度,該屬性根據前一個節點的大小不同可以是1個字節或者5個字節。
- 如果前一個節點的長度小于254個字節,那么previous_entry_length的大小為1個字節,即前一個節點的長度可以使用1個字節表示
- 如果前一個節點的長度大于等于254個字節,那么previous_entry_length的大小為5個字節,第一個字節會被設置為0xFE(十進制的254),之后的四個字節則用于保存前一個節點的長度。
小于254字節時的表示
大于等于254字節時的表示
為什么要這樣設計呢?
由于壓縮列表中的數據以一種不規則的方式進行緊鄰,無法通過后退指針來找到上一個元素,而通過保存上一個節點的長度,用當前的地址減去這個長度,就可以很容易的獲取到了上一個節點的位置,通過一個一個節點向前回溯,來達到從表尾往表頭遍歷的操作
encoding
encoding通過以下規則來記錄content的類型
- 一字節、兩字節或者五字節長, 值的最高位為 00 、 01 或者 10 的是字節數組編碼: 這種編碼表示節點的 content 屬性保存著字節數組, 數組的長度由編碼除去最高兩位之后的其他位記錄;
- 一字節長, 值的最高位以 11 開頭的是整數編碼: 這種編碼表示節點的 content 屬性保存著整數值, 整數值的類型和長度由編碼除去最高兩位之后的其他位記錄;
content
content屬性負責保存節點的值,值的具體類型由上一個字段encoding來決定。
例如存儲字節數組,00表示類型為字節數組,01011表示長度為11
存儲整數值,表示存儲的為整數,類型為int16_t
連鎖更新
當添加或刪除節點時,可能就會因為previous_entry_length的變化導致發生連鎖的更新操作。
假設e1的previous_entry_length只有1個字節,而新插入的節點大小超過了254字節,此時由于e1
的previous_entry_length無法該長度,就會將previous_entry_length的長度更新為5字節。
但是問題來了,假設e1原本的大小為252字節,當previous_entry_length更新后它的大小則超過了254,此時又會引發對e2的更新。
順著這個思路,一直更新下去
同理,刪除也會引發連鎖的更新
從上圖可以看出來,在最壞情況下,會從插入位置一直連鎖更新到末尾,即執行了N次空間重分配, 而每次空間重分配的最壞復雜度為 O(N) , 所以連鎖更新的最壞復雜度為 O(N^2) 。
即使存在這種情況,但是并不影響我們使用壓縮列表
- 壓縮列表里要恰好有多個連續的、長度介于 250 字節至 253 字節之間的節點, 連鎖更新才有可能被引發, 這種情況就和連中彩票一樣,很少見
- 即使出現連鎖更新, 但只要被更新的節點數量不多, 就不會對性能造成任何影響: 比如說, 對三五個節點進行連鎖更新是絕對不會影響性能的;
總結
- 壓縮列表是一種為節約內存而開發的順序型數據結構。
- 壓縮列表被用作列表鍵和哈希鍵的底層實現之一。
- 壓縮列表可以包含多個節點,每個節點可以保存一個字節數組或者整數值。
- 添加新節點到壓縮列表, 或者從壓縮列表中刪除節點, 可能會引發連鎖更新操作, 但這種操作出現的幾率并不高。
總結
以上是生活随笔為你收集整理的Redis 数据结构 :SDS、链表、字典、跳表、整数集合、压缩列表的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 高级数据结构与算法 | 跳跃表(Skip
- 下一篇: 什么是缓存?为什么要使用Redis?