Redis 的底層數據結構(壓縮列表)

上一篇咱們介紹了 redis 中的整數集合這種數據結構的實現,也談到了,引入這種數據結構的一個很大的緣由就是,在某些僅有少許整數元素的集合場景,經過整數集合既能夠達到字典的效率,也能使用遠少於字典的內存達到一樣的效果。java

咱們本篇介紹的壓縮列表,相信你從他的名字裏應該也能看出來,又是一個爲了節約內存而設計的數據結構,它的數據結構相對於整數集合來講會複雜了不少,可是整數集合只能容許存儲少許的整型數據,而咱們的壓縮列表能夠容許存儲少許的整型數據或字符串。git

這是他們之間的一個區別,下面咱們來看看這種數據結構。程序員

1、基本的結構定義

image

  • ZIPLIST_BYTES:四個字節,記錄了整個壓縮列表總共佔用了多少字節數
  • ZIPLIST_TAIL_OFFSET:四個字節,記錄了整個壓縮列表第一個節點到最後一個節點跨越了多少個字節,通故這個字段能夠迅速定位到列表最後一個節點位置
  • ZIPLIST_LENGTH:兩個字節,記錄了整個壓縮列表中總共包含幾個 zlentry 節點
  • zlentry:非固定字節,記錄的是單個節點,這是一個複合結構,咱們等下再說
  • 0xFF:一個字節,十進制的值爲 255,標誌壓縮列表的結尾

其中,zlentry 在 redis 中確實有着這樣的結構體定義,但實際上這個結構定義了一堆相似於 length 這樣的字段,記錄前一個節點和自身節點佔用的字節數等等信息,用處很少,而咱們更傾向於使用這樣的邏輯結構來描述 zlentry 節點。github

image

這種結構在 redis 中是沒有具體結構體定義的,請知悉,網上的不少博客文章都直接描述 zlentry 節點是這樣的一種結構,實際上是不許確的。redis

簡單解釋一下這三個字段的含義:數組

  • previous_entry_length:每一個節點會使用一個或者五個字節來描述前一個節點佔用的總字節數,若是前一個節點佔用的總字節數小於 254,那麼就用一個字節存儲,反之若是前一個節點佔用的總字節數超過了 254,那麼一個字節就不夠存儲了,這裏會用五個字節存儲並將第一個字節的值存儲爲固定值 254 用於區分。
  • encoding:壓縮列表能夠存儲 16位、32位、64位的整數以及字符串,encoding 就是用來區分後面的 content 字段中存儲於的究竟是哪一種內容,分別佔多少字節,這個咱們等下細說。
  • content:沒什麼特別的,存儲的就是具體的二進制內容,整數或者字符串。

下面咱們細說一個 encoding 具體是怎麼存儲的。bash

主要分爲兩種,一種是字符串的存儲格式:微信

編碼 編碼長度 content類型
00xxxxxx 一個字節 長度小於 63 的字符串
01xxxxxx xxxxxxxx 兩個字節 長度小於 16383 的字符串
10xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 五個字節 長度小於 4294967295 的字符串

content 的具體長度,由編碼除去高兩位剩餘的二進制位表示。markdown

編碼 編碼長度 content類型
11000000 一個字節 int16_t 類型的整數
11010000 一個字節 int32_t 類型的整數
11100000 一個字節 int64_t 類型的整數
11110000 一個字節 24 位有符號整數
11111110 一個字節 8 位有符號整數

注意,整型數據的編碼是固定 11 開頭的八位二進制,而字符串類型的編碼都是非固定的,由於它還須要經過後面的二進制位獲得字符串的長度,稍有區別。數據結構

這就是壓縮列表的基本的結構定義狀況,下面咱們經過節點的增刪改查方法源碼實現來看看 redis 中具體的實現狀況。

2、redis 的具體源碼實現

一、ziplistNew

咱們先來看看壓縮列表初始化的方法實現:

unsigned char *ziplistNew(void) {
    //bytes=2*4+2
    //分配壓縮列表結構所須要的字節數
    //ZIPLIST_BYTES + ZIPLIST_TAIL_OFFSET + ZIPLIST_LENGTH
    unsigned int bytes = ZIPLIST_HEADER_SIZE+1;
    unsigned char *zl = zmalloc(bytes);
    //初始化 ZIPLIST_BYTES 字段
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    //初始化 ZIPLIST_TAIL_OFFSET
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    //初始化 ZIPLIST_LENGTH 字段
    ZIPLIST_LENGTH(zl) = 0;
    //爲壓縮列表最後一個字節賦值 255
    zl[bytes-1] = ZIP_END;
    return zl;
}
複製代碼

二、ziplistPush

接着咱們看新增節點的源碼實現:

unsigned char *ziplistPush(unsigned char *zl, unsigned char *s
        ,unsigned int slen, int where) {
    unsigned char *p;
    //找到待插入的位置,頭部或者尾部
    p = (where == ZIPLIST_HEAD) ? ZIPLIST_ENTRY_HEAD(zl) : ZIPLIST_ENTRY_END(zl);
    return __ziplistInsert(zl,p,s,slen);
}
複製代碼

解釋一下 ziplistPush 的幾個入參的含義。

zl 指向一個壓縮列表的首地址,s 指向一個字符串首地址),slen 指向字符串的長度(若是節點存儲的值是整型,存儲的就是整型值),where 指明新節點的插入方式,頭插亦或尾插。

ziplistPush 方法的核心是 __ziplistInsert:

unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;
    unsigned int prevlensize, prevlen = 0;
    size_t offset;
    int nextdiff = 0;
    unsigned char encoding = 0;
    long long value = 123456789; 
    zlentry tail;
    //prevlensize 存儲前一個節點長度,本節點使用了幾個字節 1 or 5
    //prelen  存儲前一個節點實際佔用了幾個字節
    if (p[0] != ZIP_END) {
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
    } else {
        unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
        if (ptail[0] != ZIP_END) {
            prevlen = zipRawEntryLength(ptail);
        }
    }
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        //s 指針指向一個整數,嘗試進行一個轉換並獲得存儲這個整數佔用了幾個字節
        reqlen = zipIntSize(encoding);
    } else {
        //s 指針指向一個字符串(字符數組),slen 就是他佔用的字節數
        reqlen = slen;
    }
    //當前節點存儲數據佔用 reqlen 個字節,加上存儲前一個節點長度佔用的字節數
    reqlen += zipStorePrevEntryLength(NULL,prevlen);
    //encoding 字段存儲實際佔用字節數
    reqlen += zipStoreEntryEncoding(NULL,encoding,slen);
    //至此,reqlen 保存了存儲當前節點數據佔用字節數和 encoding 編碼佔用的字節數總和
    int forcelarge = 0;
    //當前節點佔用的總字節減去存儲前一個節點字段佔用的字節
    //記錄的是這一個節點的插入會引發下一個節點佔用字節的變化量
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    if (nextdiff == -4 && reqlen < 4) {
        nextdiff = 0;
        forcelarge = 1;
    }
    //擴容有可能致使 zl 的起始位置偏移,故記錄 p 與 zl 首地址的相對誤差數,過後還原 p 指針指向
    offset = p-zl;
    zl = ziplistResize(zl,curlen+reqlen+nextdiff);
    p = zl+offset;
    if (p[0] != ZIP_END) {
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);
        //把當前節點佔用的字節數存儲到下一個節點的頭部字段
        if (forcelarge)
            zipStorePrevEntryLengthLarge(p+reqlen,reqlen);
        else
            zipStorePrevEntryLength(p+reqlen,reqlen);

        //更新 tail_offset 字段,讓他保存從頭節點到尾節點之間的距離
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);
        zipEntry(p+reqlen, &tail);
        if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
        }
    } else {
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }
    //是否觸發連鎖更新
    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }
    //將節點寫入指定位置
    p += zipStorePrevEntryLength(p,prevlen);
    p += zipStoreEntryEncoding(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}
複製代碼

具體細節我再也不贅述,總結一下整個插入節點的步驟。

  1. 計算並獲得前一個節點的總長度,並判斷獲得當前待插入節點保存前一個節點長度的 previous_entry_length 佔用字節數
  2. 根據傳入的 s 和 slen,計算並保存 encoding 字段內容
  3. 構建節點並將數據寫入節點添加到壓縮列表中

ps:重點要去理解壓縮列表節點的數據結構定義,previous_entry_length、encoding、content 字段,這樣才能比較容易理解節點新增操做的實現。

3、連鎖更新

談到 redis 的壓縮列表,就必然會談到他的連鎖更新,咱們先引一張圖:

image

假設本來 entry1 節點佔用字節數爲 211(小於 254),那麼 entry2 的 previous_entry_length 會使用一個字節存儲 211,如今咱們新插入一個節點 NEWEntry,這個節點比較大,佔用了 512 個字節。

那麼,咱們知道,NEWEntry 節點插入後,entry2 的 previous_entry_length 存儲不了 512,那麼 redis 就會重分配內存,增長 entry2 的內存分配,並分配給 previous_entry_length 五個字節存儲 NEWEntry 節點長度。

看似沒什麼問題,可是若是極端狀況下,entry2 擴容四個字節後,致使自身佔用字節數超過 254,就會又觸發後一個節點的內存佔用空間擴大,很是極端狀況下,會致使全部的節點都擴容,這就是連鎖更新,一次更新致使大量甚至所有節點都更新內存的分配。

若是連鎖更新發生的機率很高的話,壓縮列表無疑就會是一個低效的數據結構,但實際上連鎖更新發生的條件是很是苛刻的,其一是須要大量節點長度小於 254 連續串聯鏈接,其二是咱們更新的節點位置剛好也致使後一個節點內存擴充更新。

基於這兩點,且少許的連鎖更新對性能是影響不大的,因此這裏的連鎖更新對壓縮列表的性能是沒有多大的影響的,能夠忽略,但須要知曉。

一樣的,若是以爲我寫的對你有點幫助的話,順手點一波關注吧,也歡迎加做者微信深刻探討,咱們逐漸開始走近 redis 比較實用性的相關內容了,盡請關注。


關注公衆不迷路,一個愛分享的程序員。 公衆號回覆「1024」加做者微信一塊兒探討學習! 每篇文章用到的全部案例代碼素材都會上傳我我的 github github.com/SingleYam/o… 歡迎來踩!

YangAM 公衆號
相關文章
相關標籤/搜索