物件
在前面的數個章節裡, 我們陸續介紹了 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五種型別的鍵的介紹到這裡就結束了,歡迎和大家討論、交流。