資料型別與底層原理

pinoky發表於2024-09-14

資料型別與底層原理

資料結構

雜湊表

redis使用鏈式雜湊來解決雜湊衝突,其Hash表實質上是一個二維陣列,其中每一項就是一個指向雜湊項(dictEntry)的指標

typedef struct dictht {
    dictEntry **table; //二維陣列
    unsigned long size; //Hash表大小
    unsigned long sizemask;
    unsigned long used;
} dictht;
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //鍵值對的值是由聯合體決定的,如果該值本身是整數或浮點數,就不需要用指標浪費空間了
    struct dictEntry *next;
} dictEntry;

當隨著連結串列長度的增加,Hash 表在一個位置上查詢雜湊項的耗時就會增加,從而增加了 Hash 表的整體查詢時間,這樣會導致 Hash 表的效能下降,於是redis決定rehash

  • 什麼時候rehash?

    img

    當進行插入或修改鍵值對的時候,都會透過_dictExpandIfNeeded判斷,當前 Hash 表當前承載的元素個數(d->ht[0].used)和 Hash 表當前設定的大小(d->ht[0].size)的比值,是否大於5(這個比值叫做負載因子),如果負載因子大於5且當前沒有 RDB 子程序也沒有 AOF 子程序,即開始rehash

  • 怎麼樣進行rehash

    img

    redis在dict結構體中定義了dictht ht[2];兩個Hash表,交替使用,用於rehash操作,其中備用表ht[1]會先擴容到ht[0]已使用大小的兩倍,即dictExpand(d, d->ht[0].used*2);

  • 從rehash到漸進式rehash

    因為在rehash過程中,鍵值對會被重新hash到新的位置,這個複製過程中redis主執行緒會被阻塞,為了減少開銷而是用漸進式rehash即不會一次性複製所有的ht[0]中的資料到ht[1],而是分批複製,每次複製一個bucket裡的資料

    主要依賴於兩個函式實現:dictRehash _dictRehashStep

    • dictRehashimg

      在這其中,dictResh函式透過 rehashidx變數來確定本次遷移的目標bucket(比如說rehashidx為0,即遷移ht[0]第一個bucket,以此類推),如果當前bucket為空則將rehashidx ++,檢查下一個bucket

      如果當前bucket有資料,則將這些資料重新雜湊到ht[1]中,直到bucket為空將rehashidx ++

      如果連續檢查bucket為空則停止執行(因為在rehash過程中主執行緒阻塞,避免影響redis效能)

    • _dictRehashStep:給dictResh函式傳入引數為1:即一次遷移一個bucket

跳錶

跳錶:多層的有序連結串列

其跳錶節點的定義如下:

typedef struct zskiplistNode {
    //Sorted Set中的元素
    sds ele;
    
    //元素權重值
    double score;
    
    //後向指標
    struct zskiplistNode *backward;
    
    //節點的level陣列,儲存每層上的前向指標和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;  //記錄該層上的下一個節點的指標
        unsigned long span;  //跨度:記錄forward指標和當前指標跨越了幾個level0上的節點
    } level[];  //每個節點都對應一個zskiplistLevel結構體,也對應了跳錶的一層
} zskiplistNode;

img跳錶的定義如下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  //頭節點尾節點
    unsigned long length;				  //跳錶長度
    int level;							  //跳錶最大層數
} zskiplist;

跳錶節點的查詢

從頭節點的最高層開始查詢下一個節點,有兩個判斷條件:元素權重,以及SDS型別資料

  • 如果查詢到的節點元素權重 < 要查詢的權重,則訪問該層的下一個節點

  • 如果權重相等,但SDS資料 < 要查詢的資料,也繼續訪問該層下個節點

  • 如果以上條件都不滿足,則使用level陣列裡的下一層指標,到下一層指標裡尋找

    //獲取跳錶的表頭
    x = zsl->header;
    //從最大層數開始逐一遍歷
    for (i = zsl->level-1; i >= 0; i--) {
       ...
       while (x->level[i].forward && (x->level[i].forward->score < score || (x->level[i].forward->score == score 
        && sdscmp(x->level[i].forward->ele,ele) < 0))) {
          ...
          x = x->level[i].forward;
        }
        ...
    }
    

跳錶節點層數的設計

  • 每一層的結點數是低一層是1/2,類似於二分的思想,可以保證查詢效率在O(logN),但當刪除、新增節點後,需要調整節點,帶來額外的操作開銷
  • redis採用的是:隨機生成每個結點的層數,採用zslRandomLevel決定,先把層數初始化為1,然後生成隨機數,每增加一層的機率不超過1/4

記憶體友好型資料結構設計

redis對三種資料結構針對記憶體使用效率做了設計最佳化:簡單動態字串SDS,壓縮列表ziplist,整數集合intset

redisObject基本資料物件結構體的設計

redisObject主要功能是用來儲存鍵值對中的值,定義如下

typedef struct redisObject {
    unsigned type:4; //redisObject的資料型別,4個bits
    unsigned encoding:4; //redisObject的編碼型別,4個bits
    unsigned lru:LRU_BITS;  //redisObject的LRU時間,LRU_BITS為24個bits
    int refcount; //redisObject的引用計數,4個位元組
    void *ptr; //指向值的指標,8個位元組
} robj;

採用了C語言中的位域定位方法:當一個變數佔用不了一個資料型別的所有bits時,使用該方法把一個資料型別劃分成多個位域,每個位域定義一個變數,實現一個資料型別可定義多個變數

比如此處,一個unsigned型別為4個位元組32bits,採用位域定位方法只需要4位元組就可以儲存三個變數;而不需要三個變數分別用unsigned定義消耗12位元組,節省8位元組開銷

字串SDS

為什麼redis不直接使用char*作為字串的實現?

  1. char*會導致資料在\0被截斷,而redis希望儲存任意二進位制資料
  2. char*實現的字串操作複雜度很高,比如說strlen、strcat都要求要遍歷到末尾\0,而redis希望對字串高效操作

redis的字串實現:SDS

img

typedef char* sds;sds實際上就是char*,只不過在此基礎上新增了其他後設資料資訊

一共有五種不同的型別,sdshdr8,sdshdr16,sdshdr32,sdshdr64等,區別在於len和alloc的型別不同,如下所示,sdshdr8的len和alloc就是uint_t8,只佔用1位元組

目的是靈活儲存不同大小的字串,從而有效節省記憶體空間(儲存小字串的時候結構頭佔用空間也比較小),同時使用了 __attribute__ ((__packed__))採用緊湊的方式分配記憶體,這樣編譯器就不會做位元組對齊了,進一步節省記憶體空間

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字元陣列現有長度*/
    uint8_t alloc; /* 字元陣列的已分配空間,不包括結構體和\0結束字元*/
    unsigned char flags; /* SDS型別*/
    char buf[]; /*字元陣列*/
};

同時sds透過記錄字元陣列的使用長度和分配空間大小,避免了對字串的遍歷操作,降低了操作開銷,比如sdscatlen用於追加字串,相比strcat就不需要再遍歷字串才能追加了img

此外,在儲存較小字串的時候,SDS還採用了嵌入式字串的方法

img

當一個字串建立時,會判斷該字串是否大於44位元組

如果是的話呼叫createRawStringObject建立普通字串,建立過程為:分別給redisObject和SDS結構體分配空間,然後將SDS指標賦給redisObject的ptr,需要分配兩次記憶體,既帶來記憶體分配開銷,也會導致記憶體碎片

如果字串小於44位元組,則使用嵌入式字串方法

createEmbeddedStringObject函式會分配一塊連續的記憶體空間,存放redisObject結構體、sdshdr8、字串和末尾的\0,讓SDS結構指標指向sdshdr8起點,讓redisObject指標指向字串起點,最後把引數中的字串複製到sds中的字元陣列並新增結束字元,緊湊放置兩個結構體避免記憶體碎片以及兩次分配開銷img

壓縮列表的設計

ziplist壓縮列表本身就是一塊連續的記憶體空間,使用不同的編碼來儲存資料

其建立函式如下:

unsigned char *ziplistNew(void) {
    //初始分配的大小
    unsigned int bytes = ZIPLIST_HEADER_SIZE + ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    …
   //將列表尾設定為ZIP_END,表示列表結束
    zl[bytes-1] = ZIP_END;
    return zl;
}

img

以上為初始建立壓縮列表,列表裡還未存入列表項的空間佈局

img

以上為存入列表項後的佈局,每個列表項包含三個內容:前一項長度,當前項的長度編碼,實際資料

ziplist對於列表項prevlen和encoding使用到了編碼技術使用不同數量的位元組來表示儲存的資訊,實際資料則是正常用整型或字串儲存,因為如果用相同的位元組數儲存一個大長度和小長度,對於小長度來說就是一種浪費

  • prevlen:如果前一個列表項小於254位元組,就是用1位元組表示prevlen;如果大於254位元組,就用5位元組表示prevlen(其中將第一個位元組設定為254,然後在2~5個位元組表示長度)
  • encoding:對於整數,使用1位元組來表示;對於字串的不同長度,分別使用1,2,5個位元組來表示encoding

ziplist的不足

查詢複雜度高

由於使用不同數量的位元組來表示儲存的資訊,所以中間元素的偏移量都是不確定的,需要從列表頭或列表尾遍歷,查詢中間資料的複雜度太高,一旦ziplist裡的元素個數多了,它的查詢效率就會降低

連鎖更新風險

因為在列表項中的每一項都儲存了prevlen,而這個prevlen的位元組數還是不固定的,如果在非列表末尾插入元素,後面元素的prevlen和prevlensize可能會發生變化,可能會引起後續項需要新增空間,然後導致連鎖更新,這會導致ziplist佔用的記憶體空間需要多次重新分配,影響其效能

影響效能的地方在於:多次擴容導致的多次分配記憶體

img

quicklist

為了解決ziplist的弊端,redis設計了quicklist,一個quicklist是一個連結串列,而連結串列中每個元素是一個ziplist

其quicklistnode的資料結構如下:

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前一個quicklistNode
    struct quicklistNode *next;     //後一個quicklistNode
    unsigned char *zl;              //quicklistNode指向的ziplist
    unsigned int sz;                //ziplist的位元組大小
    unsigned int count : 16;        //ziplist中的元素個數 
    ....
} quicklistNode;

而quicklist的資料結構定義如下:

typedef struct quicklist {
    quicklistNode *head;      //quicklist的連結串列頭
    quicklistNode *tail;      //quicklist的連結串列尾
    unsigned long count;     //所有ziplist中的總元素個數
    unsigned long len;       //quicklistNodes的個數
    ...
} quicklist;

當在插入一個新元素的時候,會檢查插入位置的ziplist是否能夠容納這個元素,然後判斷新插入資料大小是否滿足要求:單個ziplist是否不超過8KB || 單個ziplist裡元素個數是否滿足要求,如果滿足一個就在當前quicklistnode節點的ziplist上插入,否則就新增一個ziplist

減少了資料插入時記憶體空間的重新分配和記憶體資料的複製,也限制了單個節點上ziplist的大小

整數集合

intset的設計作為底層結構來實現set資料型別,也是一塊連續的記憶體空間,避免記憶體碎片並提高記憶體使用效率

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

共享物件

由於redis例項執行時有些資料可能會被經常訪問,比如常見的整數、redis協議中常見的回覆資訊、報錯資訊等,為了避免在記憶體反覆建立這些資料,就將這些資料建立為共享物件,當上層應用需要訪問時直接讀取即可(但這種物件主要適用於只讀場景)

sorted set

核心結構設計採用跳錶:支援高效的範圍查詢,ZRANGEBYSCORE;同時採用雜湊表進行索引:可以以O(1)返回某個元素的權重,ZSCORE

其結構體定義如下,採用了雜湊表dict 以及 跳錶zskiplsit,zset建立時會分別呼叫dictCreate和zslCreate

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

它將元素儲存在了雜湊表中作為雜湊表的key,然後將value指向元素在跳錶中的權重

當新增元素時,呼叫zsetAdd,首選使用雜湊表的dictFind查詢插入元素是否存在

  • 不存在:直接呼叫跳錶元素插入函式和雜湊表元素插入函式
  • 已經存在:zsetAdd判斷是否增加元素權重值
    • 權重值發生變化,呼叫zslUpdateScore,更新條表中的元素權重值,然後把雜湊表中該元素的value指向權重值

redis的記憶體管理

我覺得這個可以分成五個方面來說

一個是記憶體最佳化:redis使用了很多特殊的資料結構來減少儲存重複資料的開銷,比如說使用壓縮演算法來減小儲存的資料量,用點陣圖來緊湊地表示一組布林值之類的

第二個是記憶體回收,redis使用寫時複製來進行記憶體回收,就是說當一個key被修改的時候,redis會建立一個副本,然後修改應用到副本上,但是保持原始的鍵不變,這樣在多個客戶端同時讀取一個鍵的時候,他們讀取的都是各自的副本,減少了讀取操作的鎖競爭;同理的,多個客戶端同時修改一個鍵的時候,也不需要爭奪寫入權,提高了寫入操作的效率

第三個過期鍵刪除策略,redis使用了惰性刪除的策略來處理過期鍵,也就是說它不會立刻刪除過期鍵,只是會在訪問資料時檢查是否過期,等到了必要的時候再進行刪除

第四個是記憶體淘汰策略,redis提供了比如說lru演算法,lfu演算法,隨機演算法,然後還有分成是針對過期資料進行淘汰,還是針對所有資料進行淘汰,並且它還針對自己的特點對lru演算法進行了最佳化

最後是記憶體碎片整理,因為作業系統的記憶體分配機制以及redis自身鍵值對大小不一的原因,它是會產生記憶體碎片的,redis使用了記憶體碎片整理技術,可以把散落在記憶體塊裡的資料重新整理到連續記憶體塊,但是由於redis是單執行緒的嘛,所以這種清理是有代價的,它需要redis同步等待資料進行複製

redis介紹,為什麼它比較快

首先最顯而易見的原因就是,它是記憶體儲存型系統,它把所有的資料儲存在記憶體裡,這使得它避免了磁碟讀寫操作的開銷,從而可以實現非常低延遲的讀寫操作,

然後第二是它採用了單執行緒的事件驅動模型,所有的請求都在一個事件迴圈裡執行,這種架構簡化了併發控制和資料一致性的問題,避免了執行緒切換的開銷

第三個原因是它還內建了很多種高效的資料結構,比如說雜湊表,字串,集合等等,這些資料結構都在內部進行了最佳化,可以高效地進行增刪改查

最後是它還使用了自定義的面向文字的協議來進行客戶端和伺服器之間的通訊,同時使用到了批次操作和通道的技術,減少客戶端和伺服器之間的網路往返次數,提高了網路通訊的效率

redis資料型別和資料結構有哪些

一共有五種資料型別,分別是string,List,Hash,Sorted set還有set

其中String的底層資料結構是SDS簡單動態字串,List底層是雙向連結串列和壓縮列表,Hash底層是壓縮列表和雜湊表,Sorted set底層是壓縮列表和跳錶,然後set底層是雜湊表和整數陣列

Hash資料結構的底層實現原理

Hash資料結構底層使用了hash table來實現,具體來說,redis裡的雜湊表就是一個陣列,陣列的每個元素被稱為”桶“,裡面儲存著一個連結串列的頭節點,然後連結串列的節點裡包含了雜湊表裡的鍵值對。

為了將鍵對映到到陣列索引,redis使用到了murmurhash作為雜湊函式,將鍵轉化為索引值,讓它均勻地分佈在陣列中;同時edis使用了鏈式雜湊來解決雜湊衝突的問題,具體來說就是當發生衝突的時候,新的鍵值對會被插入到目標桶對應的連結串列中

redis還維護了一個負載因子來保持雜湊表的效率,當負載因子過高的時候,它就會使用漸進式rehash的方式來擴大陣列的大小,減少衝突的機率

漸進式rehash的過程:

首先是為新雜湊表分配空間,redis會重新建立一個大小為當前雜湊表兩倍的雜湊表,然後將這個新雜湊表設定為主雜湊表,並將伺服器的rehashidx設定為0,表示rehash從索引為0的雜湊表節點開始

然後redis會在後臺以非同步的方式逐步將舊雜湊表裡的資料遷移到新雜湊表,避免了一次性大規模的資料複製,在每次遷移完成後它會逐步增加rehashidx的值,直到rehashidx的值增加到雜湊表的大小時,表明遷移完成,新雜湊表徹底取代舊雜湊表

跳錶實現原理

跳錶就是在連結串列的基礎上增加多級索引,這些新增的指向其他節點的指標就叫做跳躍指標,透過跳躍指標的跳轉,我們就可以在查詢的過程中跳過一些元素,來實現資料的快速定位

然後跳錶它會有很多層,每一層都是一個有序連結串列,最底層包含了所有的元素,然後每個更高層連結串列都是前一層連結串列的子集,那我們在進行查詢操作的時候就可以從最高層級開始,根據分值大小不斷地向下層移動,直到找到目標元素為止

相關文章