1. 程式人生 > 其它 >高效能的Redis之物件底層實現原理詳解

高效能的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"的物件, 而鍵值對的值則是一個包含了字串值"helloworld"的物件:

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"
  • 最後要說的是, 可以用longdouble型別表示的浮點數在 Redis 中也是作為字串值來儲存的: 如果我們要儲存一個浮點數到字串物件裡面, 那麼程式會先將這個浮點數轉換成字串值, 然後再儲存起轉換所得的字串值。在有需要的時候, 程式會將儲存在字串物件裡面的字串值轉換回浮點數值, 執行某些操作, 然後再將執行操作所得的浮點數值轉換回字串值, 並繼續儲存在字串物件裡面。

表 8-6 字串物件儲存各型別值的編碼方式

編碼
可以用long型別儲存的整數。 int
可以用longdouble型別儲存的浮點數。 embstr或者raw
字串值, 或者因為長度太大而沒辦法用long型別表示的整數, 又或者因為長度太大而沒辦法用longdouble型別表示的浮點數。 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 取出整數值並將其轉換成longdouble型別的浮點數, 對這個浮點數進行加法計算, 然後將得出的浮點數結果儲存起來。 取出字串值並嘗試將其轉換成longdouble型別的浮點數, 對這個浮點數進行加法計算, 然後將得出的浮點數結果儲存起來。 如果字串值不能被轉換成浮點數, 那麼向客戶端返回一個錯誤。 取出字串值並嘗試將其轉換成longdouble型別的浮點數, 對這個浮點數進行加法計算, 然後將得出的浮點數結果儲存起來。 如果字串值不能被轉換成浮點數, 那麼向客戶端返回一個錯誤。
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五種型別的鍵的介紹到這裡就結束了,歡迎和大家討論、交流。