Redis進階三之底層存儲數據結構及內存優化


前言

Redis作為高性能緩存中間件,除了擁有高性能的特點之后,相比於其他緩存而言還支持多種數據結構,而如String、List、Set、SortedSet和Hash都是redis對外支持的數據結構,而內部存儲時實際上和傳統理解上的String、List、Set、SortedSet以及Hash都有所不同。Redis針對不同類型的數據結構底層都進行了優化,會根據不同的數據采用不同的數據結構來進行存儲。

一、Redis對象(RedisObject)

Redis中所有的key都是字符串,但是所有的value存儲的時候實際上都不是直接采用String、List、Set、SortedSet和Hash這些結構來存儲的,而是封裝成了RedisObejct對象。相當於Redis就是一個龐大的<String, RedisObejct>集合

Redis每次新建一個鍵值對時都會創建兩個RedisObject對象,一個是鍵的對象,一個是值的對象。

RedisObject數據結構如下所示:

type: 表示value代表的數據類型,取值范圍為String、List、Set、SortedSet、Hash等五種類型

encoding:表示value的編碼格式,包括int、embstr、raw、ziplist、linkedList、ht、intset、skipList等等

refCount:表示value對象引用計數,當refCount值為0的時候則表示可以被回收了

ptr: 指向底層數據的指針

lru: 上一次被訪問的時間

其中不同類型的數據結構可能會對應多種不同的編碼方式,通過采用不同編碼方式的方法可以達到優化內存的效果。另外相同的數據類型同樣也可能會有不同的編碼方式來優化內存。

二、Redis的編碼機制

Redis針對不同數據結構類型采用了多種編碼方式,分別如下:

編碼方式 描述
int long類型的整數
embstr embstr編碼的簡單動態字符串
raw 簡單動態字符串
ziplist 壓縮列表
linkedlist 雙向鏈表
intset 整數集合
skiplist 跳躍表
hashtable 字典

ziplist是壓縮的列表,存儲數據的內存空間是連續的,占用空間比較少但是處理數據需要耗時,相當於用時間代替了空間

 

主要數據結構不同情況下的編碼方式分別如下:

數據類型 編碼方式 使用條件
String int 值為8個字節的整數類型
embstr 長度小於39個字節的字符串
raw 長度大於39個字節的字符串
List ziplist

當List元素個數小於list-max-ziplist-entries(默認為512個)且

所有的元素值大小都小於list-max-ziplist-value(默認為64個字節)時

linkedlist 無法滿足ziplist的條件時則直接使用LinkedList,且只能升級不能降級為zipList
Set intset

當Set元素個數小於set-max-intset-entries(默認為512個)且

所有的元素都是整數類型時

hashtable 無法滿足intset的條件時直接使用hashtable,且只能升級不能降級為intset
SortedSet ziplist

當有序集合元素個數小於zset-max-ziplist-entries(默認為512個)且

所有的元素值大小都小於zset-max-ziplist-value(默認為64個字節)時

skiplist 無法滿足ziplist的條件時直接使用skiplist,且只能升級不能降級為ziplist
Hash ziplist

當哈希表元素個數小於hash-max-ziplist-entries(默認為512個)且

所有的元素值大小都小於hash-max-ziplist-value(默認為64個字節)時

hashtable 無法滿足ziplist的條件時直接使用hashtable,且只能升級不能降級為ziplist

 

redis在不同的數據情況下采用不同的編碼方式,采用占用內存小的數據結構來達到內存優化的效果。

2.1、字符串編碼方式

1、int編碼

int編碼僅僅用於字符串的value,當字符串的value為整數類型時,此時就用int編碼

2、embstr編碼

embstr是簡單動態字符串(SDS)的一種編碼,專門用於保存長度比較短的字符串。Redis存儲數據都會創建一個RedisObject,RedisObject中有一個屬性ptr是指向具體數據的指針。而保存字符串的數據結構時SDS,也就是sdshdr數據結構

采用embstr編碼方式時,會調用一次內存分配函數分配連續的內存空間同時分配給RedisObject和sdshdr兩個結構。

另外embstr編碼的字符串是只讀的,一旦發生修改就會升級為raw編碼方式。

3、raw編碼

raw也是簡單動態字符串(SDS)的一種編碼,當字符串的長度較長時就采用raw編碼方式。raw編碼方式和embstr編碼方式的區別是raw只需要調用一次內存分配函數,而raw需要分別為RedisObject和sdshdr各申請一次內存分配函數。

raw和embstr保存字符串的效果完全一樣,只不過raw在分配內存時需要多申請,同時釋放內存時也需要比embstr多釋放一次。

 

2.2、列表編碼方式

列表對象的編碼方式有ziplist和linkedlist兩種

ziplist編碼底層是通過壓縮列表實現,壓縮列表的每個節點保存一個列表的元素。列表value的RedisObject對象的ptr指向ziplist對象,

linkedlist編碼底層是通過雙向鏈表實現,鏈表的每個節點保存列表的一個元素。列表value的RedisObject對象的ptr執行linkedlist對象,

當列表的元素同時滿足以下兩個條件時才使用ziplist,否則就使用linkedlist

1、列表的元素個數不能超過512個,可以自定義具體的值

2、列表中所有元素的大小不能超過64個字節,可以自定義具體的值

 

2.3、集合編碼方式

集合的編碼方式有intset和hashtable兩種

intset編碼底層實現就是整數集合,集合中存儲的數據全部是整數類型。

hashtable編碼底層實現就是一個字典,集合的所有元素就存在字典的鍵值對的鍵中,而字典的所有鍵的值都為NULL

當集合的元素同時滿足以下兩個條件時采使用intset,否則就使用hashtable

1、集合的元素個數不能超過512個,可以自定義具體的值

2、集合的所有元素都是整數類型

 

2.4、有序集合編碼方式

有序集合的編碼方式有ziplist和skiplist兩種,有序集合的元素都有兩個屬性,一個是具體的值,一個是用於排序的分數。

ziplist編碼底層是壓縮列表,每個有序集合的元素都需要兩個連續的壓縮列表的節點存儲,一個存儲元素的值一個存儲元素的分數。

另外壓縮列表會將集合元素按分數進行排序,分數較小的排在靠近表頭的位置,分數較大的排在靠近表尾的位置。

skiplist編碼底層采用zset來實現,一個zset同時包含一個字典和一個跳躍表。

當有序集合的元素同時滿足以下兩個條件時采用ziplist,否則采用skiplist

1、當有序集合元素個數小於zset-max-ziplist-entries(默認為512個)且

2、所有的元素值大小都小於zset-max-ziplist-value(默認為64個字節)時

 

 

2.5、哈希對象編碼方式

哈希對象的編碼方式分為ziplist和hashtable兩種

ziplist編碼底層實現也是一個壓縮列表,當哈希對象存儲新的鍵值對時,先將鍵的節點插入到壓縮列表的尾部,然后再將值的節點插入到列表的尾部,所以每一個鍵值對的鍵和值會生成兩個壓縮列表的節點連續存儲在列表中的。並且后插入的節點會在列表尾部;

hashtable編碼底層實現是字典結構,哈希對象的鍵值對就對應了字典中的鍵值對,且鍵和值都是字符串結構。

當哈希對象的元素同時滿足以下兩個條件時才使用ziplist,否則就使用hashtable

1、哈希保存的鍵值對數量不能超過512個,可以自定義具體的值

2、哈希保存的所有鍵值對的值的大小都不可以超過64個字節,可以自定義具體的值

 

三、Redis的底層數據結構

3.1、簡單動態字符串(SDS)

雖然redis由C語言實現,但是redis沒有使用C語言的字符串來用,而是采用了簡單動態字符串簡稱SDS的數據結構來存儲字符串,包括字符串類型的key和value

SDS定義如下:

struct sdshdr{

    /** 記錄buf數組已使用字節數*/
    int len;
  
    /** 記錄buf數組未使用字節數*/
    int free;

    /** 字節數組,用於保存字符串數據*/
    char buf[];
}

 

SDS除了有字節數組之外,還有兩個int類型變量分別記錄已使用和未使用的字節數。這樣可以很方便的讀取字符串的長度

另外由於C語言中的字符串不會存儲自身的長度,底層實現是一個長度為N+1個字符長的數組(1個字符空間保存空字符表示結束標志),所以一旦字符串發生改變,無論是增長或者是縮短都需要重新進行一次內存分配。如果不重新分配內存,那么當字符串增長時會出現內存溢出,當字符串縮短時會造成內存泄露,都是會出現對內存不友好的結果,所以需要對字符串的內存進行重新分配。

而SDS就在C語言字符串實現的基礎之上增加了兩種優化策略,分別是空間預先分配和空間惰性釋放

1、空間預先分配策略

當SDS的len長度小於1M時,預分配的空間和已使用的空間一樣大,比如字符串增長之后len長度為100個字節,那么擴容之后SDS緩沖區的總長度會分配201個字節,其中100個字節已用,另外100個字節作為空閑空間,當后續字符串再增長時,可能就不需要再分配

當SDS的len長度大於1M時,預分配的空間始終保持是1M的空間,比如字符串長度為30M,那么擴容之后空間大小為31M,多余1M保留給后續字符串增長時使用。

所以通過內存預分配策略,當字符串經過N次增長之后,最多只會產生N次內存重新分配,而不是C語言字符串的必然N次內存重新分配,一定程度上是通過犧牲一部分的內存空間代價換來減少內存重新分配帶來的效率提高的結果,相當於空間換時間

案例如下圖示,原SDS保存了字符串“ABC”,后將字符串修改為“ABCDE”,擴容結果如下 

 

2、空間惰性釋放策略

空間惰性釋放策略和空間預分配策略目的一致,同樣是為了減少內存重新分配的次數,當字符串縮短之后,並不會立即將空閑的內存空間釋放,而是僅僅修改free的值表示有空閑空間,並不會將當前空閑的空間立即釋放,以便后續字符串增長時不需重新分配內存。

比如原先SDS值為“ABCDE”,此時free=0,len=6,此時將字符串值修改為“ABC”,那么不會將多余的空間釋放,而是修改free=2,表示有2個字節空間

當然SDS也提高了API,用於顯示的釋放空閑空間,所以無需擔心太多的空閑空間導致的內存泄露問題

總計下SDS相比於C語言字符串的優點

1、O(1)復雜度獲取字符串的長度

2、避免了緩沖區溢出的問題

3、大幅度降低了當字符串修改時導致的內存重新分配次數

4、二進制安全,SDS緩沖區可以保存任意格式的二進制數據,而不是C字符串的僅能保存文本數據

5、SDS兼容了部分C字符串的函數,提高了代碼復用率 

3.2、鏈表

redis中鏈表的實現和其他高級語言的鏈表實現邏輯基本上一致,主要有鏈表節點和鏈表類組成,定義分別如下:

/** 鏈表節點結構定義 */
struct listNode{

   /** 前置節點*/
   struct listNode *prev;

   /** 后置節點*/
   struct listNode *next;

   /** 節點的值*/
   void *value;

}

 

/** 鏈表結構定義*/
struct list{

   /** 頭節點*/
   listNode *head;

   /** 尾節點*/
   listNode *tail;

   /** 節點個數*/
   unsigned long len;
}

 

總結

1、鏈表主要用於redis的列表鍵,發布與訂閱、慢查詢、監視器等;

2、每個鏈表節點都包含前置節點和后置節點的指針,所以是雙端鏈表;

3、頭節點的前置節點和尾節點的后置節點都為空,所以鏈表是無環鏈表;

3.3、字典

字典是一種保存鍵值對的抽象數據結構,在Java語言中字典的實現就是Map,但是C語言中沒有Map數據結構,所以redis需要自行實現字典數據結構,功能和Java中的Map類似。

redis的數據庫底層就是通過字典實現,redis的key和value操作實際就是基於字典的key和value操作。另外redis的哈希數據結構底層也是通過字典實現的。

3.4、跳躍表

跳躍表(SkipList)是一種有序數據結構,通過多個節點同時維持其他多個節點的指針,從而達到快速訪問節點的目的。

跳躍表是redis有序集合的底層實現方案之一,當redis的有序集合數據量達到默認的512個時或者某個key的值的大小達到64K時,就采用跳躍表來實現。

在同一個跳躍表中,每個節點的分數值可以相同,但是節點的成員對象必須是唯一的。優先按分數進行排序,分數相同的情況下按成員對象的值進行排序

3.5、整數集合

整數集合(intset)是redis用於保存整數類型的集合數據結構,定義如下:

typedef struct intset{
    
     /**編碼方式*/
     unint32_t encoding;

     /**集合中元素數量*/
     unint32_t length;

     /**整數數組*/
     int8_t contents[];
}intset;

 

length保存整數集合保存的數據個數,contents用於保存整數數據,按從小到大的順序進行有序存儲。

雖然contents定義的是int8_t類型的值,但是實際上並不一定contents中存儲的就是int8_t類型的值,而是由encoding的值來決定。encoding支持INTSET_ENT_INT8、INTSET_ENT_INT16、INTSET_ENT_INT32、INTSET_ENT_INT64四種類型,所以contents支持存儲int8_t、int16_t、int32_t和int64_t類型的數據。contents只是默認采用int8_t類型,當有int16_t類型的數據需要存入contents中時,就會將contents升級為int16_t類型的數組,同理當存入的數據越來越大時,contents還可以升級為int32_t和int64_t的類型。

這樣做的好處是可以節省內存,當集合中存儲的數據值小時就按占內存小的數據結構存儲,只有當需要存儲數值大的數據結構時才進行升級。但是contents只能從小到大升級而不能從大到小降級。

總結:

1、整數集合是集合鍵的底層實現之一

2、整數集合底層是有序不重復的數組實現

3、當數組存儲的數據類型變化時會進行升級操作,升級機制可以節省內存空間,但是不會進行降級

3.6、壓縮列表

壓縮列表(ziplist)是redis的列表鍵和哈希鍵的底層實現方式之一,當列表或哈希鍵的key數量小於默認的512個時,且每個鍵的值的大小比較小時(64K),那么就采用ziplist來實現底層數據存儲。

壓縮列表顧名思義是內存進行了壓縮的列表,是由一系列特殊編碼的連續的內存塊組成的順序型數據結構,目的是為了節省內存空間。

壓縮列表由任意多個節點組成,每個節點存儲一個字節數組或者是一個整數。

1、壓縮列表的結構

如下圖時:

 

屬性 占用字節 描述
zlbytes 4 記錄壓縮列表占有的字節數,當壓縮列表內存重新分配時以及計算zlend的位置時使用
zltail 4 記錄尾節點距離壓縮列表起始地址有多少字節,通過偏移量可以不需要遍歷整個壓縮列表的情況下確定列表尾節點的地址
zllen 2 記錄壓縮列表節點的個數
entry 不定 壓縮列表的各個節點,節點占用內存的大小取決於存儲的具體數據
zlend 1 特殊值0XFF,十進制的255,表示壓縮列表的結束標志

 

2、壓縮列表的節點

壓縮列表核心是由各個節點組成,每個節點的結構如下圖示:

屬性 取值范圍 描述
previous_entry_length

占用1個或者5個字節,當前一個節點長度小於254個字節時,那么就占用1個字節存儲前一個節點的長度;

當前一個節點長度大於254個字節時,那么就占用5個字節,第1個字節固定存儲0XFE(254),后面4個字節存

存儲前一個節點具體的占用字節數

前一個節點占用字節數
encoding

占用1個或者2個或者5個字節
當content存儲字節數組時,占用1、2或5個字節,最高2位值為00、01或10,其他位存儲數值的長度;

當content存儲整數時,占用1個字節,最高2位值為11,其他位存儲整數的具體類型以及長度

記錄content保存的數據類型以及長度
content 字節數組或者整數值 節點存儲數據內容,類型及長度由encoding存儲

 

 3、連鎖更新的風險

連鎖更新值當新增一個或刪除一個節點時,由於壓縮列表的內存是連續的,可能會連鎖導致其他節點的內存需要重新分配的問題。

比如壓縮列表中目前有4個節點,4個節點的長度都是250~253之間的長度,由於小於254,所以后續節點的previous_entry_length值只需要1個字節存儲即可。

此時在節點1的前面插入新節點,且新節點的長度大於254個字節,那么節點1就需要采用5個字節來存儲新節點的長度值,所以節點1占用的內存空間就會多4個字節,所以會導致節點占用的空間也會超過254個字節;

同理由於節點1長度變化,會導致節點2的previous_entry_length需要由1個字節變成5個字節,從而導致節點2長度也會超過254個字節,同理后續的節點都會受到影響,這就是新增一個節點導致的連鎖更新反應。

雖然連鎖更新的風險比較大,但是實際情況下場景會比較少,因為實際情況下壓縮列表中存在多個連續的占用字節數都在250 ~ 253之間的節點的概率很小,只要連續的這樣的節點不多,連鎖更新的節點不多的話也不會對整體性能帶來影響。

總結

1、壓縮列表是一種連續內存的順序型數據結構,目的是為了節省內存;

2、壓縮列表是redis中列表、有序集合、和哈希的底層實現方式之一;

3、壓縮列表包含多個節點,每個節點可以保存整數也可以保存字節數組;

4、壓縮列表新增節點和刪除節點雖然有連續更新的風險,但是出現的概率非常小。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM