高性能的Redis之對象底層實現原理詳解


對象

在前面的數個章節里, 我們陸續介紹了 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"

 

編碼和底層實現

  1. 對象的 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"
  1. 其中,每種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"
復制代碼

 

  1. 通過 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 編碼的字符串對象來保存短字符串值有以下好處:

  1. embstr 編碼將創建字符串對象所需的內存分配次數從 raw 編碼的兩次降低為一次。
  2. 釋放 embstr 編碼的字符串對象只需要調用一次內存釋放函數, 而釋放 raw 編碼的字符串對象需要調用兩次內存釋放函數。
  3. 因為 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 編碼:

  1. 列表對象保存的所有字符串元素的長度都小於 64 字節;
  2. 列表對象保存的元素數量小於 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 編碼:

  1. 哈希對象保存的所有鍵值對的鍵和值的字符串長度都小於 64 字節;
  2. 哈希對象保存的鍵值對數量小於 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 編碼:

  1. 集合對象保存的所有元素都是整數值;
  2. 集合對象保存的元素數量不超過 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 編碼:

  1. 有序集合保存的元素數量小於 128 個;
  2. 有序集合保存的所有元素成員的長度都小於 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 屬性來實現的:

  1. 在執行一個類型特定命令之前, 服務器會先檢查輸入數據庫鍵的值對象是否為執行命令所需的類型, 如果是的話, 服務器就對鍵執行指定的命令;
  2. 否則, 服務器將拒絕執行命令, 並向客戶端返回一個類型錯誤。

舉個例子, 對於 LLEN 命令來說:

  1. 在執行 LLEN 命令之前, 服務器會先檢查輸入數據庫鍵的值對象是否為列表類型, 也即是, 檢查值對象 redisObject 結構 type 屬性的值是否為 REDIS_LIST , 如果是的話, 服務器就對鍵執行 LLEN 命令;
  2. 否則的話, 服務器就拒絕執行命令並向客戶端返回一個類型錯誤;

 

 

 其他類型特定命令的類型檢查過程也和這里展示的 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 中, 讓多個鍵共享同一個值對象需要執行以下兩個步驟:
    1. 將數據庫鍵的值指針指向一個現有的值對象;
    2. 將被共享的值對象的引用計數增一。

舉個例子, 圖 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五種類型的鍵的介紹到這里就結束了,歡迎和大家討論、交流。 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM