標籤:記憶體 aaaaa 多次 table oid 索引值 file 留空 order
壓縮列表
壓縮列表(ziplist)是列表鍵和雜湊鍵的底層實現之一,當一個列表鍵只包含少量清單項目,並且每個清單項目要嘛是整數值,要嘛是比較短的字串,那麼Redis就會使用壓縮列表來做列表鍵的底層實現。例如,執行以下命令將建立一個壓縮列表鍵的底層實現
127.0.0.1:6379> RPUSH lst 1 3 5 10086 "hello" "world"(integer) 6127.0.0.1:6379> OBJECT ENCODING lst"quicklist"
quicklist結構在quicklist.c中的解釋為A doubly linked list of ziplists意思為一個由ziplist組成的雙向鏈表,列表鍵裡麵包含的都是1、3、5、10086這樣的小整數值,以及"hello"、"world"這樣短的字串。
另外,當一個雜湊鍵只包含少量索引值對,且每個索引值對的鍵和值要嘛是比較短的字串,Redis會使用壓縮列表來做雜湊鍵的底層實現。舉個栗子,執行以下命令將建立一個壓縮列表實現的雜湊鍵
127.0.0.1:6379> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"OK127.0.0.1:6379> OBJECT ENCODING profile"ziplist"
雜湊鍵裡麵包含的所有鍵和值都是小整數或者短字串
壓縮列表的構成
壓縮列表是Redis為了節約記憶體而開發的,是由一系列特殊編碼的連續記憶體塊組成的順序型資料結構。一個壓縮列表可以包含任意多個節點,每個節點可以儲存一個位元組數組或者一個整數值。圖1-1展示了壓縮列表的各個組成部分,表1-1則記錄了各個組成部分的類型、長度以及用途
圖1-1 壓縮列表的各個組成部分
表1-1 壓縮列表各個組成部分的詳細說明明
| 屬性 |
類型 |
長度 |
用途 |
| zlbytes |
uint32_t |
4位元組 |
記錄整個壓縮列表佔用的記憶體位元組數:在對壓縮列表進行記憶體重分配,或者計算zlend的位置時使用 |
| zltail |
uint32_t |
4位元組 |
記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少位元組:通過這個位移量,程式無須遍曆整個壓縮列表就可以確定表尾節點的地址 |
| zllen |
uint16_t |
2位元組 |
記錄了壓縮列表包含的節點數量:當這個屬性的值小於UINT16_MAX(65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 這個值等於 UINT16_MAX 時,節點的真實數量需要遍曆整個壓縮列表才能計算得出 |
| entryX |
列表節點 |
不定 |
壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定 |
| zlend |
uint8_t |
1位元組 |
特殊值0xFF(十進位 255 ),用於標記壓縮列表的末端 |
圖1-2展示了一個壓縮列表的樣本:
- 列表zlbytes屬性的值為0x50(十進位80),表示壓縮列表的總長為80位元組
- 列表zltail屬性的值為0x3c(十進位60),表示如果有一個指向壓縮列表起始地址的指標p,那麼只要用指標p加上偏量60,就可以計算出表尾節點entry3的地址
- 列表zlend屬性的值為0x3(十進位3),表示壓縮列表包含三個節點
圖1-2 包含三個節點的壓縮列表
圖1-3展示了另一個壓縮表示例
圖1-3 包含五個節點的壓縮列表
- 列表zlbytes屬性的值為0xd2(十進位210),表示壓縮列表的總長為210位元組
- 列表zltail屬性的值為0xb3(十進位179),表示如果有一個指向壓縮列表起始地址的指標p,那麼只要用指標p加上偏量179,就可以計算出表尾節點entry5的地址
- 列表zlend屬性的值為0x5(十進位5),表示壓縮列表包含五個節點
壓縮列表節點的構成
每個壓縮列表節點可以儲存一個位元組數組或者一個整數值,其中,位元組數組可以是以下三種長度的其中一種:
- 長度小於等於63(2^6 -1)位元組的位元組數組
- 長度小於等於16 383(2^14 -1)位元組的位元組數組
- 長度小於等於4 294 967 295(2^32 -1)位元組的位元組數組
而整數值則可以是以下六種長度的其中一種:
- 4位長,介意0至12之間的不帶正負號的整數
- 1位元組長的有符號整數
- 3位元組長的有符號整數
- int16_t類型整數
- int32_t類型整數
- int64_t類型整數
每個壓縮列表節點都由previous_entry_length、encoding、content三個部分組成,1-4所示
圖1-4 壓縮列表節點的各個組成部分
previous_entry_length
節點previous_entry_length屬性以位元組為單位,記錄了壓縮列表中前一個節點的長度。previous_entry_length屬性可以是1個位元組或者5個位元組:
- 如果前一節點的長度小於254位元組,那麼previous_entry_length屬性的長度為1位元組:前一節點的長度就儲存在這一個位元組裡面
- 如果前一節點的長度大於等於254位元組,那麼previous_entry_length屬性的長度為5位元組:其中屬性的第一位元組會被設定為0xFE(十進位值254),而之後的四個位元組則用於儲存前一節點的長度
圖1-5展示了一個包含一位元組長previous_entry_length屬性的壓縮列表節點,屬性的值為0x05,表示前一節點的長度為5位元組
圖1-5 當前節點的前一節點的長度為5位元組
圖1-6展示了一個包含五位元組長previous_entry_length屬性的壓縮節點,屬性值為0xFE00002766,其中值的最高位位元組0xFE表示這是一個五位元組長的previous_entry_length屬性,而之後的四位元組00002766(十進位10086)才是前一節點的實際長度。因為節點的previous_entry_length屬性記錄了前一個節點的長度,所以程式可以通過指標運算,根據當前節點的起始位置來計算出前一個節點的起始地址
舉個栗子,如果我們有一個指向當前節點起始地址的指標c,那麼我們只要用指標c減去當前節點previous_entry_length屬性的值,就可以得出一個指向前一個節點起始地址的指標p,1-7所示
圖1-7 通過指標運算計算處前一個節點的地址
壓縮列表的表尾向表頭遍曆操作就是使用這一原理實現的,只要我們擁有一個指向某個節點起始地址的指標,那麼通過這個指標以及這個節點的previous_entry_length屬性,程式就可以一直向前一個節點回溯,最終到達壓縮列表的表前端節點。
圖1-8展示了一個從表尾節點向表前端節點進行遍曆的完整過程:
- 首先,我們擁有指向壓縮列表表尾節點entry4起始地址的指標p1(指向表尾節點的指標可以通過指向壓縮列表起始地址的指標加上zltail屬性的值得出)
- 通過用p1減去entry4節點previous_entry_length屬性的值,我們得到一個指向entry4前一節點entry3起始地址的指標p2
- 通過用p2減去entry3節點previous_entry_length屬性的值,我們得到一個指向entry3前一節點entry2起始地址的指標p3
- 通過用p3減去entry2節點previous_entry_length屬性的值,我們得到一個指向entry2前一節點entry1起始地址的指標p4,entry1為壓縮列表的表前端節點
- 最終,我們從表尾節點向表前端節點遍曆了整個列表
圖1-8 一個從表尾向表頭遍曆的例子
encoding
節點的encoding屬性記錄了節點的content屬性所儲存資料的類型以及長度:
- 一位元組、兩位元組或五位元組長,值的最高位為00、01或者10的是位元組數組編碼:這種編碼錶示節點的content屬性儲存著位元組數組,數組的長度由編碼除去最高兩位之後的其他位記錄
- 一位元組長,值最高位以11開頭的是整數編碼:這種編碼錶示節點的content屬性儲存的是整數值,整數值的類型和長度由編碼除去最高兩位之後的其他位記錄
表1-2記錄了所有可用的位元組數組編碼,而表1-3則記錄了所有可用的整數編碼。表格中的“_”表示留空,而b、x等變數則代表實際的位元據,為了方便閱讀,多個位元組之間用空格隔開
表1-2 位元組數組編碼
| 編碼 |
編碼長度 |
content屬性儲存的值 |
| 00bbbbbb |
1位元組 |
長度小於等於63位元組的位元組數組 |
| 01bbbbbb xxxxxxxx |
2位元組 |
長度小於等於16383位元組的位元組數組 |
| 10______ aaaaaaaa bbbbbbbb cccccccc dddddddd |
5位元組 |
長度小於等於4294967295的位元組數組 |
表1-3 整數編碼
| 編碼 |
編碼長度 |
content屬性儲存的值 |
| 11000000 |
1位元組 |
int16_t類型的整數 |
| 11010000 |
1位元組 |
int32_t類型的整數 |
| 11100000 |
1位元組 |
int64_t類型的整數 |
| 11110000 |
1位元組 |
24位有符號整數 |
| 11111110 |
1位元組 |
8位有符號整數 |
| 1111xxxx |
1位元組 |
使用這一編碼的節點沒有相應的content屬性,因為編碼本身的xxxx四個位已經儲存了一個介於0和12之間的值,所以它無須content屬性 |
content
節點的content屬性負責儲存節點的值,節點值可以是一個位元組數組或整數,值的類型和長度由節點的encoding屬性決定
圖1-9展示了一個儲存位元組數組的節點樣本:
- 編碼的最高位00表示節點儲存的是一個位元組數組
- 編碼的後六位001011記錄了位元組數組的長度11
- content屬性儲存著節點的值"hello world"
圖1-9 儲存著位元組數組"hello world"的節點
圖1-10展示了一個儲存整數值的節點樣本:
- 編碼11000000表示節點儲存的是一個int16_t類型的整數值
- content屬性儲存著節點的值10086
圖1-10 儲存著整數值10086的節點
連鎖更新
前面說過,每個節點的previous_entry_length屬性都記錄了前一個節點的長度:
- 如果前一節點的長度小於254位元組,那麼previous_entry_length屬性需要用1位元組長的空間來儲存這個長度值
- 如果前一節點的長度大於等於254位元組,那麼previous_entry_length屬性需要用5位元組長的空間來儲存這個長度值
現在,考慮這樣一種情況:在一個壓縮列表中,有多個連續的、長度介於250位元組到253位元組之間的節點e1到eN,1-11所示
圖1-11 包含e1至eN的壓縮列表
因為e1至eN的所有節點的長度都小於254位元組,所以記錄這些節點的長度只需一個位元組長的previous_entry_length屬性,換言之,e1到eN的previous_entry_length屬性都是1位元組長。這時,如果我們將一個長度大於254位元組的新節點new設定為壓縮列表的表前端節點,那麼new將成為e1的前置節點,1-12所示
圖1-12 添加新節點到壓縮列表
因為e1的previous_entry_length屬性僅1位元組長,它沒辦法儲存新節點new的長度,所以程式將對壓縮列表執行空間重分配操作,並將e1的節點previous_entry_length屬性從原來的1位元組長擴充為5位元組長
現在,麻煩來了,e1原本的長度介於250位元組至253位元組之間,在為previous_entry_length屬性新增四位元組的空間之後,e1的長度就變成了介於254位元組至257位元組之間,而這種長度使用1位元組長的previous_entry_length屬性時沒法儲存的。因此,為了讓e2的previous_entry_length屬性可以記錄下e1的長度,程式需再次對壓縮列表執行空間重分配操作,並將e2節點的previous_entry_length屬性從原來的1位元組擴充為5位元組長
正如擴充e1引發對e2的擴充一樣,擴充e2也會引發對e3的擴充……為了讓每個節點的previous_entry_length屬性都符合壓縮列表對節點的要求,程式需要不斷地對壓縮列表執行空間重分配操作,直到eN為止
Redis將這種在特殊情況產生的連續多次空間擴充操作稱之為“連鎖更新”,圖1-13展示了這一過程
圖1-13 連鎖更新過程
除了添加節點可能會引發連鎖更新,刪除節點也可能會引發連鎖更新。考慮圖1-14所示的壓縮列表,如果e1至eN都是大小介於250位元組至253位元組的節點,big節點的長度大於等於254位元組(需要5位元組的previous_entry_length來儲存),而small節點的長度小於254位元組(只需要1位元組的previous_entry_length來儲存),那麼當我們將small節點從壓縮列表中刪除之後,為了讓e1的previous_entry_length屬性可以記錄big節點的長度,程式將擴充e1的空間,並由此引發之後的連鎖更新
圖1-14 另一種連鎖更新的情況
因為連鎖更新在最壞情況下需要對壓縮列表執行N次空間重分配操作,而每次空間重分配的最壞時間複雜度為O(N),所以連鎖更新的最壞複雜度為O(N^2)。要注意的是,儘管連鎖更新的複雜度較高,但它真正造成效能問題的機率是很低的:
- 首先,壓縮列表裡要恰好有多個連續的、長度介於250位元組至253位元組之間的節點,連鎖更新才有可能引發,在實際中,這種能夠情況並不多見
- 其次,即使出現連鎖更新,但只要被更新的節點數量不多,就不會對效能造成任何影響:比如說,對三五個節點進行連鎖更新是絕不會影響效能的
因為以上原因,ziplistPush等命令的平均時間複雜度僅為O(N),在實際中,我們可以放心使用這些函數,而不必擔心連鎖更新會影響壓縮列表的效能
壓縮列表API
表1-4列出了所有用於操作壓縮列表的API
包1-4 壓縮列表API
| 函數 |
作用 |
時間複雜度 |
| ziplistNew(void) |
建立一個新的壓縮列表 |
O(1) |
| ziplistPush(unsigned char *zl, unsigned char *s, unsigned int slen, int where) |
建立一個包含給定值的新節點,並將這個新節點添加到壓縮列表的表頭或者表尾 |
平均O(N),最壞O(N^2) |
| ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) |
將包含給定值的新節點插入到給定節點之後 |
平均O(N),最壞O(N^2) |
| ziplistIndex(unsigned char *zl, int index) |
返回壓縮列表給定索引上的節點 |
O(N) |
| ziplistFind(unsigned char *p, unsigned char *vstr, unsigned int vlen, unsigned int skip) |
在壓縮列表中尋找並返回包含了給定值的節點 |
因為節點的值可能是一個位元組數組,所以檢查節點值和給定值是否相同的複雜度為O(N), 而尋找整個列表的複雜度則為O(N^2) |
| ziplistNext(unsigned char *zl, unsigned char *p) |
返回給定節點的下一個節點 |
O(1) |
| ziplistPrev(unsigned char *zl, unsigned char *p) |
返回給定節點的前一個節點 |
O(1) |
| ziplistGet(unsigned char *p, unsigned char **sval, unsigned int *slen, long long *lval) |
擷取給定節點所儲存的值 |
O(1) |
| ziplistDelete(unsigned char *zl, unsigned char **p) |
從壓縮列表中刪除給定的節點 |
平均O(N),最壞O(N^2) |
| ziplistDeleteRange(unsigned char *zl, unsigned int index, unsigned int num) |
刪除壓縮列表在給定索引上的連續多個節點 |
平均O(N),最壞O(N^2) |
| ziplistBlobLen(unsigned char *zl) |
返回壓縮列表目前佔用的記憶體位元組數 |
O(1) |
| ziplistLen(unsigned char *zl) |
返回壓縮列表目前包含的節點數量 |
節點數量小於65535時O(1),大於65535時O(N) |
Redis實現之壓縮列表