目錄
簡單動態字符串
鏈表
字典
跳躍表
整數集合
壓縮列表
對象
簡單動態字符串
導讀
- Redis 只會使用 C 字符串作為字面量, 在大多數情況下, Redis 使用 SDS (Simple Dynamic String,簡單動態字符串)作為字符串表示。
- 比起 C 字符串, SDS 具有以下優點:
-
- 常數復雜度獲取字符串長度。
- 杜絕緩沖區溢出。
- 減少修改字符串長度時所需的內存重分配次數。
- 二進制安全。
- 兼容部分 C 字符串函數。
簡單動態字符串
Redis 沒有直接使用 C 語言傳統的字符串表示(以空字符結尾的字符數組,以下簡稱 C 字符串), 而是自己構建了一種名為簡單動態字符串(simple dynamic string,SDS)的抽象類型, 並將 SDS 用作 Redis 的默認字符串表示。
SDS 的定義
每個 sds.h/sdshdr 結構表示一個 SDS 值:
struct sdshdr { // 記錄 buf 數組中已使用字節的數量 // 等於 SDS 所保存字符串的長度 int len; // 記錄 buf 數組中未使用字節的數量 int free; // 字節數組,用於保存字符串 char buf[]; };
SDS vs C字符串
表 2-1 C 字符串和 SDS 之間的區別
C 字符串 | SDS |
獲取字符串長度的復雜度為O(N)。 | 獲取字符串長度的復雜度為O(1)。 |
API 是不安全的,可能會造成緩沖區溢出。 | API 是安全的,不會造成緩沖區溢出。 |
修改字符串長度N次必然需要執行N次內存重分配。 | 修改字符串長度N次最多需要執行N次內存重分配。 |
只能保存文本數據。 | 可以保存文本或者二進制數據。 |
可以使用所有<string.h>庫中的函數。 | 可以使用一部分<string.h>庫中的函數。 |
常數復雜度獲取字符串長度
通過使用 SDS 而不是 C 字符串, Redis 將獲取字符串長度所需的復雜度從 O(N) 降低到了 O(1) , 這確保了獲取字符串長度的工作不會成為 Redis 的性能瓶頸。
杜絕緩沖區溢出
減少修改字符串時帶來的內存重分配次數:通過未使用空間, SDS 實現了空間預分配和惰性空間釋放兩種優化策略。
- 空間預分配 - 通過這種策略, SDS 將連續增長 N 次字符串所需的內存重分配次數從必定 N 次降低為最多 N 次。
空間預分配用於優化 SDS 的字符串增長操作: 當 SDS 的 API 對一個 SDS 進行修改, 並且需要對 SDS 進行空間擴展的時候, 程序不僅會為 SDS 分配修改所必須要的空間, 還會為 SDS 分配額外的未使用空間。
其中, 額外分配的未使用空間數量由以下公式決定:
- 如果對 SDS 進行修改之后, SDS 的長度(也即是 len 屬性的值)將小於 1 MB , 那么程序分配和 len 屬性同樣大小的未使用空間, 這時 SDS len 屬性的值將和 free 屬性的值相同。 舉個例子, 如果進行修改之后, SDS 的 len 將變成 13 字節, 那么程序也會分配 13 字節的未使用空間, SDS 的 buf 數組的實際長度將變成 13 + 13 + 1 = 27 字節(額外的一字節用於保存空字符)。
- 如果對 SDS 進行修改之后, SDS 的長度將大於等於 1 MB , 那么程序會分配 1 MB 的未使用空間。 舉個例子, 如果進行修改之后, SDS 的 len 將變成 30 MB , 那么程序會分配 1 MB 的未使用空間, SDS 的 buf 數組的實際長度將為 30 MB + 1 MB + 1 byte 。
- 惰性空間釋放 - 通過這種策略, SDS 避免了縮短字符串時所需的內存重分配操作, 並為將來可能有的增長操作提供了優化。
惰性空間釋放用於優化 SDS 的字符串縮短操作: 當 SDS 的 API 需要縮短 SDS 保存的字符串時, 程序並不立即使用內存重分配來回收縮短后多出來的字節, 而是使用 free 屬性將這些字節的數量記錄起來, 並等待將來使用。
與此同時, SDS 也提供了相應的 API , 讓我們可以在有需要時, 真正地釋放 SDS 里面的未使用空間, 所以不用擔心惰性空間釋放策略會造成內存浪費。
二進制安全
- 所有 SDS API 都會以處理二進制的方式來處理 SDS 存放在 buf 數組里的數據, 程序不會對其中的數據做任何限制、過濾、或者假設 —— 數據在寫入時是什么樣的, 它被讀取時就是什么樣。這也是我們將 SDS 的 buf 屬性稱為字節數組的原因 —— Redis 不是用這個數組來保存字符, 而是用它來保存一系列二進制數據。
- SDS 使用 len 屬性的值而不是空字符來判斷字符串是否結束。
- 通過使用二進制安全的 SDS , 而不是 C 字符串, 使得 Redis 不僅可以保存文本數據, 還可以保存任意格式的二進制數據。
兼容部分 C 字符串函數
雖然 SDS 的 API 都是二進制安全的, 但它們一樣遵循 C 字符串以空字符結尾的慣例: 這些 API 總會將 SDS 保存的數據的末尾設置為空字符, 並且總會在為 buf 數組分配空間時多分配一個字節來容納這個空字符, 這是為了讓那些保存文本數據的 SDS 可以重用一部分 <string.h> 庫定義的函數。這樣 Redis 就不用自己專門去實現一套函數。
表 2-2 SDS 的主要操作 API
函數 | 作用 | 時間復雜度 |
sdsnew | 創建一個包含給定 C 字符串的 SDS 。 | O(N),N為給定 C 字符串的長度。 |
sdsempty | 創建一個不包含任何內容的空 SDS 。 | O(1) |
sdsfree | 釋放給定的 SDS 。 | O(1) |
sdslen | 返回 SDS 的已使用空間字節數。 | 這個值可以通過讀取 SDS 的len屬性來直接獲得, 復雜度為O(1)。 |
sdsavail | 返回 SDS 的未使用空間字節數。 | 這個值可以通過讀取 SDS 的free屬性來直接獲得, 復雜度為 O(1)。 |
sdsdup | 創建一個給定 SDS 的副本(copy)。 | O(N),N為給定 SDS 的長度。 |
sdsclear | 清空 SDS 保存的字符串內容。 | 因為惰性空間釋放策略,復雜度為O(1)。 |
sdscat | 將給定 C 字符串拼接到 SDS 字符串的末尾。 | O(N),N為被拼接 C 字符串的長度。 |
sdscatsds | 將給定 SDS 字符串拼接到另一個 SDS 字符串的末尾。 | O(N),N為被拼接 SDS 字符串的長度。 |
sdscpy | 將給定的 C 字符串復制到 SDS 里面, 覆蓋 SDS 原有的字符串。 | O(N),N為被復制 C 字符串的長度。 |
sdsgrowzero | 用空字符將 SDS 擴展至給定長度。 | O(N),N為擴展新增的字節數。 |
sdsrange | 保留 SDS 給定區間內的數據, 不在區間內的數據會被覆蓋或清除。 | O(N),N為被保留數據的字節數。 |
sdstrim | 接受一個 SDS 和一個 C 字符串作為參數, 從 SDS 左右兩端分別移除所有在 C 字符串中出現過的字符。 | O(M*N),M為 SDS 的長度,N為給定 C 字符串的長度。 |
sdscmp | 對比兩個 SDS 字符串是否相同。 | O(N),N為兩個 SDS 中較短的那個 SDS 的長度。 |
鏈表
導讀
鏈表提供了高效的節點重排能力, 以及順序性的節點訪問方式, 並且可以通過增刪節點來靈活地調整鏈表的長度。因為 Redis 使用的 C 語言並沒有內置這種數據結構, 所以 Redis 構建了自己的鏈表實現。
- 鏈表被廣泛用於實現 Redis 的各種功能, 比如列表鍵, 發布與訂閱, 慢查詢, 監視器, 等等。
- 每個鏈表節點由一個 listNode 結構來表示, 每個節點都有一個指向前置節點和后置節點的指針, 所以 Redis 的鏈表實現是雙端鏈表。
- 每個鏈表使用一個 list 結構來表示, 這個結構帶有表頭節點指針、表尾節點指針、以及鏈表長度等信息。
- 因為鏈表表頭節點的前置節點和表尾節點的后置節點都指向 NULL , 所以 Redis 的鏈表實現是無環鏈表。
- 通過為鏈表設置不同的類型特定函數, Redis 的鏈表可以用於保存各種不同類型的值。
鏈表和鏈表節點的實現
每個鏈表節點使用一個 adlist.h/listNode 結構來表示:
1 typedef struct listNode { 2 3 // 前置節點 4 struct listNode *prev; 5 6 // 后置節點 7 struct listNode *next; 8 9 // 節點的值 10 void *value; 11 12 } listNode;
多個 listNode 可以通過 prev 和 next 指針組成雙端鏈表, 如圖 3-1 所示。
雖然僅僅使用多個 listNode 結構就可以組成鏈表, 但使用 adlist.h/list 來持有鏈表的話, 操作起來會更方便:
1 typedef struct list { 2 3 // 表頭節點 4 listNode *head; 5 6 // 表尾節點 7 listNode *tail; 8 9 // 鏈表所包含的節點數量 10 unsigned long len; 11 12 // 節點值復制函數 13 void *(*dup)(void *ptr); 14 15 // 節點值釋放函數 16 void (*free)(void *ptr); 17 18 // 節點值對比函數 19 int (*match)(void *ptr, void *key); 20 21 } list;
list 結構為鏈表提供了表頭指針 head 、表尾指針 tail , 以及鏈表長度計數器 len , 而 dup 、 free 和 match 成員則是用於實現多態鏈表所需的類型特定函數:
- dup 函數用於復制鏈表節點所保存的值;
- free 函數用於釋放鏈表節點所保存的值;
- match 函數則用於對比鏈表節點所保存的值和另一個輸入值是否相等。
圖 3-2 是由一個 list 結構和三個 listNode 結構組成的鏈表:
鏈表和鏈表節點的 API
函數 | 作用 | 時間復雜度 |
listSetDupMethod | 將給定的函數設置為鏈表的節點值復制函數。 | O(1)。 |
listGetDupMethod | 返回鏈表當前正在使用的節點值復制函數。 | 復制函數可以通過鏈表的dup屬性直接獲得, O(1) |
listSetFreeMethod | 將給定的函數設置為鏈表的節點值釋放函數。 | O(1)。 |
listGetFree | 返回鏈表當前正在使用的節點值釋放函數。 | 釋放函數可以通過鏈表的free屬性直接獲得, O(1) |
listSetMatchMethod | 將給定的函數設置為鏈表的節點值對比函數。 | O(1) |
listGetMatchMethod | 返回鏈表當前正在使用的節點值對比函數。 | 對比函數可以通過鏈表的match 屬性直接獲得, O(1) |
listLength | 返回鏈表的長度(包含了多少個節點)。 | 鏈表長度可以通過鏈表的len屬性直接獲得, O(1) 。 |
listFirst | 返回鏈表的表頭節點。 | 表頭節點可以通過鏈表的head屬性直接獲得, O(1) 。 |
listLast | 返回鏈表的表尾節點。 | 表尾節點可以通過鏈表的tail屬性直接獲得, O(1) 。 |
listPrevNode | 返回給定節點的前置節點。 | 前置節點可以通過節點的prev屬性直接獲得, O(1) 。 |
listNextNode | 返回給定節點的后置節點。 | 后置節點可以通過節點的next屬性直接獲得, O(1) 。 |
listNodeValue | 返回給定節點目前正在保存的值。 | 節點值可以通過節點的value屬性直接獲得, O(1) 。 |
listCreate | 創建一個不包含任何節點的新鏈表。 | O(1) |
listAddNodeHead | 將一個包含給定值的新節點添加到給定鏈表的表頭。 | O(1) |
listAddNodeTail | 將一個包含給定值的新節點添加到給定鏈表的表尾。 | O(1) |
listInsertNode | 將一個包含給定值的新節點添加到給定節點的之前或者之后。 | O(1) |
listSearchKey | 查找並返回鏈表中包含給定值的節點。 | O(N),N為鏈表長度。 |
listIndex | 返回鏈表在給定索引上的節點。 | O(N),N為鏈表長度。 |
listDelNode | 從鏈表中刪除給定節點。 | O(1) |
listRotate | 將鏈表的表尾節點彈出,然后將被彈出的節點插入到鏈表的表頭, 成為新的表頭節點。 | O(1) |
listDup | 復制一個給定鏈表的副本。 | O(N),N為鏈表長度。 |
listRelease | 釋放給定鏈表,以及鏈表中的所有節點。 | O(N),N為鏈表長度。 |
字典
Redis 所使用的 C 語言並沒有內置這種數據結構, 因此 Redis 構建了自己的字典實現。
- 字典, 又稱符號表(symbol table)、關聯數組(associative array)或者映射(map), 是一種用於保存鍵值對(key-value pair)的抽象數據結構。
- 在字典中, 一個鍵(key)可以和一個值(value)進行關聯(或者說將鍵映射為值), 這些關聯的鍵和值就被稱為鍵值對。
- 字典中的每個鍵都是獨一無二的, 程序可以在字典中根據鍵查找與之關聯的值, 或者通過鍵來更新值, 又或者根據鍵來刪除整個鍵值對, 等等。
導讀
- 字典被廣泛用於實現 Redis 的各種功能, 其中包括數據庫和哈希鍵。
- Redis 中的字典使用哈希表作為底層實現, 每個字典帶有兩個哈希表, 一個用於平時使用, 另一個僅在進行 rehash 時使用。
- 當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
- 哈希表使用鏈地址法來解決鍵沖突, 被分配到同一個索引上的多個鍵值對會連接成一個單向鏈表。
- 在對哈希表進行擴展或者收縮操作時, 程序需要將現有哈希表包含的所有鍵值對 rehash 到新哈希表里面, 並且這個 rehash 過程並不是一次性地完成的, 而是漸進式地完成的。
字典的實現
Redis 的字典使用哈希表作為底層實現, 一個哈希表里面可以有多個哈希表節點, 而每個哈希表節點就保存了字典中的一個鍵值對。
字典
Redis 中的字典由 dict.h/dict 結構表示:
1 typedef struct dict { 2 3 // 類型特定函數 4 dictType *type; 5 6 // 私有數據 7 void *privdata; 8 9 // 哈希表 10 dictht ht[2]; 11 12 // rehash 索引 13 // 當 rehash 不在進行時,值為 -1 14 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ 15 16 } dict;
- type 屬性和 privdata 屬性是針對不同類型的鍵值對, 為創建多態字典而設置的:
- type 屬性是一個指向 dictType 結構的指針, 每個 dictType 結構保存了一簇用於操作特定類型鍵值對的函數, Redis 會為用途不同的字典設置不同的類型特定函數。
- 而 privdata 屬性則保存了需要傳給那些類型特定函數的可選參數。
- ht 屬性是一個包含兩個項的數組, 數組中的每個項都是一個 dictht 哈希表, 一般情況下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只會在對 ht[0] 哈希表進行 rehash 時使用。
- 除了 ht[1] 之外, 另一個和 rehash 有關的屬性就是 rehashidx : 它記錄了 rehash 目前的進度, 如果目前沒有在進行 rehash , 那么它的值為 -1 。
哈希表
Redis 字典所使用的哈希表由 dict.h/dictht 結構定義:
1 typedef struct dictht { 2 3 // 哈希表數組 4 dictEntry **table; 5 6 // 哈希表大小 7 unsigned long size; 8 9 // 哈希表大小掩碼,用於計算索引值 10 // 總是等於 size - 1 11 unsigned long sizemask; 12 13 // 該哈希表已有節點的數量 14 unsigned long used; 15 16 } dictht; 17 table 屬性是一個數組, 數組中的每個元素都是一個指向 dict.h/dictEntry 結構的指針, 每個 dictEntry 結構保存着一個鍵值對。
- table 屬性是一個數組, 數組中的每個元素都是一個指向 dict.h/dictEntry 結構的指針, 每個 dictEntry 結構保存着一個鍵值對。
- size 屬性記錄了哈希表的大小, 也即是 table 數組的大小, 而 used 屬性則記錄了哈希表目前已有節點(鍵值對)的數量。
- sizemask 屬性的值總是等於 size - 1 , 這個屬性和哈希值一起決定一個鍵應該被放到 table 數組的哪個索引上面。
圖 4-1 展示了一個大小為 4 的空哈希表 (沒有包含任何鍵值對)。
哈希表節點
哈希表節點使用 dictEntry 結構表示, 每個 dictEntry 結構都保存着一個鍵值對:
1 typedef struct dictEntry { 2 3 // 鍵 4 void *key; 5 6 // 值 7 union { 8 void *val; 9 uint64_t u64; 10 int64_t s64; 11 } v; 12 13 // 指向下個哈希表節點,形成鏈表 14 struct dictEntry *next; 15 16 } dictEntry;
- key 屬性保存着鍵值對中的鍵。
- v 屬性則保存着鍵值對中的值, 其中鍵值對的值可以是一個指針, 或者是一個 uint64_t 整數, 又或者是一個 int64_t 整數。
- next 屬性是指向另一個哈希表節點的指針, 這個指針可以將多個哈希值相同的鍵值對連接在一次, 以此來解決鍵沖突(collision)的問題。
圖 4-3 展示了一個普通狀態下(沒有進行 rehash)的字典:
哈希算法
當要將一個新的鍵值對添加到字典里面時, 程序需要先根據鍵值對的鍵計算出哈希值和索引值, 然后再根據索引值, 將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。
Redis 計算哈希值和索引值的方法如下:
1 # 使用字典設置的哈希函數,計算鍵 key 的哈希值 2 hash = dict->type->hashFunction(key); 3 4 # 使用哈希表的 sizemask 屬性和哈希值,計算出索引值 5 # 根據情況不同, ht[x] 可以是 ht[0] 或者 ht[1] 6 index = hash & dict->ht[x].sizemask;
舉個例子, 對於圖 4-4 所示的字典來說, 如果我們要將一個鍵值對 k0 和 v0 添加到字典里面, 那么程序會先使用語句:
hash = dict->type->hashFunction(k0);
計算鍵 k0 的哈希值。
假設計算得出的哈希值為 8 , 那么程序會繼續使用語句:
index = hash & dict->ht[0].sizemask = 8 & 3 = 0;
計算出鍵 k0 的索引值 0 , 這表示包含鍵值對 k0 和 v0 的節點應該被放置到哈希表數組的索引 0 位置上, 如圖 4-5 所示。
當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
MurmurHash 算法最初由 Austin Appleby 於 2008 年發明, 這種算法的優點在於, 即使輸入的鍵是有規律的, 算法仍能給出一個很好的隨機分布性, 並且算法的計算速度也非常快。
MurmurHash 算法目前的最新版本為 MurmurHash3 , 而 Redis 使用的是 MurmurHash2 , 關於 MurmurHash 算法的更多信息可以參考該算法的主頁: http://code.google.com/p/smhasher/ 。
解決鍵沖突
哈希表節點的next 屬性是用來解決鍵沖突(collision)的問題,它指向另一個哈希表節點的指針。
- 當有兩個或以上數量的鍵被分配到了哈希表數組的同一個索引上面時, 我們稱這些鍵發生了沖突(collision)。
- Redis 的哈希表使用鏈地址法(separate chaining)來解決鍵沖突: 每個哈希表節點都有一個 next 指針, 多個哈希表節點可以用 next 指針構成一個單向鏈表, 被分配到同一個索引上的多個節點可以用這個單向鏈表連接起來, 這就解決了鍵沖突的問題。
因為 dictEntry 節點組成的鏈表沒有指向鏈表表尾的指針, 所以為了速度考慮, 程序總是將新節點添加到鏈表的表頭位置(復雜度為 O(1)), 排在其他已有節點的前面。
rehash
隨着操作的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減少, 為了讓哈希表的負載因子(load factor)維持在一個合理的范圍之內, 當哈希表保存的鍵值對數量太多或者太少時, 程序需要對哈希表的大小進行相應的擴展或者收縮。
擴展和收縮哈希表的工作可以通過執行 rehash (重新散列)操作來完成, Redis 對字典的哈希表執行 rehash 的步驟如下:
- 為字典的 ht[1] 哈希表分配空間, 這個哈希表的空間大小取決於要執行的操作, 以及 ht[0] 當前包含的鍵值對數量 (也即是 ht[0].used 屬性的值):
如果執行的是擴展操作, 那么 ht[1] 的大小為第一個大於等於 ht[0].used * 2 的 2^n (2 的 n 次方冪);
如果執行的是收縮操作, 那么 ht[1] 的大小為第一個大於等於 ht[0].used 的 2^n 。 - 將保存在 ht[0] 中的所有鍵值對 rehash 到 ht[1] 上面: rehash 指的是重新計算鍵的哈希值和索引值, 然后將鍵值對放置到 ht[1] 哈希表的指定位置上。
- 當 ht[0] 包含的所有鍵值對都遷移到了 ht[1] 之后 (ht[0] 變為空表), 釋放 ht[0] , 將 ht[1] 設置為 ht[0] , 並在 ht[1] 新創建一個空白哈希表, 為下一次 rehash 做准備。
舉個例子, 假設程序要對含有5個鍵值對字典的 ht[0] 進行擴展操作, 那么程序將執行以下步驟:
- ht[0].used 當前的值為 5 , 5 * 2 = 10 , 而 第一個大於等於10的且2 的 n 次方的數是16, 所以程序會將 ht[1] 哈希表的大小設置為 16 。
- 將 ht[0] 包含的5個鍵值對都 rehash 到 ht[1]。
- 釋放 ht[0] ,並將 ht[1] 設置為 ht[0] ,然后為 ht[1] 分配一個空白哈希表。
哈希表的擴展與收縮
- 當哈希表的負載因子小於 0.1 時, 程序自動開始對哈希表執行收縮操作。
- 當以下條件中的任意一個被滿足時, 程序會自動開始對哈希表執行擴展操作:
- 服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 1 ;
- 服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 並且哈希表的負載因子大於等於 5 ;
其中哈希表的負載因子可以通過公式計算得出:
1 # 負載因子 = 哈希表已保存節點數量 / 哈希表大小 2 load_factor = ht[0].used / ht[0].size
根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操作所需的負載因子並不相同, 這是因為在執行 BGSAVE 命令或 BGREWRITEAOF 命令的過程中, Redis 需要創建當前服務器進程的子進程, 而大多數操作系統都采用寫時復制(copy-on-write)技術來優化子進程的使用效率, 所以在子進程存在期間, 服務器會提高執行擴展操作所需的負載因子, 從而盡可能地避免在子進程存在期間進行哈希表擴展操作, 這可以避免不必要的內存寫入操作, 最大限度地節約內存。
漸進式 rehash
- 上一節說過, 擴展或收縮哈希表需要將 ht[0] 里面的所有鍵值對 rehash 到 ht[1] 里面, 但是, 這個 rehash 動作並不是一次性、集中式地完成的, 而是分多次、漸進式地完成的。
- 原因在於, 如果哈希表里保存的鍵值對數量巨大, 有四百萬、四千萬甚至四億個鍵值對, 那么要一次性將這些鍵值對全部 rehash 到 ht[1] 的話, 龐大的計算量可能會導致服務器在一段時間內停止服務。
- 因此, 為了避免 rehash 對服務器性能造成影響, 服務器不是一次性將 ht[0] 里面的所有鍵值對全部 rehash 到 ht[1] , 而是分多次、漸進式地將 ht[0] 里面的鍵值對慢慢地 rehash 到 ht[1] 。
- 漸進式 rehash 的好處在於它采取分而治之的方式, 將 rehash 鍵值對所需的計算工作均灘到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。
哈希表漸進式 rehash 的詳細步驟:
- 為 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。
- 在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置為 0 , 表示 rehash 工作正式開始。
- 在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操作時, 程序除了執行指定的操作以外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的所有鍵值對 rehash 到 ht[1] , 當 rehash 工作完成之后, 程序將 rehashidx 屬性的值增一。
- 隨着字典操作的不斷執行, 最終在某個時間點上, ht[0] 的所有鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設為 -1 , 表示 rehash 操作已完成。
問題:如果漸進式rehash過程中,鍵值對數量迅速增大,最終在還沒有rehash完,又需要擴容情況怎么辦?
字典 API
表 4-1 字典的主要操作 API
函數 | 作用 | 時間復雜度 |
dictCreate | 創建一個新的字典。 | O(1) |
dictAdd | 將給定的鍵值對添加到字典里面。 | O(1) |
dictReplace | 將給定的鍵值對添加到字典里面, 如果鍵已經存在於字典,那么用新值取代原有的值。 | O(1) |
dictFetchValue | 返回給定鍵的值。 | O(1) |
dictGetRandomKey | 從字典中隨機返回一個鍵值對。 | O(1) |
dictDelete | 從字典中刪除給定鍵所對應的鍵值對。 | O(1) |
dictRelease | 釋放給定字典,以及字典中包含的所有鍵值對。 | O(N),N為字典包含的鍵值對數量。 |
跳躍表
導讀
- 跳躍表是有序集合的底層實現之一, 除此之外它在 Redis 中沒有其他應用。
- Redis 的跳躍表實現由 zskiplist 和 zskiplistNode 兩個結構組成, 其中 zskiplist 用於保存跳躍表信息(比如表頭節點、表尾節點、長度), 而 zskiplistNode 則用於表示跳躍表節點。
- 每個跳躍表節點的層高都是 1 至 32 之間的隨機數。
- 在同一個跳躍表中, 多個節點可以包含相同的分值, 但每個節點的成員對象必須是唯一的。
- 跳躍表中的節點按照分值大小進行排序, 當分值相同時, 節點按照成員對象的大小進行排序。
跳躍表的實現
Redis 的跳躍表由 redis.h/zskiplistNode 和 redis.h/zskiplist 兩個結構定義, 其中 zskiplistNode 結構用於表示跳躍表節點, 而 zskiplist 結構則用於保存跳躍表節點的相關信息, 比如節點的數量, 以及指向表頭節點和表尾節點的指針, 等等。
圖 5-1 展示了一個跳躍表示例, 位於圖片最左邊的是 zskiplist 結構, 該結構包含以下屬性:
- header :指向跳躍表的表頭節點。
- tail :指向跳躍表的表尾節點。
- level :記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)。
- length :記錄跳躍表的長度,也即是,跳躍表目前包含節點的數量(表頭節點不計算在內)。
位於 zskiplist 結構右方的是四個 zskiplistNode 結構, 該結構包含以下屬性:
- 層(level):節點中用 L1 、 L2 、 L3 等字樣標記節點的各個層, L1 代表第一層, L2 代表第二層,以此類推。每個層都帶有兩個屬性:前進指針和跨度。前進指針用於訪問位於表尾方向的其他節點,而跨度則記錄了前進指針所指向節點和當前節點的距離。在上面的圖片中,連線上帶有數字的箭頭就代表前進指針,而那個數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿着層的前進指針進行。
- 后退(backward)指針:節點中用 BW 字樣標記節點的后退指針,它指向位於當前節點的前一個節點。后退指針在程序從表尾向表頭遍歷時使用。
- 分值(score):各個節點中的 1.0 、 2.0 和 3.0 是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。
- 成員對象(obj):各個節點中的 o1 、 o2 和 o3 是節點所保存的成員對象。
注意表頭節點和其他節點的構造是一樣的: 表頭節點也有后退指針、分值和成員對象, 不過表頭節點的這些屬性都不會被用到, 所以圖中省略了這些部分, 只顯示了表頭節點的各個層。
跳躍表節點 zskiplistNode
跳躍表節點的實現由 redis.h/zskiplistNode 結構定義:
1 typedef struct zskiplistNode { 2 3 // 后退指針 4 struct zskiplistNode *backward; 5 6 // 分值 7 double score; 8 9 // 成員對象 10 robj *obj; 11 12 // 層 13 struct zskiplistLevel { 14 15 // 前進指針 16 struct zskiplistNode *forward; 17 18 // 跨度 19 unsigned int span; 20 21 } level[]; 22 23 } zskiplistNode;
層
- 跳躍表節點的 level 數組可以包含多個元素, 每個元素都包含一個指向其他節點的指針, 程序可以通過這些層來加快訪問其他節點的速度, 一般來說, 層的數量越多, 訪問其他節點的速度就越快。
- 每次創建一個新跳躍表節點的時候, 程序都根據冪次定律 (power law,越大的數出現的概率越小) 隨機生成一個介於 1 和 32 之間的值作為 level 數組的大小, 這個大小就是層的“高度”。
圖 5-2 分別展示了三個高度為 1 層、 3 層和 5 層的節點, 因為 C 語言的數組索引總是從 0 開始的, 所以節點的第一層是 level[0] , 而第二層是 level[1] , 以此類推。
前進指針
每個層都有一個指向表尾方向的前進指針(level[i].forward 屬性), 用於從表頭向表尾方向訪問節點。
圖 5-3 用虛線表示出了程序從表頭向表尾方向, 遍歷跳躍表中所有節點的路徑:
- 迭代程序首先訪問跳躍表的第一個節點(表頭), 然后從第四層的前進指針移動到表中的第二個節點。
- 在第二個節點時, 程序沿着第二層的前進指針移動到表中的第三個節點。
- 在第三個節點時, 程序同樣沿着第二層的前進指針移動到表中的第四個節點。
- 當程序再次沿着第四個節點的前進指針移動時, 它碰到一個 NULL , 程序知道這時已經到達了跳躍表的表尾, 於是結束這次遍歷。
跨度
- 層的跨度(level[i].span 屬性)用於記錄兩個節點之間的距離:
- 兩個節點之間的跨度越大, 它們相距得就越遠。
- 指向 NULL 的所有前進指針的跨度都為 0 , 因為它們沒有連向任何節點。
- 初看上去, 很容易以為跨度和遍歷操作有關, 但實際上並不是這樣 —— 遍歷操作只使用前進指針就可以完成了, 跨度實際上是用來計算排位(rank)的: 在查找某個節點的過程中, 將沿途訪問過的所有層的跨度累計起來, 得到的結果就是目標節點在跳躍表中的排位。
舉個例子, 圖 5-4 用虛線標記了在跳躍表中查找分值為 3.0 、 成員對象為 o3 的節點時, 沿途經歷的層: 查找的過程只經過了一個層, 並且層的跨度為 3 , 所以目標節點在跳躍表中的排位為 3 。
再舉個例子, 圖 5-5 用虛線標記了在跳躍表中查找分值為 2.0 、 成員對象為 o2 的節點時, 沿途經歷的層: 在查找節點的過程中, 程序經過了兩個跨度為 1 的節點, 因此可以計算出, 目標節點在跳躍表中的排位為 2 。
后退指針
- 節點的后退指針(backward 屬性)用於從表尾向表頭方向訪問節點: 跟可以一次跳過多個節點的前進指針不同, 因為每個節點只有一個后退指針, 所以每次只能后退至前一個節點。
圖 5-6 用虛線展示了如果從表尾向表頭遍歷跳躍表中的所有節點: 程序首先通過跳躍表的 tail 指針訪問表尾節點, 然后通過后退指針訪問倒數第二個節點, 之后再沿着后退指針訪問倒數第三個節點, 再之后遇到指向 NULL 的后退指針, 於是訪問結束。
分值和成員
- 節點的分值(score 屬性)是一個 double 類型的浮點數, 跳躍表中的所有節點都按分值從小到大來排序。
- 節點的成員對象(obj 屬性)是一個指針, 它指向一個字符串對象, 而字符串對象則保存着一個 SDS 值。
- 在同一個跳躍表中, 各個節點保存的成員對象必須是唯一的, 但是多個節點保存的分值卻可以是相同的: 分值相同的節點將按照成員對象在字典序中的大小來進行排序, 成員對象較小的節點會排在前面(靠近表頭的方向), 而成員對象較大的節點則會排在后面(靠近表尾的方向)。
舉個例子, 在圖 5-7 所示的跳躍表中, 三個跳躍表節點都保存了相同的分值 10086.0 , 但保存成員對象 o1 的節點卻排在保存成員對象 o2 和 o3 的節點之前, 而保存成員對象 o2 的節點又排在保存成員對象 o3 的節點之前, 由此可見, o1 、 o2 、 o3 三個成員對象在字典中的排序為 o1 <= o2 <= o3 。
跳躍表 zskiplist
雖然僅靠多個跳躍表節點就可以組成一個跳躍表, 但通過使用一個 zskiplist 結構來持有這些節點, 程序可以更方便地對整個跳躍表進行處理, 比如快速訪問跳躍表的表頭節點和表尾節點, 又或者快速地獲取跳躍表節點的數量(也即是跳躍表的長度)等信息, 如圖 5-9 所示。
zskiplist 結構的定義如下:
1 typedef struct zskiplist { 2 3 // 表頭節點和表尾節點 4 struct zskiplistNode *header, *tail; 5 6 // 表中節點的數量 7 unsigned long length; 8 9 // 表中層數最大的節點的層數 10 int level; 11 12 } zskiplist; 13 header 和 tail 指針分別指向跳躍表的表頭和表尾節點, 通過這兩個指針, 程序定位表頭節點和表尾節點的復雜度為 O(1) 。
- header 和 tail 指針分別指向跳躍表的表頭和表尾節點, 通過這兩個指針, 程序定位表頭節點和表尾節點的復雜度為 O(1) 。
- length 屬性用來記錄節點的數量, 程序可以在 O(1) 復雜度內返回跳躍表的長度。
- level 屬性則用於在 O(1) 復雜度內獲取跳躍表中層高最大的那個節點的層數量, 注意表頭節點的層高並不計算在
整數集合
整數集合(intset)是集合鍵的底層實現之一: 當一個集合只包含整數值元素, 並且這個集合的元素數量不多時, Redis 就會使用整數集合作為集合鍵的底層實現。
導讀
- 整數集合是集合鍵的底層實現之一。
- 整數集合的底層實現為數組, 這個數組以有序、無重復的方式保存集合元素, 在有需要時, 程序會根據新添加元素的類型, 改變這個數組的類型。
- 升級操作為整數集合帶來了操作上的靈活性, 並且盡可能地節約了內存。
- 整數集合只支持升級操作, 不支持降級操作。
整數集合的實現
整數集合(intset)是 Redis 用於保存整數值的集合抽象數據結構, 它可以保存類型為 int16_t 、 int32_t 或者 int64_t 的整數值, 並且保證集合中不會出現重復元素。
每個 intset.h/intset 結構表示一個整數集合:
1 typedef struct intset { 2 3 // 編碼方式 4 uint32_t encoding; 5 6 // 集合包含的元素數量 7 uint32_t length; 8 9 // 保存元素的數組 10 int8_t contents[]; 11 12 } intset; 13 contents 數組是整數集合的底層實現: 整數集合的每個元素都是 contents 數組的一個數組項(item), 各個項在數組中按值的大小從小到大有序地排列, 並且數組中不包含任何重復項。
- contents 數組是整數集合的底層實現: 整數集合的每個元素都是 contents 數組的一個數組項(item), 各個項在數組中按值的大小從小到大有序地排列, 並且數組中不包含任何重復項。
- length 屬性記錄了整數集合包含的元素數量, 也即是 contents 數組的長度。
雖然 intset 結構將 contents 屬性聲明為 int8_t 類型的數組, 但實際上 contents 數組並不保存任何 int8_t 類型的值 —— contents 數組的真正類型取決於 encoding 屬性的值:
- 如果 encoding 屬性的值為 INTSET_ENC_INT16 , 那么 contents 就是一個 int16_t 類型的數組, 數組里的每個項都是一個 int16_t 類型的整數值 (最小值為 -32,768 ,最大值為 32,767 )。
- 如果 encoding 屬性的值為 INTSET_ENC_INT32 , 那么 contents 就是一個 int32_t 類型的數組, 數組里的每個項都是一個 int32_t 類型的整數值 (最小值為 -2,147,483,648 ,最大值為 2,147,483,647 )。
- 如果 encoding 屬性的值為 INTSET_ENC_INT64 , 那么 contents 就是一個 int64_t 類型的數組, 數組里的每個項都是一個 int64_t 類型的整數值 (最小值為 -9,223,372,036,854,775,808 ,最大值為 9,223,372,036,854,775,807 )。
升級
每當我們要將一個新元素添加到整數集合里面, 並且新元素的類型比整數集合現有所有元素的類型都要長時, 整數集合需要先進行升級(upgrade), 然后才能將新元素添加到整數集合里面。
升級整數集合並添加新元素共分為三步進行:
- 根據新元素的類型, 擴展整數集合底層數組的空間大小, 並為新元素分配空間。
- 將底層數組現有的所有元素都轉換成與新元素相同的類型, 並將類型轉換后的元素放置到正確的位上, 而且在放置元素的過程中, 需要繼續維持底層數組的有序性質不變。
- 將新元素添加到底層數組里面。
因為每次向整數集合添加新元素都可能會引起升級, 而每次升級都需要對底層數組中已有的所有元素進行類型轉換, 所以向整數集合添加新元素的時間復雜度為 O(N) 。
升級之后新元素的擺放位置
因為引發升級的新元素的長度總是比整數集合現有所有元素的長度都大, 所以這個新元素的值要么就大於所有現有元素, 要么就小於所有現有元素:
- 在新元素小於所有現有元素的情況下, 新元素會被放置在底層數組的最開頭(索引 0 );
- 在新元素大於所有現有元素的情況下, 新元素會被放置在底層數組的最末尾(索引 length-1 )。
升級的好處
整數集合的升級策略有兩個好處, 一個是提升整數集合的靈活性, 另一個是盡可能地節約內存。
提升靈活性
- 因為 C 語言是靜態類型語言, 為了避免類型錯誤, 我們通常不會將兩種不同類型的值放在同一個數據結構里面。
- 整數集合可以通過自動升級底層數組來適應新元素, 所以我們可以隨意地將 int16_t 、 int32_t 或者 int64_t 類型的整數添加到集合中, 而不必擔心出現類型錯誤, 這種做法非常靈活。
節約內存
- 要讓一個數組可以同時保存 int16_t 、 int32_t 、 int64_t 三種類型的值, 最簡單的做法就是直接使用 int64_t 類型的數組作為整數集合的底層實現。不過這樣一來,就會出現浪費內存的情況。
- 整數集合現在的做法既可以讓集合能同時保存三種不同類型的值, 又可以確保升級操作只會在有需要的時候進行, 這可以盡量節省內存。
降級
- 整數集合不支持降級操作, 一旦對數組進行了升級, 編碼就會一直保持升級后的狀態。
- 舉個例子, 對於一個整數集合來說, 即使我們將集合里唯一一個真正需要使用 int64_t 類型來保存的元素 4294967295 刪除了, 整數集合的編碼仍然會維持 INTSET_ENC_INT64 , 底層數組也仍然會是 int64_t 類型的。
整數集合 API
表 6-1 列出了整數集合的操作 API 。
函數 | 作用 | 時間復雜度 |
intsetNew | 創建一個新的整數集合。 | O(1) |
intsetAdd | 將給定元素添加到整數集合里面。 | O(N) |
intsetRemove | 從整數集合中移除給定元素。 | O(N) |
intsetFind | 檢查給定值是否存在於集合。 | 因為底層數組有序,查找可以通過二分查找法來進行, 所以復雜度為 O(\log N) 。 |
intsetRandom | 從整數集合中隨機返回一個元素。 | O(1) |
intsetGet | 取出底層數組在給定索引上的元素。 | O(1) |
intsetLen | 返回整數集合包含的元素個數。 | O(1) |
intsetBlobLen | 返回整數集合占用的內存字節數。 | O(1) |
壓縮列表
導讀
- 壓縮列表是一種為節約內存而開發的順序型數據結構。
- 壓縮列表被用作列表鍵和哈希鍵的底層實現之一。
- 壓縮列表可以包含多個節點,每個節點可以保存一個字節數組或者整數值。
- 添加新節點到壓縮列表, 或者從壓縮列表中刪除節點, 可能會引發連鎖更新操作, 但這種操作出現的幾率並不高。
壓縮列表的構成
- 壓縮列表是 Redis 為了節約內存而開發的, 由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。
- 一個壓縮列表可以包含任意多個節點(entry), 每個節點可以保存一個字節數組或者一個整數值。
圖 7-1 展示了壓縮列表的各個組成部分, 表 7-1 則記錄了各個組成部分的類型、長度、以及用途。
表 7-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 ),用於標記壓縮列表的末端。 |
壓縮列表節點的構成
- 每個壓縮列表節點都由 previous_entry_length 、 encoding 、 content 三個部分組成, 如圖 7-4 所示。
- 每個壓縮列表節點可以保存一個字節數組或者一個整數值, 其中, 字節數組可以是以下三種長度的其中一種:
- 長度小於等於 63 (2^{6}-1)字節的字節數組;
- 長度小於等於 16383 (2^{14}-1) 字節的字節數組;
- 長度小於等於 4294967295 (2^{32}-1)字節的字節數組;
- 而整數值則可以是以下六種長度的其中一種:
- 4 位長,介於 0 至 12 之間的無符號整數;
- 1 字節長的有符號整數;
- 3 字節長的有符號整數;
- int16_t 類型整數;
- int32_t 類型整數;
- int64_t 類型整數。
previous_entry_length
- 節點的 previous_entry_length 屬性以字節為單位, 記錄了壓縮列表中前一個節點的長度。
- previous_entry_length 屬性的長度可以是 1 字節或者 5 字節:
- 如果前一節點的長度小於 254 字節, 那么 previous_entry_length 屬性的長度為 1 字節: 前一節點的長度就保存在這一個字節里面。
- 如果前一節點的長度大於等於 254 字節, 那么 previous_entry_length 屬性的長度為 5 字節: 其中屬性的第一字節會被設置為 0xFE (十進制值 254), 而之后的四個字節則用於保存前一節點的長度。
- 因為節點的 previous_entry_length 屬性記錄了前一個節點的長度, 所以程序可以通過指針運算, 根據當前節點的起始地址來計算出前一個節點的起始地址。
圖 7-5 展示了一個包含一字節長 previous_entry_length 屬性的壓縮列表節點, 屬性的值為 0x05 , 表示前一節點的長度為 5 字節。
圖 7-6 展示了一個包含五字節長 previous_entry_length 屬性的壓縮節點, 屬性的值為 0xFE00002766 , 其中值的最高位字節 0xFE 表示這是一個五字節長的 previous_entry_length 屬性, 而之后的四字節 0x00002766 (十進制值 10086 )才是前一節點的實際長度。
encoding
節點的 encoding 屬性記錄了節點的 content 屬性所保存數據的類型以及長度:
- 一字節、兩字節或者五字節長, 值的最高位為 00 、 01 或者 10 的是字節數組編碼: 這種編碼表示節點的 content 屬性保存着字節數組, 數組的長度由編碼除去最高兩位之后的其他位記錄;
- 一字節長, 值的最高位以 11 開頭的是整數編碼: 這種編碼表示節點的 content 屬性保存着整數值, 整數值的類型和長度由編碼除去最高兩位之后的其他位記錄;
表 7-2 記錄了所有可用的字節數組編碼, 而表 7-3 則記錄了所有可用的整數編碼。 表格中的下划線 _ 表示留空, 而 b 、 x 等變量則代表實際的二進制數據, 為了方便閱讀, 多個字節之間用空格隔開。
編碼 | 編碼長度 | content 屬性保存的值 |
00bbbbbb | 1 字節 | 長度小於等於 63 字節的字節數組。 |
01bbbbbb xxxxxxxx | 2 字節 | 長度小於等於 16383 字節的字節數組。 |
10______ aaaaaaaa bbbbbbbb cccccccc dddddddd | 5 字節 | 長度小於等於 4294967295 的字節數組。 |
表 7-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 屬性決定。
- 圖 7-9 展示了一個保存字節數組的節點示例:
- 編碼的最高兩位 00 表示節點保存的是一個字節數組;
- 編碼的后六位 001011 記錄了字節數組的長度 11 ;
- content 屬性保存着節點的值 "hello world" 。
- 圖 7-10 展示了一個保存整數值的節點示例:
- 編碼 11000000 表示節點保存的是一個 int16_t 類型的整數值;
- content 屬性保存着節點的值 10086 。
連鎖更新
- 添加新節點可能會引發連鎖更新之外,
- 刪除節點也可能會引發連鎖更新。
- 因為連鎖更新在最壞情況下需要對壓縮列表執行 N 次空間重分配操作, 而每次空間重分配的最壞復雜度為 O(N) , 所以連鎖更新的最壞復雜度為 O(N^2) 。
- 要注意的是, 盡管連鎖更新的復雜度較高, 但它真正造成性能問題的幾率是很低的:
- 首先, 壓縮列表里要恰好有多個連續的、長度介於 250 字節至 253 字節之間的節點, 連鎖更新才有可能被引發, 在實際中, 這種情況並不多見;
- 其次, 即使出現連鎖更新, 但只要被更新的節點數量不多, 就不會對性能造成任何影響: 比如說, 對三五個節點進行連鎖更新是絕對不會影響性能的;
因為以上原因, ziplistPush 等命令的平均復雜度僅為 O(N) , 在實際中, 我們可以放心地使用這些函數, 而不必擔心連鎖更新會影響壓縮列表的性能。
壓縮列表 API
表 7-4 列出了所有用於操作壓縮列表的 API 。
函數 | 作用 | 算法復雜度 |
ziplistNew | 創建一個新的壓縮列表。 | O(1) |
ziplistPush | 創建一個包含給定值的新節點, 並將這個新節點添加到壓縮列表的表頭或者表尾。 | 平均 O(N) ,最壞 O(N^2) 。 |
ziplistInsert | 將包含給定值的新節點插入到給定節點之后。 | 平均 O(N) ,最壞 O(N^2) 。 |
ziplistIndex | 返回壓縮列表給定索引上的節點。 | O(N) |
ziplistFind | 在壓縮列表中查找並返回包含了給定值的節點。 | 因為節點的值可能是一個字節數組, 所以檢查節點值和給定值是否相同的復雜度為 O(N) , 而查找整個列表的復雜度則為 O(N^2) 。 |
ziplistNext | 返回給定節點的下一個節點。 | O(1) |
ziplistPrev | 返回給定節點的前一個節點。 | O(1) |
ziplistGet | 獲取給定節點所保存的值。 | O(1) |
ziplistDelete | 從壓縮列表中刪除給定的節點。 | 平均 O(N) ,最壞 O(N^2) 。 |
ziplistDeleteRange | 刪除壓縮列表在給定索引上的連續多個節點。 | 平均 O(N) ,最壞 O(N^2) 。 |
ziplistBlobLen | 返回壓縮列表目前占用的內存字節數。 | O(1) |
ziplistLen | 返回壓縮列表目前包含的節點數量。 | 節點數量小於 65535 時 O(1) , 大於 65535 時 O(N) 。 |
因為 ziplistPush 、 ziplistInsert 、 ziplistDelete 和 ziplistDeleteRange 四個函數都有可能會引發連鎖更新, 所以它們的最壞復雜度都是 O(N^2) 。
對象
在前面的數個章節里, 我們陸續介紹了 Redis 用到的所有主要數據結構, 比如簡單動態字符串(SDS)、雙端鏈表、字典、壓縮列表、整數集合, 等等。
- Redis 並沒有直接使用這些數據結構來實現鍵值對數據庫, 而是基於這些數據結構創建了一個對象系統, 這個系統包含字符串對象、列表對象、哈希對象、集合對象和有序集合對象這五種類型的對象, 每種對象都用到了至少一種我們前面所介紹的數據結構。
- 通過這五種不同類型的對象,(1)Redis 可以在執行命令之前, 根據對象的類型來判斷一個對象是否可以執行給定的命令。 (2)可以針對不同的使用場景, 為對象設置多種不同的數據結構實現, 從而優化對象在不同場景下的使用效率。
- Redis 的對象系統還實現了基於引用計數技術的內存回收機制: 當程序不再使用某個對象的時候, 這個對象所占用的內存就會被自動釋放; 另外, Redis 還通過引用計數技術實現了對象共享機制, 這一機制可以在適當的條件下, 通過讓多個數據庫鍵共享同一個對象來節約內存。
- 最后, Redis 的對象帶有訪問時間記錄信息, 該信息可以用於計算數據庫鍵的空轉時長, 在服務器啟用了 maxmemory 功能的情況下, 空轉時長較大的那些鍵可能會優先被服務器刪除。
導讀
- Redis 數據庫中的每個鍵值對的鍵和值都是一個對象。
- Redis 共有字符串、列表、哈希、集合、有序集合五種類型的對象, 每種類型的對象至少都有兩種或以上的編碼方式, 不同的編碼可以在不同的使用場景上優化對象的使用效率。
- 服務器在執行某些命令之前, 會先檢查給定鍵的類型能否執行指定的命令, 而檢查一個鍵的類型就是檢查鍵的值對象的類型。
- Redis 的對象系統帶有引用計數實現的內存回收機制, 當一個對象不再被使用時, 該對象所占用的內存就會被自動釋放。
- Redis 會共享值為 0 到 9999 的字符串對象。
- 對象會記錄自己的最后一次被訪問的時間, 這個時間可以用於計算對象的空轉時間。
對象的類型與編碼
- Redis 使用對象來表示數據庫中的鍵和值, 每次當我們在 Redis 的數據庫中新創建一個鍵值對時, 我們至少會創建兩個對象, 一個對象用作鍵值對的鍵(鍵對象), 另一個對象用作鍵值對的值(值對象)。
- Redis 中的每個對象都由一個 redisObject 結構表示, 該結構中和保存數據有關的三個屬性分別是 type 屬性、 encoding 屬性和 ptr 屬性:
1 typedef struct redisObject { 2 3 // 類型 4 unsigned type:4; 5 6 // 編碼 7 unsigned encoding:4; 8 9 // 指向底層實現數據結構的指針 10 void *ptr; 11 12 // ... 13 14 } robj;
舉個例子, 以下 SET 命令在數據庫中創建了一個新的鍵值對, 其中鍵值對的鍵是一個包含了字符串值 "msg" 的對象, 而鍵值對的值則是一個包含了字符串值 "hello world" 的對象:
1 redis> SET msg "hello world" 2 OK
類型
- 對象的 type 屬性記錄了對象的類型, 這個屬性的值可以是以下常量的其中一個。
表 8-1 對象的類型
類型常量 | 對象的名稱 |
REDIS_STRING | 字符串對象 |
REDIS_LIST | 列表對象 |
REDIS_HASH | 哈希對象 |
REDIS_SET | 集合對象 |
REDIS_ZSET | 有序集合對象 |
- 對於 Redis 數據庫保存的鍵值對來說, 鍵總是一個字符串對象, 而值則可以是字符串對象、列表對象、哈希對象、集合對象或者有序集合對象的其中一種, 因此:
- 當我們稱呼一個數據庫鍵為“字符串鍵”時, 我們指的是“這個數據庫鍵所對應的值為字符串對象”;
- 當我們稱呼一個鍵為“列表鍵”時, 我們指的是“這個數據庫鍵所對應的值為列表對象”,諸如此類。
- TYPE 命令的實現方式也與此類似, 當我們對一個數據庫鍵執行 TYPE 命令時, 命令返回的結果為數據庫鍵對應的值對象的類型, 而不是鍵對象的類型:
1 # 鍵為字符串對象,值為列表對象 2 redis> RPUSH numbers 1 3 5 3 (integer) 6 4 5 redis> TYPE numbers 6 list
表 8-2 列出了 TYPE 命令在面對不同類型的值對象時所產生的輸出。
對象 | 對象 type 屬性的值 | TYPE 命令的輸出 |
字符串對象 | REDIS_STRING | "string" |
列表對象 | REDIS_LIST | "list" |
哈希對象 | REDIS_HASH | "hash" |
集合對象 | REDIS_SET | "set" |
有序集合對象 | REDIS_ZSET | "zset" |
編碼和底層實現
- 對象的 ptr 指針指向對象的底層實現數據結構, 而這些數據結構由對象的 encoding 屬性決定。
encoding 屬性記錄了對象所使用的編碼, 也即是說這個對象使用了什么數據結構作為對象的底層實現, 這個屬性的值可以是表 8-3 列出的常量的其中一個。
編碼常量 | 編碼所對應的底層數據結構 | OBJECT ENCODING 命令輸出 |
REDIS_ENCODING_INT | long 類型的整數 | "int" |
REDIS_ENCODING_EMBSTR | embstr 編碼的簡單動態字符串 | "embstr" |
REDIS_ENCODING_RAW | 簡單動態字符串 | "raw" |
REDIS_ENCODING_HT | 字典 | "hashtable" |
REDIS_ENCODING_LINKEDLIST | 雙端鏈表 | "linkedlist" |
REDIS_ENCODING_ZIPLIST | 壓縮列表 | "ziplist" |
REDIS_ENCODING_INTSET | 整數集合 | "intset" |
REDIS_ENCODING_SKIPLIST | 跳躍表和字典 | "skiplist" |
- 其中,每種type類型的對象都至少使用了兩種不同的編碼, 表 8-4 不同類型和編碼的對象
類型常量 | 編碼 | 對象 |
REDIS_STRING | REDIS_ENCODING_INT | 使用整數值實現的字符串對象。 |
REDIS_ENCODING_EMBSTR | 使用 embstr 編碼的簡單動態字符串實現的字符串對象。 | |
REDIS_ENCODING_RAW | 使用簡單動態字符串實現的字符串對象。 | |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的列表對象。 |
REDIS_ENCODING_LINKEDLIST | 使用雙端鏈表實現的列表對象。 | |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的哈希對象。 |
REDIS_ENCODING_HT | 使用字典實現的哈希對象。 | |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整數集合實現的集合對象。 |
REDIS_ENCODING_HT | 使用字典實現的集合對象。 | |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的有序集合對象。 |
REDIS_ENCODING_SKIPLIST | 使用跳躍表和字典實現的有序集合對象。 |
使用 OBJECT ENCODING 命令可以查看一個數據庫鍵的值對象的編碼:
1 redis> SET msg "hello wrold" 2 OK 3 4 redis> OBJECT ENCODING msg 5 "embstr" 6 7 redis> SET story "long long long long long long ago ..." 8 OK 9 10 redis> OBJECT ENCODING story 11 "raw" 12 13 redis> SADD numbers 1 3 5 14 (integer) 3 15 16 redis> OBJECT ENCODING numbers 17 "intset" 18 19 redis> SADD numbers "seven" 20 (integer) 1 21 22 redis> OBJECT ENCODING numbers 23 "hashtable"
- 通過 encoding 屬性來設定對象所使用的編碼, 而不是為特定類型的對象關聯一種固定的編碼, 極大地提升了 Redis 的靈活性和效率, 因為 Redis 可以根據不同的使用場景來為一個對象設置不同的編碼, 從而優化對象在某一場景下的效率。
舉個例子, 在列表對象包含的元素比較少時, Redis 使用壓縮列表作為列表對象的底層實現:
- 因為壓縮列表比雙端鏈表更節約內存, 並且在元素數量較少時, 在內存中以連續塊方式保存的壓縮列表比起雙端鏈表可以更快被載入到緩存中;
- 隨着列表對象包含的元素越來越多, 使用壓縮列表來保存元素的優勢逐漸消失時, 對象就會將底層實現從壓縮列表轉向功能更強、也更適合保存大量元素的雙端鏈表上面;
其他類型的對象也會通過使用多種不同的編碼來進行類似的優化。
在接下來的內容中, 我們將分別介紹 Redis 中的五種不同類型的對象, 說明這些對象底層所使用的編碼方式, 列出對象從一種編碼轉換成另一種編碼所需的條件, 以及同一個命令在多種不同編碼上的實現方法。
字符串對象
- 字符串對象的編碼可以是 int 、 raw 或者 embstr 。
- 如果一個字符串對象保存的是整數值, 並且這個整數值可以用 long 類型來表示, 那么字符串對象會將整數值保存在字符串對象結構的 ptr 屬性里面(將 void* 轉換成 long ), 並將字符串對象的編碼設置為 int 。
舉個例子, 如果我們執行以下 SET 命令, 那么服務器將創建一個如圖 8-1 所示的 int 編碼的字符串對象作為 number 鍵的值:
1 redis> SET number 10086 2 OK 3 4 redis> OBJECT ENCODING number 5 "int"
- 如果字符串對象保存的是一個字符串值, 並且這個字符串值的長度大於 39 字節, 那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串值, 並將對象的編碼設置為 raw 。
舉個例子, 如果我們執行以下命令, 那么服務器將創建一個如圖 8-2 所示的 raw 編碼的字符串對象作為 story 鍵的值:
1 redis> SET story "Long, long, long ago there lived a king ..." 2 OK 3 4 redis> STRLEN story 5 (integer) 43 6 7 redis> OBJECT ENCODING story 8 "raw"
- 如果字符串對象保存的是一個字符串值, 並且這個字符串值的長度小於等於 39 字節, 那么字符串對象將使用 embstr 編碼的方式來保存這個字符串值。
embstr 編碼是專門用於保存短字符串的一種優化編碼方式, 這種編碼和 raw 編碼一樣, 都使用 redisObject 結構和 sdshdr 結構來表示字符串對象, 但 raw 編碼會調用兩次內存分配函數來分別創建 redisObject 結構和 sdshdr 結構, 而 embstr 編碼則通過調用一次內存分配函數來分配一塊連續的空間, 空間中依次包含 redisObject 和 sdshdr 兩個結構, 如圖 8-3 所示。
embstr 編碼的字符串對象在執行命令時, 產生的效果和 raw 編碼的字符串對象執行命令時產生的效果是相同的, 但使用 embstr 編碼的字符串對象來保存短字符串值有以下好處:
- embstr 編碼將創建字符串對象所需的內存分配次數從 raw 編碼的兩次降低為一次。
- 釋放 embstr 編碼的字符串對象只需要調用一次內存釋放函數, 而釋放 raw 編碼的字符串對象需要調用兩次內存釋放函數。
- 因為 embstr 編碼的字符串對象的所有數據都保存在一塊連續的內存里面, 所以這種編碼的字符串對象比起 raw 編碼的字符串對象能夠更好地利用緩存帶來的優勢。
作為例子, 以下命令創建了一個 embstr 編碼的字符串對象作為 msg 鍵的值, 值對象的樣子如圖 8-4 所示:
1 redis> SET msg "hello" 2 OK 3 4 redis> OBJECT ENCODING msg 5 "embstr"
- 最后要說的是, 可以用 long double 類型表示的浮點數在 Redis 中也是作為字符串值來保存的: 如果我們要保存一個浮點數到字符串對象里面, 那么程序會先將這個浮點數轉換成字符串值, 然后再保存起轉換所得的字符串值。在有需要的時候, 程序會將保存在字符串對象里面的字符串值轉換回浮點數值, 執行某些操作, 然后再將執行操作所得的浮點數值轉換回字符串值, 並繼續保存在字符串對象里面。
表 8-6 字符串對象保存各類型值的編碼方式
值 | 編碼 |
可以用 long 類型保存的整數。 | int |
可以用 long double 類型保存的浮點數。 | embstr 或者 raw |
字符串值, 或者因為長度太大而沒辦法用 long 類型表示的整數, 又或者因為長度太大而沒辦法用 long double 類型表示的浮點數。 | embstr 或者 raw |
編碼的轉換
- int 編碼的字符串對象和 embstr 編碼的字符串對象在條件滿足的情況下, 會被轉換為 raw 編碼的字符串對象。
- 對於 int 編碼的字符串對象來說, 如果我們向對象執行了一些命令, 使得這個對象保存的不再是整數值, 而是一個字符串值, 那么字符串對象的編碼將從 int 變為 raw 。比如APPEND 命令
- 另外, 因為 Redis 沒有為 embstr 編碼的字符串對象編寫任何相應的修改程序 (只有 int 編碼的字符串對象和 raw 編碼的字符串對象有這些程序), 所以 embstr 編碼的字符串對象實際上是只讀的: 當我們對 embstr 編碼的字符串對象執行任何修改命令時, 程序會先將對象的編碼從 embstr 轉換成 raw , 然后再執行修改命令; 因為這個原因, embstr 編碼的字符串對象在執行修改命令之后, 總會變成一個 raw 編碼的字符串對象。
字符串命令的實現
因為字符串鍵的值為字符串對象, 所以用於字符串鍵的所有命令都是針對字符串對象來構建的, 表 8-7 列舉了其中一部分字符串命令, 以及這些命令在不同編碼的字符串對象下的實現方法。
命令 | int 編碼的實現方法 | embstr 編碼的實現方法 | raw 編碼的實現方法 |
SET | 使用 int 編碼保存值。 | 使用 embstr 編碼保存值。 | 使用 raw 編碼保存值。 |
GET | 拷貝對象所保存的整數值, 將這個拷貝轉換成字符串值, 然后向客戶端返回這個字符串值。 | 直接向客戶端返回字符串值。 | 直接向客戶端返回字符串值。 |
APPEND | 將對象轉換成 raw 編碼, 然后按 raw 編碼的方式執行此操作。 | 將對象轉換成 raw 編碼, 然后按 raw 編碼的方式執行此操作。 | 調用 sdscatlen 函數, 將給定字符串追加到現有字符串的末尾。 |
INCRBYFLOAT | 取出整數值並將其轉換成 long double 類型的浮點數, 對這個浮點數進行加法計算, 然后將得出的浮點數結果保存起來。 | 取出字符串值並嘗試將其轉換成 long double 類型的浮點數, 對這個浮點數進行加法計算, 然后將得出的浮點數結果保存起來。 如果字符串值不能被轉換成浮點數, 那么向客戶端返回一個錯誤。 | 取出字符串值並嘗試將其轉換成 long double 類型的浮點數, 對這個浮點數進行加法計算, 然后將得出的浮點數結果保存起來。 如果字符串值不能被轉換成浮點數, 那么向客戶端返回一個錯誤。 |
INCRBY | 對整數值進行加法計算, 得出的計算結果會作為整數被保存起來。 | embstr 編碼不能執行此命令, 向客戶端返回一個錯誤。 | raw 編碼不能執行此命令, 向客戶端返回一個錯誤。 |
DECRBY | 對整數值進行減法計算, 得出的計算結果會作為整數被保存起來。 | embstr 編碼不能執行此命令, 向客戶端返回一個錯誤。 | raw 編碼不能執行此命令, 向客戶端返回一個錯誤。 |
STRLEN | 拷貝對象所保存的整數值, 將這個拷貝轉換成字符串值, 計算並返回這個字符串值的長度。 | 調用 sdslen 函數, 返回字符串的長度。 | 調用 sdslen 函數, 返回字符串的長度。 |
SETRANGE | 將對象轉換成 raw 編碼, 然后按 raw 編碼的方式執行此命令。 | 將對象轉換成 raw 編碼, 然后按 raw 編碼的方式執行此命令。 | 將字符串特定索引上的值設置為給定的字符。 |
GETRANGE | 拷貝對象所保存的整數值, 將這個拷貝轉換成字符串值, 然后取出並返回字符串指定索引上的字符。 | 直接取出並返回字符串指定索引上的字符。 |
列表對象
- 列表對象的編碼可以是 ziplist 或者 linkedlist 。
- ziplist 編碼的列表對象使用壓縮列表作為底層實現, 每個壓縮列表節點(entry)保存了一個列表元素。
- 另一方面, linkedlist 編碼的列表對象使用雙端鏈表作為底層實現, 每個雙端鏈表節點(node)都保存了一個字符串對象, 而每個字符串對象都保存了一個列表元素。
舉個例子, 如果我們執行以下 RPUSH 命令, 那么服務器將創建一個列表對象作為 numbers 鍵的值:
1 redis> RPUSH numbers 1 "three" 5 2 (integer) 3
注意, linkedlist 編碼的列表對象在底層的雙端鏈表結構中包含了多個字符串對象, 這種嵌套字符串對象的行為在稍后介紹的哈希對象、集合對象和有序集合對象中都會出現, 字符串對象是 Redis 五種類型的對象中唯一一種會被其他四種類型對象嵌套的對象。
注意
為了簡化字符串對象的表示, 我們在圖 8-6 使用了一個帶有 StringObject 字樣的格子來表示一個字符串對象, 而 StringObject 字樣下面的是字符串對象所保存的值。
比如說, 圖 8-7 代表的就是一個包含了字符串值 "three" 的字符串對象, 它是 8-8 的簡化表示。
本書接下來的內容將繼續沿用這一簡化表示。
編碼轉換
當列表對象可以同時滿足以下兩個條件時, 列表對象使用 ziplist 編碼:
- 列表對象保存的所有字符串元素的長度都小於 64 字節;
- 列表對象保存的元素數量小於 512 個;
不能滿足這兩個條件的列表對象需要使用 linkedlist 編碼。
- 對於使用 ziplist 編碼的列表對象來說, 當使用 ziplist 編碼所需的兩個條件的任意一個不能被滿足時, 對象的編碼轉換操作就會被執行: 原本保存在壓縮列表里的所有列表元素都會被轉移並保存到雙端鏈表里面, 對象的編碼也會從 ziplist 變為 linkedlist 。
注意
以上兩個條件的上限值是可以修改的, 具體請看配置文件中關於 list-max-ziplist-value 選項和 list-max-ziplist-entries 選項的說明。
列表命令的實現
因為列表鍵的值為列表對象, 所以用於列表鍵的所有命令都是針對列表對象來構建的,
表 8-8 列出了其中一部分列表鍵命令, 以及這些命令在不同編碼的列表對象下的實現方法。
命令 | ziplist 編碼的實現方法 | linkedlist 編碼的實現方法 |
LPUSH | 調用 ziplistPush 函數, 將新元素推入到壓縮列表的表頭。 | 調用 listAddNodeHead 函數, 將新元素推入到雙端鏈表的表頭。 |
RPUSH | 調用 ziplistPush 函數, 將新元素推入到壓縮列表的表尾。 | 調用 listAddNodeTail 函數, 將新元素推入到雙端鏈表的表尾。 |
LPOP | 調用 ziplistIndex 函數定位壓縮列表的表頭節點, 在向用戶返回節點所保存的元素之后, 調用 ziplistDelete 函數刪除表頭節點。 | 調用 listFirst 函數定位雙端鏈表的表頭節點, 在向用戶返回節點所保存的元素之后, 調用 listDelNode 函數刪除表頭節點。 |
RPOP | 調用 ziplistIndex 函數定位壓縮列表的表尾節點, 在向用戶返回節點所保存的元素之后, 調用 ziplistDelete 函數刪除表尾節點。 | 調用 listLast 函數定位雙端鏈表的表尾節點, 在向用戶返回節點所保存的元素之后, 調用 listDelNode 函數刪除表尾節點。 |
LINDEX | 調用 ziplistIndex 函數定位壓縮列表中的指定節點, 然后返回節點所保存的元素。 | 調用 listIndex 函數定位雙端鏈表中的指定節點, 然后返回節點所保存的元素。 |
LLEN | 調用 ziplistLen 函數返回壓縮列表的長度。 | 調用 listLength 函數返回雙端鏈表的長度。 |
LINSERT | 插入新節點到壓縮列表的表頭或者表尾時, 使用 ziplistPush 函數; 插入新節點到壓縮列表的其他位置時, 使用 ziplistInsert 函數。 | 調用 listInsertNode 函數, 將新節點插入到雙端鏈表的指定位置。 |
LREM | 遍歷壓縮列表節點, 並調用 ziplistDelete 函數刪除包含了給定元素的節點。 | 遍歷雙端鏈表節點, 並調用 listDelNode 函數刪除包含了給定元素的節點。 |
LTRIM | 調用 ziplistDeleteRange 函數, 刪除壓縮列表中所有不在指定索引范圍內的節點。 | 遍歷雙端鏈表節點, 並調用 listDelNode 函數刪除鏈表中所有不在指定索引范圍內的節點。 |
LSET | 調用 ziplistDelete 函數, 先刪除壓縮列表指定索引上的現有節點, 然后調用 ziplistInsert 函數, 將一個包含給定元素的新節點插入到相同索引上面。 | 調用 listIndex 函數, 定位到雙端鏈表指定索引上的節點, 然后通過賦值操作更新節點的值。 |
哈希對象
- 哈希對象的編碼可以是 ziplist 或者 hashtable 。
- ziplist 編碼的哈希對象使用壓縮列表作為底層實現, 每當有新的鍵值對要加入到哈希對象時, 程序會先將保存了鍵的壓縮列表節點推入到壓縮列表表尾, 然后再將保存了值的壓縮列表節點推入到壓縮列表表尾, 因此:
- 保存了同一鍵值對的兩個節點總是緊挨在一起, 保存鍵的節點在前, 保存值的節點在后;
- 先添加到哈希對象中的鍵值對會被放在壓縮列表的表頭方向, 而后來添加到哈希對象中的鍵值對會被放在壓縮列表的表尾方向。
- 另一方面, hashtable 編碼的哈希對象使用字典作為底層實現, 哈希對象中的每個鍵值對都使用一個字典鍵值對來保存:
- 字典的每個鍵都是一個字符串對象, 對象中保存了鍵值對的鍵;
- 字典的每個值都是一個字符串對象, 對象中保存了鍵值對的值。
舉個例子, 如果我們執行以下 HSET 命令, 那么服務器將創建一個列表對象作為 profile 鍵的值:
1 redis> HSET profile name "Tom" 2 (integer) 1 3 4 redis> HSET profile age 25 5 (integer) 1 6 7 redis> HSET profile career "Programmer" 8 (integer) 1
編碼轉換
當哈希對象可以同時滿足以下兩個條件時, 哈希對象使用 ziplist 編碼:
- 哈希對象保存的所有鍵值對的鍵和值的字符串長度都小於 64 字節;
- 哈希對象保存的鍵值對數量小於 512 個;
不能滿足這兩個條件的哈希對象需要使用 hashtable 編碼。
- 對於使用 ziplist 編碼的列表對象來說, 當使用 ziplist 編碼所需的兩個條件的任意一個不能被滿足時, 對象的編碼轉換操作就會被執行: 原本保存在壓縮列表里的所有鍵值對都會被轉移並保存到字典里面, 對象的編碼也會從 ziplist 變為 hashtable 。
注意
這兩個條件的上限值是可以修改的, 具體請看配置文件中關於 hash-max-ziplist-value 選項和 hash-max-ziplist-entries 選項的說明。
哈希命令的實現
因為哈希鍵的值為哈希對象, 所以用於哈希鍵的所有命令都是針對哈希對象來構建的, 表 8-9 列出了其中一部分哈希鍵命令, 以及這些命令在不同編碼的哈希對象下的實現方法。
命令 | ziplist 編碼實現方法 | hashtable 編碼的實現方法 |
HSET | 首先調用 ziplistPush 函數, 將鍵推入到壓縮列表的表尾, 然后再次調用 ziplistPush 函數, 將值推入到壓縮列表的表尾。 | 調用 dictAdd 函數, 將新節點添加到字典里面。 |
HGET | 首先調用 ziplistFind 函數, 在壓縮列表中查找指定鍵所對應的節點, 然后調用 ziplistNext 函數, 將指針移動到鍵節點旁邊的值節點, 最后返回值節點。 | 調用 dictFind 函數, 在字典中查找給定鍵, 然后調用 dictGetVal 函數, 返回該鍵所對應的值。 |
HEXISTS | 調用 ziplistFind 函數, 在壓縮列表中查找指定鍵所對應的節點, 如果找到的話說明鍵值對存在, 沒找到的話就說明鍵值對不存在。 | 調用 dictFind 函數, 在字典中查找給定鍵, 如果找到的話說明鍵值對存在, 沒找到的話就說明鍵值對不存在。 |
HDEL | 調用 ziplistFind 函數, 在壓縮列表中查找指定鍵所對應的節點, 然后將相應的鍵節點、 以及鍵節點旁邊的值節點都刪除掉。 | 調用 dictDelete 函數, 將指定鍵所對應的鍵值對從字典中刪除掉。 |
HLEN | 調用 ziplistLen 函數, 取得壓縮列表包含節點的總數量, 將這個數量除以 2 , 得出的結果就是壓縮列表保存的鍵值對的數量。 | 調用 dictSize 函數, 返回字典包含的鍵值對數量, 這個數量就是哈希對象包含的鍵值對數量。 |
HGETALL | 遍歷整個壓縮列表, 用 ziplistGet 函數返回所有鍵和值(都是節點)。 | 遍歷整個字典, 用 dictGetKey 函數返回字典的鍵, 用 dictGetVal 函數返回字典的值。 |
集合對象
- 集合對象的編碼可以是 intset 或者 hashtable 。
- intset 編碼的集合對象使用整數集合作為底層實現, 集合對象包含的所有元素都被保存在整數集合里面。
- 另一方面, hashtable 編碼的集合對象使用字典作為底層實現, 字典的每個鍵都是一個字符串對象, 每個字符串對象包含了一個集合元素, 而字典的值則全部被設置為 NULL 。
舉個例子, 以下代碼將創建一個如圖 8-12 所示的 intset 編碼集合對象:
1 redis> SADD numbers 1 3 5 2 (integer) 3
以下代碼將創建一個如圖 8-13 所示的 hashtable 編碼集合對象:
1 redis> SADD fruits "apple" "banana" "cherry" 2 (integer) 3
編碼的轉換
當集合對象可以同時滿足以下兩個條件時, 對象使用 intset 編碼:
- 集合對象保存的所有元素都是整數值;
- 集合對象保存的元素數量不超過 512 個;
不能滿足這兩個條件的集合對象需要使用 hashtable 編碼。
- 對於使用 intset 編碼的集合對象來說, 當使用 intset 編碼所需的兩個條件的任意一個不能被滿足時, 對象的編碼轉換操作就會被執行: 原本保存在整數集合中的所有元素都會被轉移並保存到字典里面, 並且對象的編碼也會從 intset 變為 hashtable 。
注意
第二個條件的上限值是可以修改的, 具體請看配置文件中關於 set-max-intset-entries 選項的說明。
集合命令的實現
因為集合鍵的值為集合對象, 所以用於集合鍵的所有命令都是針對集合對象來構建的, 表 8-10 列出了其中一部分集合鍵命令, 以及這些命令在不同編碼的集合對象下的實現方法。
表 8-10 集合命令的實現方法
命令 | intset 編碼的實現方法 | hashtable 編碼的實現方法 |
SADD | 調用 intsetAdd 函數, 將所有新元素添加到整數集合里面。 | 調用 dictAdd , 以新元素為鍵, NULL 為值, 將鍵值對添加到字典里面。 |
SCARD | 調用 intsetLen 函數, 返回整數集合所包含的元素數量, 這個數量就是集合對象所包含的元素數量。 | 調用 dictSize 函數, 返回字典所包含的鍵值對數量, 這個數量就是集合對象所包含的元素數量。 |
SISMEMBER | 調用 intsetFind 函數, 在整數集合中查找給定的元素, 如果找到了說明元素存在於集合, 沒找到則說明元素不存在於集合。 | 調用 dictFind 函數, 在字典的鍵中查找給定的元素, 如果找到了說明元素存在於集合, 沒找到則說明元素不存在於集合。 |
SMEMBERS | 遍歷整個整數集合, 使用 intsetGet 函數返回集合元素。 | 遍歷整個字典, 使用 dictGetKey 函數返回字典的鍵作為集合元素。 |
SRANDMEMBER | 調用 intsetRandom 函數, 從整數集合中隨機返回一個元素。 | 調用 dictGetRandomKey 函數, 從字典中隨機返回一個字典鍵。 |
SPOP | 調用 intsetRandom 函數, 從整數集合中隨機取出一個元素, 在將這個隨機元素返回給客戶端之后, 調用 intsetRemove 函數, 將隨機元素從整數集合中刪除掉。 | 調用 dictGetRandomKey 函數, 從字典中隨機取出一個字典鍵, 在將這個隨機字典鍵的值返回給客戶端之后, 調用 dictDelete 函數, 從字典中刪除隨機字典鍵所對應的鍵值對。 |
SREM | 調用 intsetRemove 函數, 從整數集合中刪除所有給定的元素。 | 調用 dictDelete 函數, 從字典中刪除所有鍵為給定元素的鍵值對。 |
有序集合對象
- 有序集合的編碼可以是 ziplist 或者 skiplist 。
- ziplist 編碼的有序集合對象使用壓縮列表作為底層實現, 每個集合元素使用兩個緊挨在一起的壓縮列表節點來保存, 第一個節點保存元素的成員(member), 而第二個元素則保存元素的分值(score)。
- 壓縮列表內的集合元素按分值從小到大進行排序, 分值較小的元素被放置在靠近表頭的方向, 而分值較大的元素則被放置在靠近表尾的方向。
- skiplist 編碼的有序集合對象使用 zset 結構作為底層實現, 一個 zset 結構同時包含一個字典和一個跳躍表:
1 typedef struct zset { 2 3 zskiplist *zsl; 4 dict *dict; 5 6 } zset;
-
- zset 結構中的 zsl 跳躍表按分值從小到大保存了所有集合元素, 每個跳躍表節點都保存了一個集合元素: 跳躍表節點的 object 屬性保存了元素的成員, 而跳躍表節點的 score 屬性則保存了元素的分值。 通過這個跳躍表, 程序可以對有序集合進行范圍型操作, 比如 ZRANK 、 ZRANGE 等命令就是基於跳躍表 API 來實現的。
- zset 結構中的 dict 字典為有序集合創建了一個從成員到分值的映射, 字典中的每個鍵值對都保存了一個集合元素: 字典的鍵保存了元素的成員, 而字典的值則保存了元素的分值。 通過這個字典, 程序可以用 O(1) 復雜度查找給定成員的分值, ZSCORE 命令就是根據這一特性實現的, 而很多其他有序集合命令都在實現的內部用到了這一特性。
- 值得一提的是, 雖然 zset 結構同時使用跳躍表和字典來保存有序集合元素, 但這兩種數據結構都會通過指針來共享相同元素的成員和分值, 所以同時使用跳躍表和字典來保存集合元素不會產生任何重復成員或者分值, 也不會因此而浪費額外的內存。
- 有序集合每個元素的成員都是一個字符串對象, 而每個元素的分值都是一個 double 類型的浮點數。
舉個例子, 如果我們執行以下 ZADD 命令, 那么服務器將創建一個有序集合對象作為 price 鍵的值:
1 redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry 2 (integer) 3
- 如果 price 鍵的值對象使用的是 ziplist 編碼, 那么這個值對象將會是圖 8-14 所示的樣子, 而對象所使用的壓縮列表則會是 8-15 所示的樣子。
- 如果前面 price 鍵創建的不是 ziplist 編碼的有序集合對象, 而是 skiplist 編碼的有序集合對象, 那么這個有序集合對象將會是圖 8-16 所示的樣子, 而對象所使用的 zset 結構將會是圖 8-17 所示的樣子。
注意
為了展示方便, 圖 8-17 在字典和跳躍表中重復展示了各個元素的成員和分值, 但在實際中, 字典和跳躍表會共享元素的成員和分值, 所以並不會造成任何數據重復, 也不會因此而浪費任何內存。
為什么有序集合需要同時使用跳躍表和字典來實現?
- 在理論上來說, 有序集合可以單獨使用字典或者跳躍表的其中一種數據結構來實現, 但無論單獨使用字典還是跳躍表, 在性能上對比起同時使用字典和跳躍表都會有所降低。
- 舉個例子, 如果我們只使用字典來實現有序集合, 那么雖然以 O(1) 復雜度查找成員的分值這一特性會被保留, 但是, 因為字典以無序的方式來保存集合元素, 所以每次在執行范圍型操作 —— 比如 ZRANK 、 ZRANGE 等命令時, 程序都需要對字典保存的所有元素進行排序, 完成這種排序需要至少 O(N \log N) 時間復雜度, 以及額外的 O(N) 內存空間 (因為要創建一個數組來保存排序后的元素)。
- 另一方面, 如果我們只使用跳躍表來實現有序集合, 那么跳躍表執行范圍型操作的所有優點都會被保留, 但因為沒有了字典, 所以根據成員查找分值這一操作的復雜度將從 O(1) 上升為 O(\log N) 。
- 因為以上原因, 為了讓有序集合的查找和范圍型操作都盡可能快地執行, Redis 選擇了同時使用字典和跳躍表兩種數據結構來實現有序集合。
編碼的轉換
當有序集合對象可以同時滿足以下兩個條件時, 對象使用 ziplist 編碼:
- 有序集合保存的元素數量小於 128 個;
- 有序集合保存的所有元素成員的長度都小於 64 字節;
不能滿足以上兩個條件的有序集合對象將使用 skiplist 編碼。
- 對於使用 ziplist 編碼的有序集合對象來說, 當使用 ziplist 編碼所需的兩個條件中的任意一個不能被滿足時, 程序就會執行編碼轉換操作, 將原本儲存在壓縮列表里面的所有集合元素轉移到 zset 結構里面, 並將對象的編碼從 ziplist 改為 skiplist 。
注意
以上兩個條件的上限值是可以修改的, 具體請看配置文件中關於 zset-max-ziplist-entries 選項和 zset-max-ziplist-value 選項的說明。
有序集合命令的實現
因為有序集合鍵的值為有序集合對象, 所以用於有序集合鍵的所有命令都是針對有序集合對象來構建的, 表 8-11 列出了其中一部分有序集合鍵命令, 以及這些命令在不同編碼的有序集合對象下的實現方法。
命令 | ziplist 編碼的實現方法 | zset 編碼的實現方法 |
ZADD | 調用 ziplistInsert 函數, 將成員和分值作為兩個節點分別插入到壓縮列表。 | 先調用 zslInsert 函數, 將新元素添加到跳躍表, 然后調用 dictAdd 函數, 將新元素關聯到字典。 |
ZCARD | 調用 ziplistLen 函數, 獲得壓縮列表包含節點的數量, 將這個數量除以 2 得出集合元素的數量。 | 訪問跳躍表數據結構的 length 屬性, 直接返回集合元素的數量。 |
ZCOUNT | 遍歷壓縮列表, 統計分值在給定范圍內的節點的數量。 | 遍歷跳躍表, 統計分值在給定范圍內的節點的數量。 |
ZRANGE | 從表頭向表尾遍歷壓縮列表, 返回給定索引范圍內的所有元素。 | 從表頭向表尾遍歷跳躍表, 返回給定索引范圍內的所有元素。 |
ZREVRANGE | 從表尾向表頭遍歷壓縮列表, 返回給定索引范圍內的所有元素。 | 從表尾向表頭遍歷跳躍表, 返回給定索引范圍內的所有元素。 |
ZRANK | 從表頭向表尾遍歷壓縮列表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 | 從表頭向表尾遍歷跳躍表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 |
ZREVRANK | 從表尾向表頭遍歷壓縮列表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 | 從表尾向表頭遍歷跳躍表, 查找給定的成員, 沿途記錄經過節點的數量, 當找到給定成員之后, 途經節點的數量就是該成員所對應元素的排名。 |
ZREM | 遍歷壓縮列表, 刪除所有包含給定成員的節點, 以及被刪除成員節點旁邊的分值節點。 | 遍歷跳躍表, 刪除所有包含了給定成員的跳躍表節點。 並在字典中解除被刪除元素的成員和分值的關聯。 |
ZSCORE | 遍歷壓縮列表, 查找包含了給定成員的節點, 然后取出成員節點旁邊的分值節點保存的元素分值。 | 直接從字典中取出給定成員的分值。 |
類型檢查與命令多態
- Redis 中用於操作鍵的命令基本上可以分為兩種類型。
- 其中一種命令可以對任何類型的鍵執行, 比如說 DEL 命令、 EXPIRE 命令、 RENAME 命令、 TYPE 命令、 OBJECT 命令, 等等。
- 而另一種命令只能對特定類型的鍵執行, 比如說:
- SET 、 GET 、 APPEND 、 STRLEN 等命令只能對字符串鍵執行;
- HDEL 、 HSET 、 HGET 、 HLEN 等命令只能對哈希鍵執行;
- RPUSH 、 LPOP 、 LINSERT 、 LLEN 等命令只能對列表鍵執行;
- SADD 、 SPOP 、 SINTER 、 SCARD 等命令只能對集合鍵執行;
- ZADD 、 ZCARD 、 ZRANK 、 ZSCORE 等命令只能對有序集合鍵執行;
例子1, 以下代碼就展示了使用 DEL 命令來刪除三種不同類型的鍵:
1 # 字符串鍵 2 redis> SET msg "hello" 3 OK 4 5 # 列表鍵 6 redis> RPUSH numbers 1 2 3 7 (integer) 3 8 9 # 集合鍵 10 redis> SADD fruits apple banana cherry 11 (integer) 3 12 13 redis> DEL msg 14 (integer) 1 15 16 redis> DEL numbers 17 (integer) 1 18 19 redis> DEL fruits 20 (integer) 1
例子2, 我們可以用 SET 命令創建一個字符串鍵, 然后用 GET 命令和 APPEND 命令操作這個鍵, 但如果我們試圖對這個字符串鍵執行只有列表鍵才能執行的 LLEN 命令, 那么 Redis 將向我們返回一個類型錯誤:
1 redis> SET msg "hello world" 2 OK 3 4 redis> GET msg 5 "hello world" 6 7 redis> APPEND msg " again!" 8 (integer) 18 9 10 redis> GET msg 11 "hello world again!" 12 13 redis> LLEN msg 14 (error) WRONGTYPE Operation against a key holding the wrong kind of value
類型檢查的實現
從上面發生類型錯誤的代碼示例可以看出, 為了確保只有指定類型的鍵可以執行某些特定的命令, 在執行一個類型特定的命令之前, Redis 會先檢查輸入鍵的類型是否正確, 然后再決定是否執行給定的命令。
類型特定命令所進行的類型檢查是通過 redisObject 結構的 type 屬性來實現的:
- 在執行一個類型特定命令之前, 服務器會先檢查輸入數據庫鍵的值對象是否為執行命令所需的類型, 如果是的話, 服務器就對鍵執行指定的命令;
- 否則, 服務器將拒絕執行命令, 並向客戶端返回一個類型錯誤。
舉個例子, 對於 LLEN 命令來說:
- 在執行 LLEN 命令之前, 服務器會先檢查輸入數據庫鍵的值對象是否為列表類型, 也即是, 檢查值對象 redisObject 結構 type 屬性的值是否為 REDIS_LIST , 如果是的話, 服務器就對鍵執行 LLEN 命令;
- 否則的話, 服務器就拒絕執行命令並向客戶端返回一個類型錯誤;
其他類型特定命令的類型檢查過程也和這里展示的 LLEN 命令的類型檢查過程類似。
多態命令的實現
- Redis 除了會根據值對象的類型來判斷鍵是否能夠執行指定命令之外, 還會根據值對象的編碼方式, 選擇正確的命令實現代碼來執行命令。
- 舉個例子, 在前面介紹列表對象的編碼時我們說過, 列表對象有 ziplist 和 linkedlist 兩種編碼可用, 其中前者使用壓縮列表 API 來實現列表命令, 而后者則使用雙端鏈表 API 來實現列表命令。
現在, 考慮這樣一個情況, 如果我們對一個鍵執行 LLEN 命令, 那么服務器除了要確保執行命令的是列表鍵之外, 還需要根據鍵的值對象所使用的編碼來選擇正確的 LLEN 命令實現:
- 如果列表對象的編碼為 ziplist , 那么說明列表對象的實現為壓縮列表, 程序將使用 ziplistLen 函數來返回列表的長度;
- 如果列表對象的編碼為 linkedlist , 那么說明列表對象的實現為雙端鏈表, 程序將使用 listLength 函數來返回雙端鏈表的長度;
借用面向對象方面的術語來說, 我們可以認為 LLEN 命令是多態(polymorphism)的: 只要執行 LLEN 命令的是列表鍵, 那么無論值對象使用的是 ziplist 編碼還是 linkedlist 編碼, 命令都可以正常執行。
圖 8-19 其他類型特定命令的執行過程也是類似的。
實際上, 我們可以將 DEL 、 EXPIRE 、 TYPE 等命令也稱為多態命令, 因為無論輸入的鍵是什么類型, 這些命令都可以正確地執行。他們和 LLEN 等命令的區別在於, 前者是基於類型的多態 —— 一個命令可以同時用於處理多種不同類型的鍵, 而后者是基於編碼的多態 —— 一個命令可以同時用於處理多種不同編碼。
內存回收
- 因為 C 語言並不具備自動的內存回收功能, 所以 Redis 在自己的對象系統中構建了一個引用計數(reference counting)技術實現的內存回收機制, 通過這一機制, 程序可以通過跟蹤對象的引用計數信息, 在適當的時候自動釋放對象並進行內存回收。
- 每個對象的引用計數信息由 redisObject 結構的 refcount 屬性記錄:
1 typedef struct redisObject { 2 3 // ... 4 5 // 引用計數 6 int refcount; 7 8 // ... 9 10 } robj;
- 對象的引用計數信息會隨着對象的使用狀態而不斷變化:
- 在創建一個新對象時, 引用計數的值會被初始化為 1 ;
- 當對象被一個新程序使用時, 它的引用計數值會被增一;
- 當對象不再被一個程序使用時, 它的引用計數值會被減一;
- 當對象的引用計數值變為 0 時, 對象所占用的內存會被釋放。
- 表 8-12 列出了修改對象引用計數的 API , 這些 API 分別用於增加、減少、重置對象的引用計數。
函數 | 作用 |
incrRefCount | 將對象的引用計數值增一。 |
decrRefCount | 將對象的引用計數值減一, 當對象的引用計數值等於 0 時, 釋放對象。 |
resetRefCount | 將對象的引用計數值設置為 0 , 但並不釋放對象, 這個函數通常在需要重新設置對象的引用計數值時使用。 |
- 對象的整個生命周期可以划分為創建對象、操作對象、釋放對象三個階段。
作為例子, 以下代碼展示了一個字符串對象從創建到釋放的整個過程:
1 // 創建一個字符串對象 s ,對象的引用計數為 1 2 robj *s = createStringObject(...) 3 4 // 對象 s 執行各種操作 ... 5 6 // 將對象 s 的引用計數減一,使得對象的引用計數變為 0 7 // 導致對象 s 被釋放 8 decrRefCount(s)
其他不同類型的對象也會經歷類似的過程。
對象共享
- 除了用於實現內存回收機制之外, 對象的引用計數屬性還帶有對象共享的作用。
- 在 Redis 中, 讓多個鍵共享同一個值對象需要執行以下兩個步驟:
-
- 將數據庫鍵的值指針指向一個現有的值對象;
- 將被共享的值對象的引用計數增一。
舉個例子, 圖 8-21 就展示了包含整數值 100 的字符串對象同時被鍵 A 和鍵 B 共享之后的樣子, 可以看到, 除了對象的引用計數從之前的 1 變成了 2 之外, 其他屬性都沒有變化。
- 共享對象機制對於節約內存非常有幫助, 數據庫中保存的相同值對象越多, 對象共享機制就能節約越多的內存。
比如說, 假設數據庫中保存了整數值 100 的鍵不只有鍵 A 和鍵 B 兩個, 而是有一百個, 那么服務器只需要用一個字符串對象的內存就可以保存原本需要使用一百個字符串對象的內存才能保存的數據。
- 目前來說, Redis 會在初始化服務器時, 創建一萬個字符串對象, 這些對象包含了從 0 到 9999 的所有整數值, 當服務器需要用到值為 0 到 9999 的字符串對象時, 服務器就會使用這些共享對象, 而不是新創建對象。
注意
創建共享字符串對象的數量可以通過修改 redis.h/REDIS_SHARED_INTEGERS 常量來修改。
舉個例子, 如果我們創建一個值為 100 的鍵 A , 並使用 OBJECT REFCOUNT 命令查看鍵 A 的值對象的引用計數, 我們會發現值對象的引用計數為 2 :
1 redis> SET A 100 2 OK 3 4 redis> OBJECT REFCOUNT A 5 (integer) 2
引用這個值對象的兩個程序分別是持有這個值對象的服務器程序, 以及共享這個值對象的鍵 A , 如圖 8-22 所示。
- 另外, 這些共享對象不單單只有字符串鍵可以使用, 那些在數據結構中嵌套了字符串對象的對象(linkedlist 編碼的列表對象、 hashtable 編碼的哈希對象、 hashtable 編碼的集合對象、以及 zset 編碼的有序集合對象)都可以使用這些共享對象。
為什么 Redis 不共享包含字符串的對象?
當服務器考慮將一個共享對象設置為鍵的值對象時, 程序需要先檢查給定的共享對象和鍵想創建的目標對象是否完全相同, 只有在共享對象和目標對象完全相同的情況下, 程序才會將共享對象用作鍵的值對象, 而一個共享對象保存的值越復雜, 驗證共享對象和目標對象是否相同所需的復雜度就會越高, 消耗的 CPU 時間也會越多:
- 如果共享對象是保存整數值的字符串對象, 那么驗證操作的復雜度為 O(1) ;
- 如果共享對象是保存字符串值的字符串對象, 那么驗證操作的復雜度為 O(N) ;
- 如果共享對象是包含了多個值(或者對象的)對象, 比如列表對象或者哈希對象, 那么驗證操作的復雜度將會是 O(N^2) 。
因此, 盡管共享更復雜的對象可以節約更多的內存, 但受到 CPU 時間的限制, Redis 只對包含整數值的字符串對象進行共享。
對象的空轉時長
- 除了前面介紹過的 type 、 encoding 、 ptr 和 refcount 四個屬性之外, redisObject 結構包含的最后一個屬性為 lru 屬性, 該屬性記錄了對象最后一次被命令程序訪問的時間:
typedef struct redisObject { // ... unsigned lru:22; // ... } robj;
- OBJECT IDLETIME 命令可以打印出給定鍵的空轉時長, 這一空轉時長就是通過將當前時間減去鍵的值對象的 lru 時間計算得出的.
- 除了可以被 OBJECT IDLETIME 命令打印出來之外, 鍵的空轉時長還有另外一項作用: 如果服務器打開了 maxmemory 選項, 並且服務器用於回收內存的算法為 volatile-lru 或者 allkeys-lru , 那么當服務器占用的內存數超過了 maxmemory 選項所設置的上限值時, 空轉時長較高的那部分鍵會優先被服務器釋放, 從而回收內存。
- 配置文件的 maxmemory 選項和 maxmemory-policy 選項的說明介紹了關於這方面的更多信息。
1 redis> SET msg "hello world" 2 OK 3 4 # 等待一小段時間 5 redis> OBJECT IDLETIME msg 6 (integer) 20 7 8 # 等待一陣子 9 redis> OBJECT IDLETIME msg 10 (integer) 180 11 12 # 訪問 msg 鍵的值 13 redis> GET msg 14 "hello world" 15 16 # 鍵處於活躍狀態,空轉時長為 0 17 redis> OBJECT IDLETIME msg 18 (integer) 0
Redis五種類型的鍵的介紹到這里就結束了,歡迎和大家討論、交流。
內容參考自: 《Redis設計與實現》
========== 碼字不易,轉載請注明出處 ==========