1. 程式人生 > 其它 >zset中的score_深入理解Redis系列ZSET

zset中的score_深入理解Redis系列ZSET

技術標籤:zset中的score

“redis有序集合,內部有2種實現方式,一種為壓縮連結串列,另一種為跳躍表。兩者都是redis的基本資料結構,各有千秋。今天我們通過zet的來深入的認識下他們。

01

壓縮列表ziplist

由一系列連續的記憶體塊組成的順序型資料結構,通過連續分配記憶體的方式,可以節約記憶體。通過複雜的設計,來最大化節省記憶體。

結構

zlbytes + zltail + zllen+ entry1 + entry2 + ... + zlend

其中:

zlbytes:壓縮列表總位元組數,佔4個位元組

zltail:尾結點的偏移量,佔4個位元組

zllen:列表中結點的個數,佔2個位元組

entry1:結點(下文會詳細說明)

zlend:固定為0xFF

可知:頭部位置為從zl偏移10個位元組就拿到第一個entry的地址。即p = zl + 10 ,尾部位置就是 p = zl + zltail代表的offset

entry的實際構成:prevlen + encoding + entry-data 或者 prevlen + encoding

(注意:這裡的構成和ziplist.c中struct zlentry不同)

prevlen:前一個結點的位元組數,prevlen欄位可能佔1個位元組,也可能佔5個位元組,當佔一個位元組數時,代表前一個entry的總位元組數不超過254。當佔5個位元組數時,前一個entry的所佔的總位元組數為5箇中的後四個位元組所表示的數,比如 0xfe 00 00 00 ff 代表前一個entry總位元組數為255個位元組。

encoding:本結點的編碼,這裡的編碼中也包含了本節點的entry-data位元組長度。

字元型別的編碼:

  • ZIP_STR_06B:(0 << 6) 即00xxxxxx 前兩位00表示最大長度為63的字串,後面6位表示實際字串長度,encoding佔1個位元組

  • ZIP_STR_14B (1 << 6) 即01xxxxxx xxxxxxxx 前兩位01表示中等長度的字串(63

  • ZIP_STR_32B (2 << 6) //10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 特大字元,第一個位元組固定128(0X80),後面四個位元組儲存實際長度,encoding佔用5個位元組

int型別的編碼:統一佔一個位元組

  • ZIP_INT_16B (0xc0 | 0<<4) //11000000 content內容是int16,長度是2個位元組

  • ZIP_INT_32B (0xc0 | 1<<4) //11010000 content內容是int32,長度是4個位元組

  • ZIP_INT_64B (0xc0 | 2<<4) //11100000 表示content內容是int64,長度是8個位元組

  • ZIP_INT_24B (0xc0 | 3<<4) //11110000 表示content內容是int24,長度是3個位元組

  • ZIP_INT_8B 0xfe // 11111110 表示content內容是int8,長度是1個位元組

  • ZIP_INT_IMM 1111xxxx //代表小整數,沒有content內容,encoding的後四位正好就可以表示出,範圍在11110001~11111101之間,代表0~12的數,也就是xxxx-1,對應上面 prevlen + encoding 這種情況

entry-data:儲存的內容

實際的例子:[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64],02佔一個位元組,表示上一個entry的長度為2個位元組,0b佔一個位元組,0x0000 1011 代表ZIP_STR_06B小字串,且長度為11個位元組。48 65 6c 6c 6f 20 57 6f 72 6c 64 這11個位元組為儲存的內容,ASCII為"Hello World"

為什麼要254?

因為255是固定0Xff,代表zl的結尾,都知道一個位元組只能代表0~255數字,而255已經使用,所以prevlen只能取最大的254,代表上一個元素的所佔的總位元組數。但是如果上一個位元組數超過254怎麼辦。就需要擴充套件prevlen所佔的位元組數(原來為1個位元組)為5個位元組 size(len) + 1, 擴充套件後的第一個位元組固定為254,後四個位元組為真正的位元組數。

問題

假如一個結點這樣構成:prevlen = f0 1個位元組,encodeing = c0 ff ff ff ff 5個位元組,entry-data 內容有 32^2 - 1 個位元組,那麼下一個entry的prevlen = 32^2 - 1 + 5 + 1 ,那麼5個位元組不夠表示了呀?!等等,32^2 - 1 B ≈ 1G 實際場景中單個value有這麼存的麼?!哈哈

為什麼要級聯更新?why?

前面瞭解到prevlen所佔的位元組為1個或者5個,當prevlen所佔的位元組字數變化時,可能會導致entry的總長度發生變化,而entry的總長度變化會導致後一個entry的prevlen欄位發生變化,這樣就會引起連鎖反應。

什麼時候發生?when?

執行插入或者刪除操作時都可能會觸發。具體的觸發條件,下文會舉例說明。

如何發生的?how?

級聯更新分為2種:級聯擴充套件和級聯收縮

看一個級聯擴充套件的例子:e1 e2 e3 e4 e5 屬性為 bytes(e1) = 100 bytes(e2) = 250 bytes(e3) = 251 bytes(e4) = 252 bytes(e5) = 253,其中:

e1.prevlen = 0

e2.prevlen = 100

e3.prevlen = 250

e4.prevlen = 251

e5.prevlen = 252

當一個新結點e要新插入到e1,e2之間,其中bytes(e) = 255,插入後bytes(e2.prevlen) = 1 變為 5,導致bytes(e2) = 250+4 ->bytes(e3.prevlen) = 5 -> bytes(e3) = 251+4 -> ...

就這樣連鎖的更新下去,直到結點的長度不發生變化時停止。

看一個級聯收縮的例子:e1 e2 e3 e4 e5 屬性為 bytes(e1) = 100 bytes(e2) = 254 bytes(e3) = 255 bytes(e4) = 256 bytes(e4) = 257,其中

e1.prevlen = 0

e2.prevlen = 100

e3.prevlen = 254

e4.prevlen = 255

e5.prevlen = 256

當刪除e2時,那麼導致bytes(e3.prevlen) = 5 變為 1 -> bytes(e3) = 255 - 4 = 251 -> bytes(e4.prevlen) = 5變為1

-> bytes(e4) = 256 - 4 = 252 ...

但是在級聯收縮時,redis在實際中沒有做收縮,因為5個位元組也是可以儲存1個位元組的內容的。

雖然有點浪費,但是級聯更新實在是太可怕了,所以浪費就浪費吧。所以 prevlen中儲存的可能是 0xfe 00 00 00 fb

zset通過ziplist實現

使用2個entry來表示element和sorce,成組成對的插入和刪除。(以下簡稱為e-s對)

zadd:在插入時,首先遍歷比較每一個element的sorce,然後確定插入位置。

zdel:刪除時,首先遍歷比較element,查詢到e-s對,然後刪除。

zupdate:更新時,首先遍歷比較element,查詢到e-s對,比較sorce,如果分數不同,就先刪除,後新增。

zrang:取第一個e-s開始記數,通過傳入的start確定開始查詢的位置。其中reverse可以通過head和tail來確定。

根據區間長度迴圈取值,將ziplist中的entry轉換為zset的資料

zrangByscore:根據reverse決定從頭還是尾開始掃描,根據score確定第一個符合的元素。繼續迴圈,判斷是否超過max,超過break

02

跳錶skiplist

跳錶是在原有的有序連結串列上面增加了多級索引,通過索引來實現快速查詢。跳錶不僅能提高搜尋效能,同時也可以提高插入和刪除操作的效能。

結構

/** * 跳躍表結點結構體 */typedef struct zskiplistNode {    sds ele; // key    double score; // score    struct zskiplistNode *backward; // 後退指標,它指向位於當前節點的前一個節點    //層級結構體    struct zskiplistLevel {        struct zskiplistNode *forward; // 前進指標,前進指標用於訪問位於表尾方向的其他節點,        unsigned long span; // 跨度,跨度則記錄了前進指標所指向節點和當前節點的距離    } level[]; // 多層連線指標,多級索引} zskiplistNode;/** * 跳躍表結構體 */typedef struct zskiplist {    struct zskiplistNode *header, // 頭指標            *tail; // 尾指標    unsigned long length; // 記錄跳躍表的長度,也即是,跳躍表目前包含節點的數量(表頭節點不計算在內)    int level; // 記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)} zskiplist;

為什麼新增時要隨機一個層級?why?

隨機一個層級,是為了使索引的分佈均勻,不然在某2個索引之間可能會聚集過多的元素,導致時間複雜退化,比如有一級索引:1和100,如果不更新索引的話,在後續執行插入操作後,2者之間,可能會插入很多的資料,最後退化為普通連結串列。

如何隨機?how?

隨機獲取一個層級,隨機函式生成的值要合理,不能過多的生成高層,也不能不生成低層。以下是redis的隨機函式

#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */int zslRandomLevel(void) {    int level = 1;    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))        level += 1;    return (level}

解釋下:(ZSKIPLIST_P * 0xFFFF) 就是 1111 1111 1111 1111 右移2位 -> 0011 1111 1111 1111,而random() & 0xFFFF 就是取確保在

0000 0000 0000 0000 ~ 1111 1111 1111 1111 隨機到一個數。而這些數中,當前2位是00時,會滿足 00xx xxxx xxxx xxxx <

0011 1111 1111 1111, level++,而前2位是00的概率也是1/4,就是說level=2的概率為1/4,level如果想成為3,下次前2位還要隨機到00,1/4 * 1/4 = 1/16,以此類推。

空間複雜度為:假如有n個元素要插入skiplist中,那麼在第一層的索引個數為n/4,即n(level1) = n/4,n(level2)=n/16,n(level3) = n/64 ....

全部的索引個數 n/4 + n/16 + ... + 1 求等比數列的前n項和可知, S = (n-1)/3 , 所以skiplist額外的空間為n/3,也可以理解為平均每個節點有 1.33 個指標。

zset通過skiplist來實現

typedef struct zset {    dict *dict; // key => score 的字典    zskiplist *zsl; // 跳躍表} zset;

zadd:新增時,插入一個元素,隨機獲取一個層級,更新索引和資料結構

zdel:刪除時,將元素刪除,更新索引和各層的span

zupdate:如果元素存在,且socre不同,如果newscore在前後2個節點score之間,代表不會影響排序,直接修改就行,如果分值影響了排序,則需要刪除這個元素,並插入“新”的這個元素。

zrange:通過傳入的start,找到符合範圍的第一個節點ln,時間複雜度O(logN),最壞 O(N) ,N為跳躍表長度。通過span來實現,來記錄level層上各個級點的跨度。根據區間長度迴圈取值。

zrangeByscore:根據傳入的range,查詢符合分數範圍的第一個結點, 時間複雜度O(logN),根據區間長度迴圈取值,判斷是否超過max,超過break。

03

轉換convert

redis中規定:當zset中的元素如果有滿足如下條件時,zet的實現方式要變為skiplist。

zset-max-ziplist-entries 128 當元素個數超過128個

zset-max-ziplist-value 64 當單個元素的位元組數超過64B

這個2個配置在redis.config 中可以配置

為什麼要轉換?why?

當元素個數超過128個,ziplist花費的時間複雜度較高了

什麼時候轉換?when?

ziplist -> skiplist,每次新增時,判斷是否觸發Convert

skiplist -> ziplist,在載入rdb檔案時,如果zset的資料量小於等於zset-max-ziplist-entries,並且最大的value不超過zset-max-ziplist-value,那麼會將skiplist轉化為ziplist。

如何轉換?how?

ziplist -> skiplist,迴圈遍歷出ziplist中的e-s對,然後依次插入skiplist中。釋放ziplist的記憶體。

skiplist -> ziplist,從skiplist的頭結點,找到level[0]層第一個元素,根據forward依次遍歷出其他元素,並插入ziplist中,最後釋skiplist的記憶體。

04

總結

ziplist的優點是節省記憶體,缺點是時間複雜度比較大,skiplist的優點是時間複雜度低,但是索引也會佔記憶體,根據資料結構的知識,時間複雜度和空間複雜度二者是不能兼得,要根據具體的場景來選擇一種合適的資料結構,redis通過在config中定義zset的元素數量的閥值來動態的調整底層的實現。

關注這個公眾號,讓你成為awesome Coder!

0d821fde4d126d131dee2964edd7da75.png