《redis設計與實現》


 

一、數據結構與對象

1. 引言

1.1 版本說明

本書是基於 Redis 2.9 —— 也即是 Redis 3.0 的開發版來編寫的, 因為 Redis 3.0 的更新主要與 Redis 的多機功能有關, 而 Redis 3.0 的單機功能則與 Redis 2.6 、Redis 2.8 的單機功能基本相同, 所以本書的內容對於使用 Redis 2.6 至 Redis 3.0 的讀者來說應該都是有用的。

另外, 因為 Redis 通常都是漸進地增加新功能, 並且很少會大幅地修改已有的功能, 所以本書的大部分內容對於 Redis 3.0 之后的幾個版本來說, 應該也是有用的。

1.2 章節編排

本書由《數據結構與對象》、《單機數據庫的實現》、《多機數據庫的實現》、《獨立功能的實現》四個部分組成。

第一部分

Redis 數據庫里面的每個鍵值對(key-value pair)都是由對象(object)組成的:

其中, 數據庫鍵總是一個字符串對象(string object);
而數據庫鍵的值則可以是字符串對象、 列表對象(list object)、 哈希對象(hash object)、 集合對象(set object)、 有序集合對象(sorted set object)這五種對象中的其中一種。

本書的第一部分 —— 也即是《數據結構與對象》部分, 將對以上提到的五種不同類型的對象進行介紹, 剖析這些對象所使用的底層數據結構, 並說明這些數據結構是如何深刻地影響對象的功能和性能的。

第二部分

本書的第二部分 —— 也即是《單機數據庫的實現》部分, 對 Redis 實現單機數據庫的方法進行了介紹。

《數據庫》一章對 Redis 數據庫的實現原理進行了介紹: 說明了服務器保存鍵值對的方法, 服務器保存鍵值對過期時間的方法, 以及服務器自動刪除過期鍵值對的方法, 等等。

《RDB 持久化》和《AOF 持久化》兩章分別介紹了 Redis 兩種不同的持久化方式的實現原理: 說明了服務器根據數據庫來生成持久化文件的方法, 服務器根據持久化文件來還原數據庫的方法, 以及 BGSAVE 命令和 BGREWRITEAOF 命令的實現原理, 等等。

《事件》一章對 Redis 的文件事件和時間事件進行了介紹:

  • 文件事件主要用於應答(accept)客戶端的連接請求, 接收客戶端發送的命令請求, 以及向客戶端返回命令回復;
  • 而時間事件則主要用於執行 redis.c/serverCron 函數 —— 這個函數通過執行常規的維護和管理操作來保持 Redis 服務器的正常運作, 一些重要的定時操作也是由這個函數負責觸發的。

《客戶端》一章對 Redis 服務器維護和管理客戶端狀態的方法進行了介紹: 列舉了客戶端狀態包含的各個屬性, 說明了客戶端的輸入緩沖區和輸出緩沖區的實現方法, 以及 Redis 服務器創建和銷毀客戶端狀態的條件, 等等。

《服務器》一章對單機 Redis 服務器的運作機制進行了介紹: 詳細地說明了服務器處理命令請求的步驟, 解釋了 serverCron 函數所做的工作, 並講解了 Redis 服務器的初始化過程。

第三部分

本書的第三部分 —— 也即是《多機數據庫的實現》部分, 對 Redis 的 Sentinel 、復制(replication)、集群(cluster)三個多機功能進行了介紹。

《Sentinel》一章對 Redis Sentinel 的實現原理進行了介紹: 說明了 Sentinel 監視服務器的方法, Sentinel 判斷服務器是否下線的方法, 以及 Sentinel 對下線服務器進行故障轉移的方法, 等等。

《復制》一章對 Redis 的主從復制功能(master-slave replication)的實現原理進行了介紹: 說明了當用戶指定一個服務器(從服務器)去復制另一個服務器(主服務器)時, 主從服務器之間執行了什么操作, 進行了什么數據交互, 諸如此類。

《集群》一章對 Redis 集群的實現原理進行了介紹: 說明了節點(node)的構建方法, 節點處理命令請求的方法, 轉發(redirection)錯誤的實現方法, 以及各個節點之間進行通訊的方法, 等等。

第四部分

本書的第四部分 —— 也即是《獨立功能的實現》部分, 對 Redis 中各個相對獨立的功能模塊進行了介紹。

《發布與訂閱》一章對 PUBLISH 、 SUBSCRIBE 、 PUBSUB 等命令的實現原理進行了介紹, 解釋了 Redis 的發布與訂閱功能是如何實現的。

《事務》一章對 MULTI 、 EXEC 、 WATCH 等命令的實現原理進行了介紹, 解釋了 Redis 的事務是如何實現的, 並說明了 Redis 的事務對 ACID 性質的支持程度。

《Lua 腳本》一章對 EVAL 、 EVALSHA 、 SCRIPT_LOAD 等命令的實現原理進行了介紹, 解釋了 Redis 服務器是如何執行和管理用戶傳入的 Lua 腳本的; 這一章還對 Redis 服務器構建 Lua 環境的過程, 以及主從服務器之間復制 Lua 腳本的方法進行了介紹。

《排序》一章對 SORT 命令、 以及 SORT 命令所有可用選項(比如 DESC 、 ALPHA 、 GET,等等)的實現原理進行了介紹, 並說明了當 SORT 命令帶有多個選項時, 不同選項執行的先后順序。

《二進制位數組》一章對 Redis 保存二進制位數組的方法進行了介紹, 並說明了 GETBIT 、 SETBIT 、 BITCOUNT 、 BITOP 這幾個二進制位數組操作命令的實現原理。

《慢查詢日志》一章對 Redis 創建和保存慢查詢日志(slow log)的方法進行了介紹, 並說明了 SLOWLOG GET 、 SLOWLOG LEN 、 SLOWLOG RESET 等慢查詢日志操作命令的實現原理。

《監視器》一章介紹了將客戶端變為監視器(monitor)的方法, 以及服務器在處理命令請求時, 向監視器發送命令信息的方法。

2. 簡單動態字符串

2.1 SDS 的定義

每個 sds.h/sdshdr 結構表示一個 SDS 值:

struct sdshdr {

    // 記錄 buf 數組中已使用字節的數量
    // 等於 SDS 所保存字符串的長度
    int len;

    // 記錄 buf 數組中未使用字節的數量
    int free;

    // 字節數組,用於保存字符串
    char buf[];

};

圖 2-1 展示了一個 SDS 示例:

  • free 屬性的值為 0,表示這個 SDS 沒有分配任何未使用空間。
  • len 屬性的值為 5,表示這個 SDS 保存了一個五字節長的字符串。
  • buf 屬性是一個 char 類型的數組,數組的前五個字節分別保存了 'R'、'e'、'd'、'i'、's' 五個字符,而最后一個字節則保存了空字符 '\0'。

遵循空字符結尾這一慣例的好處是,SDS 可以直接重用一部分 C 字符串函數庫里面的函數。

2.2 SDS 與 C 字符串的區別

C 字符串 SDS
獲取字符串長度的復雜度為 O(N)。 獲取字符串長度的復雜度為 O(1)。
API 是不安全的,可能會造成緩沖區溢出。 API 是安全的,不會造成緩沖區溢出。
修改字符串長度 N 次必然需要執行 N 次內存重分配。 修改字符串長度 N 次最多需要執行 N 次內存重分配。
只能保存文本數據。 可以保存文本或者二進制數據。
可以使用所有 <string.h> 庫中的函數。 可以使用一部分 <string.h> 庫中的函數。

3. 鏈表

3.1 鏈表和鏈表節點的實現

鏈表節點

每個鏈表節點使用一個 adlist.h/listNode 結構來表示:

typedef struct listNode {

    // 前置節點
    struct listNode *prev;

    // 后置節點
    struct listNode *next;

    // 節點的值
    void *value;

} listNode;

多個 listNode 可以通過 prev 和 next 指針組成雙端鏈表,如圖 3-1 所示。

鏈表

雖然僅僅使用多個 listNode 結構就可以組成鏈表,但使用 adlist.h/list 來持有鏈表的話,操作起來會更方便:

typedef struct list {

    // 表頭節點
    listNode *head;

    // 表尾節點
    listNode *tail;

    // 鏈表所包含的節點數量
    unsigned long len;

    // 節點值復制函數
    void *(*dup)(void *ptr);

    // 節點值釋放函數
    void (*free)(void *ptr);

    // 節點值對比函數
    int (*match)(void *ptr, void *key);

} list;

list 結構為鏈表提供了表頭指針 head 、表尾指針 tail,以及鏈表長度計數器 len,而 dup 、 free 和 match 成員則是用於實現多態鏈表所需的類型特定函數:

  • dup 函數用於復制鏈表節點所保存的值;
  • free 函數用於釋放鏈表節點所保存的值;
  • match 函數則用於對比鏈表節點所保存的值和另一個輸入值是否相等。

圖 3-2 是由一個 list 結構和三個 listNode 結構組成的鏈表:

鏈表特性

Redis 的鏈表實現的特性可以總結如下:

  • 雙端: 鏈表節點帶有 prev 和 next 指針,獲取某個節點的前置節點和后置節點的復雜度都是 O(1)。
  • 無環: 表頭節點的 prev 指針和表尾節點的 next 指針都指向 NULL,對鏈表的訪問以 NULL 為終點。
  • 帶表頭指針和表尾指針: 通過 list 結構的 head 指針和 tail 指針,程序獲取鏈表的表頭節點和表尾節點的復雜度為 O(1)。
  • 帶鏈表長度計數器: 程序使用 list 結構的 len 屬性來對 list 持有的鏈表節點進行計數,程序獲取鏈表中節點數量的復雜度為 O(1)。
  • 多態: 鏈表節點使用 void* 指針來保存節點值,並且可以通過 list 結構的 dup 、 free 、 match 三個屬性為節點值設置類型特定函數,所以鏈表可以用於保存各種不同類型的值。

4. 字典

4.1 字典的實現

Redis 的字典使用哈希表作為底層實現,一個哈希表里面可以有多個哈希表節點,而每個哈希表節點就保存了字典中的一個鍵值對。

哈希表

Redis 字典所使用的哈希表由 dict.h/dictht 結構定義:

typedef struct dictht {

    // 哈希表數組
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;

    // 該哈希表已有節點的數量
    unsigned long used;

} dictht;
  • table 屬性是一個數組,數組中的每個元素都是一個指向 dict.h/dictEntry 結構的指針,每個 dictEntry 結構保存着一個鍵值對。
  • size 屬性記錄了哈希表的大小,也即是 table 數組的大小,而 used 屬性則記錄了哈希表目前已有節點(鍵值對)的數量。
  • sizemask 屬性的值總是等於 size - 1,這個屬性和哈希值一起決定一個鍵應該被放到 table 數組的哪個索引上面。

圖 4-1 展示了一個大小為 4 的空哈希表(沒有包含任何鍵值對):

哈希表節點

哈希表節點使用 dictEntry 結構表示,每個 dictEntry 結構都保存着一個鍵值對:

typedef struct dictEntry {

    // 鍵
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下個哈希表節點,形成鏈表
    struct dictEntry *next;

} dictEntry;
  • key 屬性保存着鍵值對中的鍵,而 v 屬性則保存着鍵值對中的值,其中鍵值對的值可以是一個指針,或者是一個 uint64_t 整數,又或者是一個 int64_t 整數。
  • next 屬性是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一次,以此來解決鍵沖突(collision)的問題。

圖 4-2 就展示了如何通過 next 指針,將兩個索引值相同的鍵 k1 和 k0 連接在一起。

字典

Redis 中的字典由 dict.h/dict 結構表示:

typedef struct dict {

    // 類型特定函數
    dictType *type;

    // 私有數據
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 當 rehash 不在進行時,值為 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

} dict;

type 屬性和 privdata 屬性是針對不同類型的鍵值對,為創建多態字典而設置的:

  • type 屬性是一個指向 dictType 結構的指針,每個 dictType 結構保存了一簇用於操作特定類型鍵值對的函數,Redis 會為用途不同的字典設置不同的類型特定函數。
  • privdata 屬性則保存了需要傳給那些類型特定函數的可選參數。
  • ht 屬性是一個包含兩個項的數組,數組中的每個項都是一個 dictht 哈希表,一般情況下,字典只使用 ht[0] 哈希表,ht[1] 哈希表只會在對 ht[0] 哈希表進行 rehash 時使用。
  • rehashidx 屬性記錄了 rehash 目前的進度,如果目前沒有在進行 rehash,那么它的值為 -1。

圖 4-3 展示了一個普通狀態下(沒有進行 rehash)的字典:

4.2 哈希算法

當要將一個新的鍵值對添加到字典里面時,程序需要先根據鍵值對的鍵計算出哈希值和索引值,然后再根據索引值,將包含新鍵值對的哈希表節點放到哈希表數組的指定索引上面。

Redis 計算哈希值和索引值的方法如下:

# 使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);

# 使用哈希表的 sizemask 屬性和哈希值,計算出索引值
# 根據情況不同,ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

當字典被用作數據庫的底層實現,或者哈希鍵的底層實現時,Redis 使用 MurmurHash2 算法來計算鍵的哈希值。

MurmurHash 算法最初由 Austin Appleby 於 2008 年發明,這種算法的優點在於,即使輸入的鍵是有規律的,算法仍能給出一個很好的隨機分布性,並且算法的計算速度也非常快。

MurmurHash 算法目前的最新版本為 MurmurHash3,而 Redis 使用的是 MurmurHash2,關於 MurmurHash 算法的更多信息可以參考該算法的主頁: http://code.google.com/p/smhasher/。

4.3 解決鍵沖突

當有兩個或以上數量的鍵被分配到了哈希表數組的同一個索引上面時,我們稱這些鍵發生了沖突(collision)。

Redis 的哈希表使用鏈地址法(separate chaining)來解決鍵沖突: 每個哈希表節點都有一個 next 指針,多個哈希表節點可以用 next 指針構成一個單向鏈表,被分配到同一個索引上的多個節點可以用這個單向鏈表連接起來,這就解決了鍵沖突的問題。

因為 dictEntry 節點組成的鏈表沒有指向鏈表表尾的指針,所以為了速度考慮,程序總是將新節點添加到鏈表的表頭位置(復雜度為 O(1)),排在其他已有節點的前面。

4.4 rehash

隨着操作的不斷執行,哈希表保存的鍵值對會逐漸地增多或者減少,為了讓哈希表的負載因子(load factor)維持在一個合理的范圍之內,當哈希表保存的鍵值對數量太多或者太少時,程序需要對哈希表的大小進行相應的擴展或者收縮。

擴展和收縮哈希表的工作可以通過執行 rehash (重新散列)操作來完成,Redis 對字典的哈希表執行 rehash 的步驟如下:

  1. 為字典的 ht[1] 哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及 ht[0] 當前包含的鍵值對數量 (也即是 ht[0].used 屬性的值):
    • 如果執行的是擴展操作,那么 ht[1] 的大小為第一個大於等於 ht[0].used * 2 的 2^n (2 的 n 次方冪);
    • 如果執行的是收縮操作,那么 ht[1] 的大小為第一個大於等於 ht[0].used 的 2^n。
  2. 將保存在 ht[0] 中的所有鍵值對 rehash 到 ht[1] 上面: rehash 指的是重新計算鍵的哈希值和索引值,然后將鍵值對放置到 ht[1] 哈希表的指定位置上。
  3. 當 ht[0] 包含的所有鍵值對都遷移到了 ht[1] 之后 (ht[0] 變為空表),釋放 ht[0],將 ht[1] 設置為 ht[0],並在 ht[1] 新創建一個空白哈希表,為下一次 rehash 做准備。

哈希表的擴展與收縮

當以下條件中的任意一個被滿足時,程序會自動開始對哈希表執行擴展操作:

服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且哈希表的負載因子大於等於 1 ;
服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,並且哈希表的負載因子大於等於 5 ;
其中哈希表的負載因子可以通過公式:

# 負載因子 = 哈希表已保存節點數量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行,服務器執行擴展操作所需的負載因子並不相同,這是因為在執行 BGSAVE 命令或 BGREWRITEAOF 命令的過程中,Redis 需要創建當前服務器進程的子進程,而大多數操作系統都采用寫時復制(copy-on-write)技術來優化子進程的使用效率,所以在子進程存在期間,服務器會提高執行擴展操作所需的負載因子,從而盡可能地避免在子進程存在期間進行哈希表擴展操作,這可以避免不必要的內存寫入操作,最大限度地節約內存。

另一方面,當哈希表的負載因子小於 0.1 時,程序自動開始對哈希表執行收縮操作。

4.5 漸進式rehash

上一節說過,擴展或收縮哈希表需要將 ht[0] 里面的所有鍵值對 rehash 到 ht[1] 里面,但是,這個 rehash 動作並不是一次性、集中式地完成的,而是分多次、漸進式地完成的。

這樣做的原因在於,如果 ht[0] 里只保存着四個鍵值對,那么服務器可以在瞬間就將這些鍵值對全部 rehash 到 ht[1] ; 但是,如果哈希表里保存的鍵值對數量不是四個,而是四百萬、四千萬甚至四億個鍵值對,那么要一次性將這些鍵值對全部 rehash 到 ht[1] 的話,龐大的計算量可能會導致服務器在一段時間內停止服務。

以下是哈希表漸進式 rehash 的詳細步驟:

  1. 為 ht[1] 分配空間,讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。
  2. 在字典中維持一個索引計數器變量 rehashidx,並將它的值設置為 0,表示 rehash 工作正式開始。
  3. 在 rehash 進行期間,每次對字典執行添加、刪除、查找或者更新操作時,程序除了執行指定的操作以外,還會順帶將 ht[0] 哈希表在 rehashidx 索引上的所有鍵值對 rehash 到 ht[1],當 rehash 工作完成之后,程序將 rehashidx 屬性的值增一。
  4. 隨着字典操作的不斷執行,最終在某個時間點上,ht[0] 的所有鍵值對都會被 rehash 至 ht[1],這時程序將 rehashidx 屬性的值設為 -1,表示 rehash 操作已完成。

漸進式 rehash 的好處在於它采取分而治之的方式,將 rehash 鍵值對所需的計算工作均攤到對字典的每個添加、刪除、查找和更新操作上,從而避免了集中式 rehash 而帶來的龐大計算量。

漸進式 rehash 執行期間的哈希表操作

因為在進行漸進式 rehash 的過程中,字典會同時使用 ht[0] 和 ht[1] 兩個哈希表,所以在漸進式 rehash 進行期間,字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行: 比如說,要在字典里面查找一個鍵的話,程序會先在 ht[0] 里面進行查找,如果沒找到的話,就會繼續到 ht[1] 里面進行查找,諸如此類。

另外,在漸進式 rehash 執行期間,新添加到字典的鍵值對一律會被保存到 ht[1] 里面,而 ht[0] 則不再進行任何添加操作: 這一措施保證了 ht[0] 包含的鍵值對數量會只減不增,並隨着 rehash 操作的執行而最終變成空表。

5. 跳躍表

5.1 跳躍表的實現

圖 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 是節點所保存的成員對象。

注意表頭節點和其他節點的構造是一樣的: 表頭節點也有后退指針、分值和成員對象,不過表頭節點的這些屬性都不會被用到,所以圖中省略了這些部分,只顯示了表頭節點的各個層。

跳躍表節點

跳躍表節點的實現由 redis.h/zskiplistNode 結構定義:

typedef struct zskiplistNode {

    // 后退指針
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成員對象
    robj *obj;

    // 層
    struct zskiplistLevel {

        // 前進指針
        struct zskiplistNode *forward;

        // 跨度
        unsigned int span;

    } level[];

} zskiplistNode;

1.層
跳躍表節點的 level 數組可以包含多個元素,每個元素都包含一個指向其他節點的指針,程序可以通過這些層來加快訪問其他節點的速度,一般來說,層的數量越多,訪問其他節點的速度就越快。

每次創建一個新跳躍表節點的時候,程序都根據冪次定律 (power law,越大的數出現的概率越小) 隨機生成一個介於 1 和 32 之間的值作為 level 數組的大小,這個大小就是層的“高度”。

2.前進指針
每個層都有一個指向表尾方向的前進指針(level[i].forward 屬性),用於從表頭向表尾方向訪問節點。

3.跨度
層的跨度(level[i].span 屬性)用於記錄兩個節點之間的距離:

  • 兩個節點之間的跨度越大,它們相距得就越遠。
  • 指向 NULL 的所有前進指針的跨度都為 0,因為它們沒有連向任何節點。
    初看上去,很容易以為跨度和遍歷操作有關,但實際上並不是這樣 —— 遍歷操作只使用前進指針就可以完成了,跨度實際上是用來計算排位(rank)的: 在查找某個節點的過程中,將沿途訪問過的所有層的跨度累計起來,得到的結果就是目標節點在跳躍表中的排位。

4.后退指針
節點的后退指針(backward 屬性)用於從表尾向表頭方向訪問節點: 跟可以一次跳過多個節點的前進指針不同,因為每個節點只有一個后退指針,所以每次只能后退至前一個節點。

5.分值和成員
節點的分值(score 屬性)是一個 double 類型的浮點數,跳躍表中的所有節點都按分值從小到大來排序。

節點的成員對象(obj 屬性)是一個指針,它指向一個字符串對象,而字符串對象則保存着一個 SDS 值。

在同一個跳躍表中,各個節點保存的成員對象必須是唯一的,但是多個節點保存的分值卻可以是相同的: 分值相同的節點將按照成員對象在字典序中的大小來進行排序,成員對象較小的節點會排在前面(靠近表頭的方向),而成員對象較大的節點則會排在后面(靠近表尾的方向)。

跳躍表

通過使用一個 zskiplist 結構來持有這些節點,程序可以更方便地對整個跳躍表進行處理,比如快速訪問跳躍表的表頭節點和表尾節點,又或者快速地獲取跳躍表節點的數量(也即是跳躍表的長度)等信息。

zskiplist 結構的定義如下:

typedef struct zskiplist {

    // 表頭節點和表尾節點
    struct zskiplistNode *header, *tail;

    // 表中節點的數量
    unsigned long length;

    // 表中層數最大的節點的層數
    int level;

} zskiplist;

header 和 tail 指針分別指向跳躍表的表頭和表尾節點,通過這兩個指針,程序定位表頭節點和表尾節點的復雜度為 O(1)。

通過使用 length 屬性來記錄節點的數量,程序可以在 O(1) 復雜度內返回跳躍表的長度。

level 屬性則用於在 O(1) 復雜度內獲取跳躍表中層高最大的那個節點的層數量,注意表頭節點的層高並不計算在內。

6. 整數集合

6.1 整數集合的實現

整數集合(intset)是 Redis 用於保存整數值的集合抽象數據結構,它可以保存類型為 int16_t 、 int32_t 或者 int64_t 的整數值,並且保證集合中不會出現重復元素。

每個 intset.h/intset 結構表示一個整數集合:

typedef struct intset {

    // 編碼方式
    uint32_t encoding;

    // 集合包含的元素數量
    uint32_t length;

    // 保存元素的數組
    int8_t contents[];

} intset;

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 )。

6.2 升級

每當我們要將一個新元素添加到整數集合里面,並且新元素的類型比整數集合現有所有元素的類型都要長時,整數集合需要先進行升級(upgrade),然后才能將新元素添加到整數集合里面。

升級整數集合並添加新元素共分為三步進行:

  1. 根據新元素的類型,擴展整數集合底層數組的空間大小,並為新元素分配空間。
  2. 將底層數組現有的所有元素都轉換成與新元素相同的類型,並將類型轉換后的元素放置到正確的位上,而且在放置元素的過程中,需要繼續維持底層數組的有序性質不變。
  3. 將新元素添加到底層數組里面。

舉個例子,假設現在有一個 INTSET_ENC_INT16 編碼的整數集合,集合中包含三個 int16_t 類型的元素,如圖 6-3 所示。

因為每次向整數集合添加新元素都可能會引起升級,而每次升級都需要對底層數組中已有的所有元素進行類型轉換,所以向整數集合添加新元素的時間復雜度為 O(N)。

6.3 升級的好處

整數集合的升級策略有兩個好處,一個是提升整數集合的靈活性,另一個是盡可能地節約內存。

提升靈活性

因為 C 語言是靜態類型語言,為了避免類型錯誤,我們通常不會將兩種不同類型的值放在同一個數據結構里面。

比如說,我們一般只使用 int16_t 類型的數組來保存 int16_t 類型的值,只使用 int32_t 類型的數組來保存 int32_t 類型的值,諸如此類。

但是,因為整數集合可以通過自動升級底層數組來適應新元素,所以我們可以隨意地將 int16_t 、 int32_t 或者 int64_t 類型的整數添加到集合中,而不必擔心出現類型錯誤,這種做法非常靈活。

節約內存

當然,要讓一個數組可以同時保存 int16_t 、 int32_t 、 int64_t 三種類型的值,最簡單的做法就是直接使用 int64_t 類型的數組作為整數集合的底層實現。 不過這樣一來,即使添加到整數集合里面的都是 int16_t 類型或者 int32_t 類型的值,數組都需要使用 int64_t 類型的空間去保存它們,從而出現浪費內存的情況。

而整數集合現在的做法既可以讓集合能同時保存三種不同類型的值,又可以確保升級操作只會在有需要的時候進行,這可以盡量節省內存。

比如說,如果我們一直只向整數集合添加 int16_t 類型的值,那么整數集合的底層實現就會一直是 int16_t 類型的數組,只有在我們要將 int32_t 類型或者 int64_t 類型的值添加到集合時,程序才會對數組進行升級。

6.4 降級

整數集合不支持降級操作,一旦對數組進行了升級,編碼就會一直保持升級后的狀態。

7. 壓縮列表

7.2 壓縮列表的構成

壓縮列表是 Redis 為了節約內存而開發的,由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。

一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或者一個整數值。

圖 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 ),用於標記壓縮列表的末端。

7.2 壓縮列表節點的構成

每個壓縮列表節點可以保存一個字節數組或者一個整數值,其中,字節數組可以是以下三種長度的其中一種:

  1. 長度小於等於 63 (2^{6}-1)字節的字節數組;
  2. 長度小於等於 16383 (2^{14}-1) 字節的字節數組;
  3. 長度小於等於 4294967295 (2^{32}-1)字節的字節數組;

而整數值則可以是以下六種長度的其中一種:

  1. 4 位長,介於 0 至 12 之間的無符號整數;
  2. 1 字節長的有符號整數;
  3. 3 字節長的有符號整數;
  4. int16_t 類型整數;
  5. int32_t 類型整數;
  6. int64_t 類型整數。

每個壓縮列表節點都由 previous_entry_length 、 encoding 、 content 三個部分組成,如圖 7-4 所示。

previous_entry_length

節點的 previous_entry_length 屬性以字節為單位,記錄了壓縮列表中前一個節點的長度。
previous_entry_length 屬性的長度可以是 1 字節或者 5 字節:

  • 如果前一節點的長度小於 254 字節,那么 previous_entry_length 屬性的長度為 1 字節: 前一節點的長度就保存在這一個字節里面。
  • 如果前一節點的長度大於等於 254 字節,那么 previous_entry_length 屬性的長度為 5 字節: 其中屬性的第一字節會被設置為 0xFE (十進制值 254),而之后的四個字節則用於保存前一節點的長度。

encoding

節點的 encoding 屬性記錄了節點的 content 屬性所保存數據的類型以及長度:

  • 一字節、兩字節或者五字節長,值的最高位為 00 、 01 或者 10 的是字節數組編碼: 這種編碼表示節點的 content 屬性保存着字節數組,數組的長度由編碼除去最高兩位之后的其他位記錄;
  • 一字節長,值的最高位以 11 開頭的是整數編碼: 這種編碼表示節點的 content 屬性保存着整數值,整數值的類型和長度由編碼除去最高兩位之后的其他位記錄;

表 7-2 字節數組編碼

編碼 編碼長度 content屬性保存的值
00xxxxxx 1 字節 長度小於等於 63 字節的字節數組。
01xxxxxx 2 字節 長度小於等於 16383 字節的字節數組。
10xxxxxx 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 屬性負責保存節點的值,節點值可以是一個字節數組或者整數,值的類型和長度由節點的 encoding 屬性決定。

8. 對象

Redis 並沒有直接使用這些數據結構來實現鍵值對數據庫,而是基於這些數據結構創建了一個對象系統,這個系統包含字符串對象、列表對象、哈希對象、集合對象和有序集合對象這五種類型的對象,每種對象都用到了至少一種我們前面所介紹的數據結構。

通過這五種不同類型的對象,Redis 可以在執行命令之前,根據對象的類型來判斷一個對象是否可以執行給定的命令。 使用對象的另一個好處是,我們可以針對不同的使用場景,為對象設置多種不同的數據結構實現,從而優化對象在不同場景下的使用效率。

除此之外,Redis 的對象系統還實現了基於引用計數技術的內存回收機制: 當程序不再使用某個對象的時候,這個對象所占用的內存就會被自動釋放; 另外,Redis 還通過引用計數技術實現了對象共享機制,這一機制可以在適當的條件下,通過讓多個數據庫鍵共享同一個對象來節約內存。

最后,Redis 的對象帶有訪問時間記錄信息,該信息可以用於計算數據庫鍵的空轉時長,在服務器啟用了 maxmemory 功能的情況下,空轉時長較大的那些鍵可能會優先被服務器刪除。

8.1 對象的類型與編碼

Redis 使用對象來表示數據庫中的鍵和值, 每次當我們在 Redis 的數據庫中新創建一個鍵值對時, 我們至少會創建兩個對象, 一個對象用作鍵值對的鍵(鍵對象), 另一個對象用作鍵值對的值(值對象)。

類型

對象 type 屬性值 TYPE 命令的輸出
字符串對象 REDIS_STRING "string"
列表讀寫 REDIS_LIST "list"
哈希對象 REDIS_HASH "hash"
集合對象 REDIS_SET "set"
有序集合對象 REDIS_ZSET "zset"

當我們對一個數據庫鍵執行 TYPE 命令時, 命令返回的結果為數據庫鍵對應的值對象的類型, 而不是鍵對象的類型:

# 鍵為字符串對象,值為字符串對象
redis> SET msg "hello world"
OK
redis> TYPE msg
string

# 鍵為字符串對象,值為列表對象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TYPE numbers
list

# 鍵為字符串對象,值為哈希對象
redis> HMSET profile name Tome age 25 career Programmer
OK
redis> TYPE profile
hash

# 鍵為字符串對象,值為集合對象
redis> SADD fruits apple banana cherry
(integer) 3
redis> TYPE fruits
set

# 鍵為字符串對象,值為有序集合對象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
redis> TYPE price
zset

編碼

數據結構 編碼常量 OBJECT ENCODING 命令輸出
整數 REDIS_ENCODING_INT "int"
embstr SDS REDIS_ENCODING_EMBSTR "embstr"
raw SDS REDIS_ENCODING_RAW "raw"
字典 REDIS_ENCODING_HT "hashtable"
雙端鏈表 REDIS_ENCODING_LINKEDLIST "linkedlist"
壓縮列表 REDIS_ENCODING_ZIPLIST "ziplist"
整數集合 REDIS_ENCODING_INTSET "intset"
跳躍表和字典 REDIS_ENCODING_SKIPLIST "skiplist"

使用 OBJECT ENCODING 命令可以查看一個數據庫鍵的值對象的編碼。

redis> SET msg "hello wrold"
OK
redis> OBJECT ENCODING msg
"embstr"

redis> SET story "long long long long long long ago ..."
OK
redis> OBJECT ENCODING story
"raw"

redis> SADD numbers 1 3 5
(integer) 3
redis> OBJECT ENCODING numbers
"intset"

redis> SADD numbers "seven"
(integer) 1
redis> OBJECT ENCODING numbers
"hashtable"

通過 encoding 屬性來設定對象所使用的編碼,而不是為特定類型的對象關聯一種固定的編碼,極大地提升了 Redis 的靈活性和效率,因為 Redis 可以根據不同的使用場景來為一個對象設置不同的編碼,從而優化對象在某一場景下的效率。

8.2 字符串對象

字符串對象的編碼可以是 int 、 raw 或者 embstr。

如果一個字符串對象保存的是整數值,並且這個整數值可以用 long 類型來表示,那么字符串對象會將整數值保存在字符串對象結構的 ptr 屬性里面(將 void* 轉換成 long ),並將字符串對象的編碼設置為 int。

如果字符串對象保存的是一個字符串值,並且這個字符串值的長度大於 32 字節,那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串值,並將對象的編碼設置為 raw。

如果字符串對象保存的是一個字符串值,並且這個字符串值的長度小於等於 32字節,那么字符串對象將使用 embstr 編碼的方式來保存這個字符串值。

編碼轉換

int 編碼的字符串對象和 embstr 編碼的字符串對象在條件滿足的情況下。會被轉換為 raw 編碼的字符串對象。

(1)對於 int 編碼的字符串對象來說,如果我們向對象執行了一些命令,使得這個對象保存的不再是整數值,而是一個字符串值,那么字符串對象的編碼將從 int 變為 raw。

(2)另外,因為 Redis 沒有為 embstr 編碼的字符串對象編寫任何相應的修改程序 (只有 int 編碼的字符串對象和 raw 編碼的字符串對象有這些程序),所以 embstr 編碼的字符串對象實際上是只讀的: 當我們對 embstr 編碼的字符串對象執行任何修改命令時,程序會先將對象的編碼從 embstr 轉換成 raw,然后再執行修改命令; 因為這個原因,embstr 編碼的字符串對象在執行修改命令之后,總會變成一個 raw 編碼的字符串對象。

8.3 列表對象

列表對象的編碼可以是 ziplist 或者 linkedlist。

ziplist 編碼的列表對象使用壓縮列表作為底層實現, 每個壓縮列表節點(entry)保存了一個列表元素。

linkedlist 編碼的列表對象使用雙端鏈表作為底層實現, 每個雙端鏈表節點(node)都保存了一個字符串對象, 而每個字符串對象都保存了一個列表元素。

redis> RPUSH numbers 1 "three" 5
(integer) 3

編碼轉換

當列表對象可以同時滿足以下兩個條件時, 列表對象使用 ziplist 編碼:

  1. 列表對象保存的所有字符串元素的長度都小於 64 字節;
  2. 列表對象保存的元素數量小於 512 個;

不能滿足這兩個條件的列表對象需要使用 linkedlist 編碼。

8.4 哈希對象

哈希對象的編碼可以是 ziplist 或者 hashtable。

ziplist 編碼的哈希對象使用壓縮列表作為底層實現, 每當有新的鍵值對要加入到哈希對象時, 程序會先將保存了鍵的壓縮列表節點推入到壓縮列表表尾, 然后再將保存了值的壓縮列表節點推入到壓縮列表表尾, 因此:

  1. 保存了同一鍵值對的兩個節點總是緊挨在一起, 保存鍵的節點在前, 保存值的節點在后;
  2. 先添加到哈希對象中的鍵值對會被放在壓縮列表的表頭方向, 而后來添加到哈希對象中的鍵值對會被放在壓縮列表的表尾方向。
redis> HSET profile name "Tom"
(integer) 1

redis> HSET profile age 25
(integer) 1

redis> HSET profile career "Programmer"
(integer) 1

另一方面, hashtable 編碼的哈希對象使用字典作為底層實現, 哈希對象中的每個鍵值對都使用一個字典鍵值對來保存:

  1. 字典的每個鍵都是一個字符串對象, 對象中保存了鍵值對的鍵;
  2. 字典的每個值都是一個字符串對象, 對象中保存了鍵值對的值。

編碼轉換

當哈希對象可以同時滿足以下兩個條件時, 哈希對象使用 ziplist 編碼:

  1. 哈希對象保存的所有鍵值對的鍵和值的字符串長度都小於 64 字節;
  2. 哈希對象保存的鍵值對數量小於 512 個;

不能滿足這兩個條件的哈希對象需要使用 hashtable 編碼。

8.5 集合對象

集合對象的編碼可以是 intset 或者 hashtable。

intset 編碼的集合對象使用整數集合作為底層實現, 集合對象包含的所有元素都被保存在整數集合里面。

舉個例子, 以下代碼將創建一個如圖 8-12 所示的 intset 編碼集合對象:

redis> SADD numbers 1 3 5
(integer) 3

hashtable 編碼的集合對象使用字典作為底層實現, 字典的每個鍵都是一個字符串對象, 每個字符串對象包含了一個集合元素, 而字典的值則全部被設置為 NULL。

舉個例子, 以下代碼將創建一個如圖 8-13 所示的 hashtable 編碼集合對象:

redis> SADD fruits "apple" "banana" "cherry"
(integer) 3

編碼轉換

當集合對象可以同時滿足以下兩個條件時, 對象使用 intset 編碼:

  1. 集合對象保存的所有元素都是整數值;
  2. 集合對象保存的元素數量不超過 512 個;

不能滿足這兩個條件的集合對象需要使用 hashtable 編碼。

8.6 有序集合對象

有序集合的編碼可以是 ziplist 或者 skiplist。

ziplist 編碼的有序集合對象使用壓縮列表作為底層實現, 每個集合元素使用兩個緊挨在一起的壓縮列表節點來保存, 第一個節點保存元素的成員(member), 而第二個元素則保存元素的分值(score)。

壓縮列表內的集合元素按分值從小到大進行排序, 分值較小的元素被放置在靠近表頭的方向, 而分值較大的元素則被放置在靠近表尾的方向。

舉個例子, 如果我們執行以下 ZADD 命令, 那么服務器將創建一個有序集合對象作為 price 鍵的值:

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

如果 price 鍵的值對象使用的是 ziplist 編碼, 那么這個值對象將會是圖 8-14 所示的樣子, 而對象所使用的壓縮列表則會是 8-15 所示的樣子。

skiplist 編碼的有序集合對象使用 zset 結構作為底層實現, 一個 zset 結構同時包含一個字典和一個跳躍表:

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset 結構中的 zsl 跳躍表按分值從小到大保存了所有集合元素, 每個跳躍表節點都保存了一個集合元素: 跳躍表節點的 object 屬性保存了元素的成員, 而跳躍表節點的 score 屬性則保存了元素的分值。 通過這個跳躍表, 程序可以對有序集合進行范圍型操作, 比如 ZRANK 、 ZRANGE 等命令就是基於跳躍表 API 來實現的。

除此之外, zset 結構中的 dict 字典為有序集合創建了一個從成員到分值的映射, 字典中的每個鍵值對都保存了一個集合元素: 字典的鍵保存了元素的成員, 而字典的值則保存了元素的分值。 通過這個字典, 程序可以用 O(1) 復雜度查找給定成員的分值, ZSCORE 命令就是根據這一特性實現的, 而很多其他有序集合命令都在實現的內部用到了這一特性。

有序集合每個元素的成員都是一個字符串對象, 而每個元素的分值都是一個 double 類型的浮點數。 值得一提的是, 雖然 zset 結構同時使用跳躍表和字典來保存有序集合元素, 但這兩種數據結構都會通過指針來共享相同元素的成員和分值, 所以同時使用跳躍表和字典來保存集合元素不會產生任何重復成員或者分值, 也不會因此而浪費額外的內存。

舉個例子, 如果前面 price 鍵創建的不是 ziplist 編碼的有序集合對象, 而是 skiplist 編碼的有序集合對象, 那么這個有序集合對象將會是圖 8-16 所示的樣子, 而對象所使用的 zset 結構將會是圖 8-17 所示的樣子。

編碼轉換

當有序集合對象可以同時滿足以下兩個條件時, 對象使用 ziplist 編碼:

  • 有序集合保存的元素數量小於 128 個;
  • 有序集合保存的所有元素成員的長度都小於 64 字節;

不能滿足以上兩個條件的有序集合對象將使用 skiplist 編碼。

 

二、單機數據庫的實現

1. 數據庫

2. RDB持久化

3. AOF持久化

4. 事件

5. 客戶端

6. 服務端

 

三、多機數據庫的實現

1. 復制

2. Sentinel

3. 集群

 

四、獨立功能的實現

1. 發布與訂閱

2. 事務

3. Lua腳本

4. 排序

5. 二進制位數組

6. 慢查詢日志

7. 監視器


免責聲明!

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



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