1. 程式人生 > >Redis原理

Redis原理

sig 同時 strong clas ref int 完整 最終 空白

目錄

  • 1. 數據結構與對象
    • 1.1 SDS
      • 1.1.1 數據結構
      • 1.1.2 優勢
      • 1.1.3 內存分配策略
    • 1.2 鏈表的實現
    • 1.3 字典
      • 1.3.1 數據結構
      • 1.3.2 哈希算法
      • 1.3.3 rehash操作
      • 1.3.4 漸進式rehash
    • 1.4 跳躍表
    • 1.5 整數集合
    • 1.6 壓縮列表
  • 2 單機數據庫
    • 2.1 鍵的過期
      • 2.1.1 過期刪除策略
      • 2.1.2 過期鍵處理:AOF、RDB、復制
    • 2.2 RDB持久化
    • 2.3 AOF持久化
      • 2.3.1 AOF文件載入與還原
      • 2.3.2 AOF重寫
  • 3 復制
    • 3.1 舊版本復制
      • 3.1.1 同步
      • 3.1.2 命令傳播
      • 3.1.3 缺點
    • 3.2 新版本復制
      • 3.2.1 復制偏移量
      • 3.2.2 復制積壓緩沖區
      • 3.2.3 服務器運行ID
  • 4 集群
    • 4.1 結構
    • 4.2 槽指派
      • 4.2.1 記錄節點的槽指派信息
      • 4.2.2 傳播節點的槽指派信息
      • 4.2.3 槽信息
      • 4.2.4 重新分片
    • 4.3 復制與故障轉移
      • 4.3.1 故障檢測
      • 4.3.2 故障轉移
      • 4.3.3 選舉master

1. 數據結構與對象

1.1 SDS

Redis自己構建了一種名為Simple Dynamic String(SDS,簡單動態字符串)的字符串抽象類型,作為Redis的默認字符串表示。在Redis的數據庫裏,包含字符串的鍵值對在底層都是由SDS實現的。如下:

redis> RPUSH fruits "apple" "banana" "cherry"
  • 其中鍵是字符串對象,對象的底層實現是一個保存了"fruits"的SDS。
  • 其中只是一個列表對象,列表對象包含3個字符串對象,這3個字符串對象分別由3個SDS實現。

除了用作保存數據庫中的字符串值外,SDS還被用作緩沖區:①AOF模塊中的AOF緩沖區、②客戶端狀態中的輸入緩沖區。

1.1.1 數據結構

其基本數據結構如下所示:

struct sdshdr {
    int len;    // 記錄buf數組中已經使用的字節的數量,等於SDS所保存字符串的長度
    int free;   // 記錄buf數組中未使用字節的數量
    char buf[]; // 字節數組,用於保存字符串。註意最後一個字符為:'\0',不記錄在len的長度裏。即len + free + 1 = 數組buf的長度
}

1.1.2 優勢

C字符串使用長度為N+1的字符數組表示長度為N的字符串(字符數組最後一個元素為"\0")。這種簡單的方式,不能滿足Redis對字符串在安全性、效率、功能等方面的要求。而SDS具有如下優點:

  • 獲取字符串長度

    C字符串不記錄自身長度,所以獲取字符串長度必須遍歷整個字符串,復雜度O(N)。SDS的len記錄SDS本身長度,復雜度O(1)。獲取字符串長度不會成為Redis的性能瓶頸。如,即使對一個非常長的字符串鍵反復執行STRLEN命令,也不會對系統造成任何影響(其復雜度僅為O(1))。

  • 杜絕緩沖區溢出

    C字符串不記錄自身長度的另一個問題是容易造成緩沖區溢出。如將字符串src拼接到dest字符串末尾的strcat函數,strcat會假定用戶在執行此函數時,已經為dest分配了足夠多的內存,可以容納src字符串中的所有內容,一旦此假設不成立,就會產生緩存區溢出。
    SDS API則會先檢查SDS空間是否滿足修改需求,不滿足,API會自動將SDS的空間擴展至執行修改所需的大小。

  • 減少字符串修改帶來的內存重分配次數

    C字符串長度和底層數組長度存在關聯性,因此每次增長或縮短一個C字符串,程序總會對保存這個C字符串的數組進行一次內存重分配操作:
    1. 增長字符串。執行前需要先通過內存重分配來擴展底層數組的空間大小,否則就會緩沖區溢出
    2. 縮短字符串。執行前需要先通過內存重分配來釋放字符串不再使用的那部分空間,否則就會產生內存泄漏
  • 二進制數據安全

    C字符串中的字符必須符合某種編碼(如ASCII),且除字符串末尾外,字符串裏面不能包含空字符,否則最先被程序讀入的空字符將會被誤認為時字符串結尾。這些限制使得C字符串只能保存文本數據,而不能保存圖片、音頻、壓縮文件等二進制數據。

    為確保Redis可以適用於各種場景,SDS API會以處理二進制的方式來處理SDS存放在buf數組裏的數據,程序不會對其中的數據做任何操作。這也是我們將SDS的buf屬性稱為字節數組的原因:Redis不是用這個數組保存字符,而是保存一系列二進制數據。SDS使用len屬性的值而非空字符來判斷字符串是否結束。

1.1.3 內存分配策略

內存重分配涉及復雜算法,並可能需要執行系統調用,因此通常為一個耗時操作。SDS通過free字段(未使用空間)解除了字符串長度和底層數組長度之間的關聯關系。通過free字段,SDS實現了空間預分配和惰性空間釋放兩種優化策略:

  • 空間預分配策略

    SDS API對SDS進行空間擴展時,不僅會分配修改所必須的空間,還會為SDS分配額外的未使用空間。額外分配的未使用空間數量的公式如下:
    1. 若修改後,SDS的len<1MB,程序分配和len屬性同樣大小的未使用空間,即len=free。如修改後len=13B,那麽free=13B,buf的實際長度27B。
    2. 若修改後,SDS的len>1MB,程序分配1MB的未使用空間。如修改後len=30MB,那麽free=1MB,buf的實際長度:30MB+1MB+1B
    通過空間預分配策略,Redis可以減少連續執行字符串增長操作所需的內存重分配次數。註意,擴展SDS空間前,會先判斷未使用空間是否足夠,若足夠,API會直接使用未使用空間,而無需執行內存重分配。
  • 惰性空間釋放

    當需要縮短SDS字符串時,程序不會立即使用內存重分配來回收縮短後多余的字節,而是使用free字段將這些字節數量記錄下來,並等待將來回收(若將來對SDS又進行增長操作,這些未使用空間也可能會派上用場)。

1.2 鏈表的實現

C語言中沒有鏈表結構,因此Redis構建了自己的鏈表,其中用到鏈表的實現由:列表(LRANGE)的底層實現、發布與訂閱、慢查詢、監視器、客戶端狀態信息、客戶端輸出緩沖區。Redis的鏈表特性:

  • 雙端
  • 無環
  • 帶表頭指針(head)和表尾指針(tail)
  • 帶鏈表長度計數器
  • 多態(可存儲各種不同類型的值)

鏈表的數據結構如下所示:

// 鏈表節點
typedef struct listNode {
    struct listNode *prev;  // 前置節點
    struct listNode *next;  // 後置節點
    void *vlaue;            // 節點值
}
// 雖然僅僅使用listNode就可以組成鏈表,但Redis使用list結構使得操作起來更加方便:
typedef struct list {
    struct listNode *head;  // 表頭節點
    struct listNode *tail;  // 表尾節點
    unsigned long len;      // 鏈表鎖包含的節點數量
    // 節點復制函數、節點釋放函數、節點值對比函數,省略
} list;

1.3 字典

C語言並沒有內置Map數據結構,Redis構建了自己的實現。Redis的數據庫就是使用字典來作為底層實現的,對數據庫的CURD操作也是構建在對字典的操作上。

redis> SET msg "hello world"

"msg"-"hello world"鍵值對就保存在代表數據庫的字典裏。除了表示數據庫外,字典還是哈希鍵(HSET)的底層實現之一,當一個哈希鍵包含的鍵值對比較多時,或鍵值對中的元素都是比較長的字符串時,Redis就會使用字典作為哈希鍵的底層實現。如下:

redis> HLEN website     ##website包含10086個鍵值對
redis> HGETALL website  ##website鍵底層實現是一個字典,字典包含10086個鍵值對

1.3.1 數據結構

Redis的字典使用哈希表作為底層實現,一個哈希表可以有多個哈希表節點,每個哈希表節點保存字典中的一個鍵值對。Redis的字典結構如下:

// 哈希表節點
typedef struct dictEntry {
    void *key;   // 鍵
    union {      // 值
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    struct dictEntry *next;  // 指向下一個哈希表節點,形成鏈表。堅決hash碰撞的鍵
} dictEntry;
// 哈希表
typedef struct dictht {
    dictEntry **table;  // 哈希表數組
    unsigned long size; // 哈希表大小
    unsigned long sizemask; // 哈希表大小掩碼,用於計算索引值,總是等於size -1;
    unsigned long used; // 該哈希表已有節點的數量
} dictht;
// 字典
typedef struct dict { 
    dictType *type;  // 類型特定函數。保存了用於操作特定類型鍵值對的函數,Redis會為用途不同的字典設置不同的類型特定的函數
    void *privdata;  // 私有數據
    dictht ht[2];    // 哈希表。是一個包含2個項的數組,每個項都是一個dictht哈希表,一般,字典只使用ht[0]哈希表,ht[1]哈希表只會在ht[0]哈希表進行rehash時使用;
    int trehashidx;  // rehash索引。當rehash不在進行時,該值為-1
}

1.3.2 哈希算法

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

hash = dict->type->hashFunction(key);   // 計算哈希值
index = hash & dict->ht[x].sizemask;    // 計算索引,ht[x]可以為ht[0]或ht[1]

哈希沖突

當有2個或以上數量的鍵被分配到了哈希表數組的同一個索引上面時,Redis的哈希表使用鏈式地址法來解決鍵沖突(next指針,單向鏈表,由於沒有指向鏈表表尾的指針,所以為速度考慮,程序總是將新節點添加到鏈表的表頭位置)。

其中鏈式的結構使用的是dictEntry->next指針。

1.3.3 rehash操作

哈希表的負載因子計算:

load_factor = ht[0].used / ht[0].size

隨著操作不斷執行,哈希表的鍵值對增加或減少,為了讓哈希表負載因子維持在一個合理範圍內,需要對哈希表進行相應的擴容或收縮操作,rehash的步驟如下:

  • 字典的ht[1]哈希表分配空間,此哈希表空間大小取決於要執行的操作,以及ht[0]當前包含的鍵值對數量(即ht[0].used屬性的值)

    擴展操作:ht[1]的大小 == 第一個 >= ht[0].used*2的2^n。
    收縮操作:ht[1]的大小 == 第一個 >= ht[0].used的2^n。

  • 將保存在ht[0]中的所有鍵值對rehash到ht[1]上:重新計算鍵的索引值,然後將鍵值對放置到ht[1]哈希表的指定位置上。
  • 當ht[0]包含的所有鍵值對都遷移到了ht[1]後,釋放ht[0],將ht[1]設置為ht[0],並在ht[1]新創建一個空白哈希表,以備下次rehash。

自動擴展

根據BGSAVE或BGREWRITEAOF命令是否在執行,服務器執行擴展操作所需負載因子並不相同:

  • 服務器目前沒有執行BGSAVE或BGREWRITEAOF,且哈希表的負載因子>=1
  • 服務器目前正在執行BGSAVE或BGREWRITEAOF,且哈希表的負載因子>=5

原因:執行這兩個命令的過程中,Redis需要創建當前服務器進程的子進程,而大多數OS都采用copy-on-write技術來優化子進程的使用效率,所以在子進程存在期間,服務器會提高執行擴展操作所需的負載因子,從而盡可能地避免在子進程存在期間進行哈希表擴展操作,如此可以避免不必要的內存寫入,最大限度節約內存。

自動收縮

負載因子小於0.1時,自動執行。

1.3.4 漸進式rehash

擴展或收縮需要將ht[0]的所有鍵值對rehash到ht[1]裏,但這個rehash並非一次性、集中式完成,而是分多次、漸進式完成。原因在於,若哈希表中保存鍵值對數量過大(如400w),那麽一次性rehash全部鍵值對,可能會導致服務器在一段時間內停止服務。步驟如下:

  1. 為ht[1]分配空間,字典同時持有ht[0]和ht[1]兩個哈希表
  2. 字典維持一個索引計數器變量rehashidx,將其設置為0,表示rehash正式開始。
  3. 在rehash期間,任何對字典執行CRUD操作,程序除了執行指定操作外,都會順帶將ht[0]哈希表在rehashidx索引上的所有鍵值對rehash到ht[1],每次rehash工作完成,程序都會將rehashidx屬性值+1
  4. 隨著字典操作不斷執行,最終某個時間點,ht[0]的所有鍵值對都會被rehash到ht[1],此時程序將rehashidx設為-1,表示rehash操作完成

在rehash執行期間,字典會同時使用ht[0]和ht[1]兩個哈希表,所以在漸進式rehash過程中,字典的刪除、查找、更新等操作都會在2個哈希表上進行

1.4 跳躍表

跳躍表是一種有序的數據結構,通過在每個節點上維持多個指向其他節點的指針,從而達到快速訪問的目的。跳躍表支持平均O(logN)、最壞O(N)復雜度的節點查找。跳躍表在大部分情況下效率可媲美平衡樹(跳躍表實現更為簡單)。Redis使用跳躍表作為有序集合鍵的底層實現之一:

  1. 若有序集合的元素數量較多。
  2. 或有序集合中元素的member是比較長的字符串時,Redis就會使用跳躍表作為有序集合的底層實現。

跳表的數據結構如下:

// 節點
typedef struct zskiplistNode {
    // 層
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前進指針
        unsigned int span;  // 跨度
    } level[];
    struct zskiplistNode *backward;  // 後退指針
    double score; // 分值
    robj *obj;  // 成員對象
} zskiplistNode;
// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, tail; 
    unsigned long length; // 表中節點的數量
    int level;  // 表中層數最大的節點的層數,表頭結點的層數不計算在內
}

跳躍表節點的Level數組可以包含多個元素,每個元素都包含一個指向其他節點的指針,程序可以通過這些層來加快訪問其他節點的速度。一般來說,層數越多,訪問其他節點速度越快。在每次創建跳表節點時,程序都根據冪次定律隨機生成一個介於1~32之間的值,作為Level數組的大小,這個大小就是層的"高度"。

跨度

層的跨度用於記錄2個節點之間的距離(指向NULL的所有跨度均為0),跨度越大相距越遠。跨度和遍歷無關,遍歷只使用前進指針即可完成,跨度實際用於計算排位:在查找某個節點的過程中,將沿途訪問過的所有節點的跨度累計,即為目標節點在跳躍表中的排位。

後退指針

節點的後退指針用於從表尾向表頭訪問,跟前進指針不同,每個節點只有一個後退指針,指向前一個節點。

分值和成員

分值是跳躍表中的所有節點按照分值從小到大排序。成員對象是一個指針,指向一個SDS字符串結構。

1.5 整數集合

整數集合是集合鍵的底層實現之一:當集合只包含整數值元素,且集合元素數量不多。如下:

redis> SADD number 1 3 5 7 9
// 集合結構
typedef struct intset {
    uint32_t encoding;  // 編碼方式
    uint32_t length;    // 集合包含的元素數量,即數組長度
    int8_t contents[];  // 保存元素的數組,按值得大小從下到大有序的排列,且不包含任何重復項
} intset;

其中:countents數組並不保存int8_t類型的值,數組真正類型取決於encoding屬性的值:

  • encoding=INTSET_ENC_INT16:即int16_t
  • encoding=INTSET_ENC_INT32:即int32_t
  • encoding=INTSET_ENC_INT64:即int64_t

當contents數組中原本保存的值為int16_t類型,而之後新增數據為int64_t類型時,整數集合已有的所有元素都會被轉為int64_t類型。即整數集合的升級。升級的優點:

  • 提升靈活性,升級數組的方式不必擔心類型錯誤
  • 節約內存,避免直接使用int64_t類型的數組

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

1.6 壓縮列表

壓縮列表是Redis為節約內存而開發的。由一系列特殊編碼的連續內存塊組成的順序型數據結構。一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或整數值。壓縮列表的組成部分如下:

技術分享圖片

  • zlbytes:記錄整個壓縮列表占用的內存字節數:在對壓縮列表進行內存重分配,或計算zlend的位置時使用。
  • zltail:記錄壓縮列表的尾節點距離壓縮列表的起始地址有多少字節(通過zltail,程序可以無需遍歷,直接定位尾節點地址)
  • zllen:記錄壓縮列表包含的節點數量
  • zlend:特殊值0xFF,用於標記壓縮列表的末端。

壓縮列表是列表鍵和哈希鍵的底層實現之一。列表鍵只有少量列表項,且每個項要麽小整數值,要麽短字符串。如:

RPUSH lst 1 3 5 10086 "hello" "world"

2 單機數據庫

Redis是一個鍵值對數據庫服務器,服務器中的每個數據庫由redisDb結構表示,其中redisDb結構的dict字典保存了數據庫中的所有鍵值對,我們將這個字典稱為鍵空間

typedef struct redisDb {
    //...
    dict *dict;  // 數據庫鍵空間,保存數據庫中的所有鍵值對。
} redisDb;

2.1 鍵的過期

redisDb結構中的expires字典保存了數據庫中所有鍵的過期時間:

  • 過期字典鍵:一個指針,指向鍵空間中的某個鍵對象
  • 過期字典值:long類型的UNIX時間戳,精確到毫秒

2.1.1 過期刪除策略

Redis服務器實際使用惰性刪除定期刪除兩種策略,通過配合使用兩種策略,服務器可以合理使用CPU時間和避免浪費內存:

  • 惰性刪除:對CPU最友好,程序在取出鍵時才會對鍵進行過期檢查。缺點:對內存不友好,若鍵過期,而這個鍵仍然保留在數據庫中占用內存。若數據庫有許多過期鍵,而這些過期鍵又恰好未被訪問,那麽它們也許永遠都不會被刪除。
  • 定期刪除:每個一段時間執行一次刪除過期鍵操作,並通過限制刪除操作執行的時長、頻率來減少刪除操作對CPU時間的影響

惰性刪除策略
所有讀寫數據庫的Redis命令,在執行前都會調用expireIfNeeded函數對輸入鍵進行檢查:若鍵已過期,此函數將鍵從數據庫中刪除;若鍵未過期,此函數不做動作。

定期刪除策略
activeExpireCycle函數周期執行,在規定時間內,分多次遍歷服務器中各個數據庫,從數據庫的expires字典隨機檢查一部分鍵的過期時間,並刪除其中過期鍵。

2.1.2 過期鍵處理:AOF、RDB、復制


生成RDB文件
對數據庫中的鍵進行檢查,已過期的鍵不會保存到新創建的RDB文件中

載入RDB文
啟動Redis服務器時,若Redis開啟了RDB功能,則對RDB文件進行載入。

  1. master,載入RDB文件時,對文件中保存的key進行檢查,過期的key會被忽略。
  2. slave,載入RDB文件時,文件中保存的所有鍵無論是否過期,都會被載入到數據庫中。

AOF文件
當服務器以AOF持久化模式運行時,若數據庫中某個鍵已經過期:

  • 尚未被惰性刪除或定期刪除,那麽AOF文件不會因為這個過期鍵而產生任何影響;
  • 已經被惰性刪除或定期刪除,那麽程序會向AOF文件追加一條DEL命令,顯示地記錄該鍵被刪除。

在執行AOF重寫時,程序會對數據庫中的鍵進行檢查,已過期的鍵不會被保存到重寫後的AOF文件中


復制
當服務器運行在復制模式下,slave的過期鍵刪除動作由master控制:

  • master在刪除一個過期鍵後,會顯示地向所有slave發送一個DEL命令,告知slave刪除這個過期鍵
  • slave在執行客戶端發送的讀命令時,即使碰到過期鍵也不會將過期鍵刪除,而是以未過期鍵的方式進行處理
  • slave只有在接收到master發送的DEL命令後,才會刪除過期鍵

master控制slave來同一刪除過期鍵,可以保證主從服務器數據的一致性。舉例:msg鍵為過期鍵。

  • 若客戶端從slave獲取msg鍵的值,slave發現msg已經過期,但slave並不會刪除msg鍵,而是繼續將msg鍵的值返回給客戶端,就好像msg鍵並沒有過期一樣。
  • 若客戶端從master獲取msg鍵的值,master發現該鍵過期,會刪除msg鍵,向客戶端返回空,並向slave發送DEL msg命令

2.2 RDB持久化

Redis是內存數據庫,將數據庫狀態存儲在內存裏。Redis提供了RDB持久化功能,可以將Redis內存中的數據庫狀態保存到磁盤上,避免數據意外丟失。RDB持久化既可以手動執行,也可以根據配置選定定期執行,該功能可以將某個時間點上的數據庫狀態保存到一個RDB文件(壓縮的二進制文件)中。Redis服務器在啟動時會檢測RDB文件,若存在,則自動載入(因此沒有提供載入RDB文件的命令)。

Redis的SAVEBGSAVE命令用於生成RDB文件:

  • SAVE,阻塞服務器進程,直到RDB文件創建完畢,阻塞期間,服務器不能處理任何命令請求
  • BGSAVE,派生子進程,然後由子進程負責創建RDB文件,服務器進程繼續處理命令情趣。

註:由於AOF文件的更新頻率通常比RDB文件更新頻率高,因此若服務器開啟了AOF持久化功能,那麽服務器會優先使用AOF文件來還原數據庫狀態。

Redis允許用戶通過配置save選項,讓服務器每隔一段時間自動執行一次BGSAVE命令。SAVE選項可以設置多個保存條件,只要滿足任意一個條件,服務器就會執行BGSAVE命令,如下:

save 900 1
save 300 10
save 60 10000
即:服務器在900s內,對數據庫進行了至少1次修改;服務器在300s內,對數據庫進行了至少10次修改;服務器在60s內,對數據庫進行了至少10000次修改。

2.3 AOF持久化

AOF持久化的實現共3個步驟:

  • 命令追加。當AOF持久化功能開啟時,服務器在執行完寫命令後,會以協議格式將被執行的命令追加到aof_buf緩沖區的末尾
  • 文件的寫入和同步。Redis服務器進程就是一個事件循環:
  1. 文件事件,負責接收客戶端的命令請求,以及向客戶端發送命令回復
  2. 時間事件,負責執行像serverCron函數這樣需要定時運行的函數

因為服務器在處理文件事件時可能會執行寫命令,使一些內容被追加到aof_buf緩沖區裏,所以服務器在每次結束一個事件循環前,都會調用flushAppendOnlyFile函數考慮是否需要將aof_buf緩沖區的內容寫到AOF文件裏。flushAppendOnlyFile函數行為由服務器配置的appendsync選項值決定(默認為everysec)

  • always,將aof_buf緩沖區的所有內容寫入到AOF文件,並同步AOF文件。即每次收到寫命令就立即強制同步磁盤
  • everysec,將aof_buf緩沖區的所有內容寫入到AOF文件,若上次同步AOF文件時間距離當前使勁超過1s就對AOF文件進行同步。即每隔1s同步一次磁盤
  • no,將aof_buf緩沖區的所有內容寫入到AOF文件,但並不對AOF文件進行同步,何時同步由操作系統決定

2.3.1 AOF文件載入與還原

創建偽客戶端的原因:Redis命令只能在客戶端上下文執行,所以服務器使用一個沒有網絡連接的偽客戶端來執行來自AOF文件保存的寫命令。

技術分享圖片

2.3.2 AOF重寫

因為AOF持久化通過保存被執行的寫命令來記錄數據庫狀態的,所以AOF文件內容會越來越大,若不加以控制,AOF文件過大可能會對Redis服務器甚至宿主機造成影響。且AOF文件體積越大,數據還原時間也會越長。
為解決此問題,Redis提供了AOF文件重寫功能:Redis服務器可以創建一個新的AOF文件來替代現有的AOF文件,新AOF文件不會包含任何浪費空間的冗余命令。如下命令:

redis> RPUSH list "A" "B"
redis> RPUSH list "C"
redis> RPUSH list "D" "E"
redis> RPOP list
redis> RPOP list
redis> RPUSH list "F" "G"

此時AOF文件寫入6條命令,去除冗余其實可以以一條RPUSH命令代替AOF文件中的6條命令。AOF重寫會進行大量的寫操作,所以長時間阻塞,因此Redis服務器使用子進程來處理:

  • 子進程進行AOF重寫期間,服務器進程(父進程)可以繼續處理命令請求
  • 子進程帶有服務器進程的數據副本,使用子進程而不是線程,可以避免使用鎖的情況下,保證數據的安全性

問題描述: 在子進程進行AOF重寫期間,服務器進程還需要繼續處理命令請求,新的命令可能會對現有的數據庫狀態進行修改,從而使得服務器當前的數據庫狀態和重寫後的AOF文件所保存的數據庫狀態不一致。
解決方式: Redis服務器設置了一個AOF重寫緩沖區,此緩沖區在服務器創建子進程後開始使用,當Redis服務器執行完一個寫命令後,會同時將這個寫命令發送給AOF緩沖區和AOF重寫緩沖區。

技術分享圖片
如此可以保證:
①AOF緩沖區的內容會被定期寫入和同步到AOF文件,對現有AOF文件的處理工作如常進行。
②從創建子進程開始,服務器執行的所有命令都會被記錄到AOF重寫緩沖區裏。
當子進程完成AOF重寫後,向父進程發送信號,父進程會調用一個信號處理函數,執行如下工作:

  • 將AOF重寫緩沖區中的所有內容寫入到新AOF文件中,這時新AOF文件所保存的數據庫狀態將和服務器當前數據庫狀態一致
  • 對新AOF文件進行改名,原子地覆蓋現有AOF文件,完成新舊交替。
    此信號處理函數執行完畢後,父進程就可以繼續像往常一樣接收命令請求了。整個AOF後臺重寫過程,只有信號處理函數執行時會對服務器進程(父進程)造成阻塞,其他時間,AOF後臺進程都不會阻塞父進程。

3 復制

在Redis中,可以通過執行SLAVEOF命令或設置slaveof選項,讓一個服務器(slave)去復制另一個服務器(master)。Redis的復制功能分為2種操作:①同步;②命令傳播。

  • 同步操作:將slave的數據狀態更新至master當前所處的數據庫狀態
  • 命令傳播:在master的數據庫狀態被修改時,導致主從服務器狀態出現不一致時,讓主從服務器回到一致狀態

3.1 舊版本復制

3.1.1 同步

當向slave發送SLAVEOF命令,要求進行復制時,slave首先執行同步操作。同步操作主要通過向master發送SYNC命令來完成,如下:

  • slave向master發送SYNC命令
  • 收到SYNC命令的maser執行BGSAVE命令,在後臺生成RDB文件,並使用一個緩沖區記錄從現在開始執行的所有寫命令
  • 當master的BGSAVE命令執行完畢時,master會將BGSAVE命令生成的RDB文件發送給slave,slave接收並載入這個RDB文件,將自己的數據庫狀態更新至master執行BGSAVE命令時的數據庫裝填
  • master將記錄在緩沖區中的所有寫命令發送給slave,slave執行這些寫命令,將自己的數據庫狀態更新至master數據庫當前所處狀態。

3.1.2 命令傳播

同步操作完畢後,master/slave的數據庫將達到一致狀態,但這種一致並非一成不變,每當master執行客戶端的寫命令時,master的數據庫就有可能被修改,並導致master和slave不在一致。未解決此問題,master需要對slave執行命令傳播操作:master會將自己執行的寫命令,發送給slave執行。

3.1.3 缺點

slave對master的復制可分為2種情況:
①初次復制;
②斷線後重新復制。
舊版本復制功能,對於初次復制沒有問題,但對於斷線後的重新復制,雖然可以做到,但是效率極低:

  • T1 ~ T1000時刻:主從完成同步,並執行傳播:k1...k1000
  • T1001時刻:主從服務器連接斷開
  • T1001 ~T 1003時刻:master接收到k1001~k1003,共3個鍵
  • T1004時刻:主從服務器重新連接

此時slave向master發送SYNC命令重新同步,而該命令會讓master從k1~k1003的所有鍵生成RDB文件。

3.2 新版本復制

Redis2.8之後,使用PSYNC命令執行復制中的同步操作PSYNC的同步操作分為2種模式:完整同步部分同步

  • 完整同步:類似SYNC,都是通過讓master創建RDB文件,以及向slave發送保存在緩沖區裏的寫命令進行同步
  • 用於斷線後重復,若條件允許,master將master/slave連接斷開期間執行的寫命令發送給slave,slave只接受並執行這些寫命令

部分重同步的的過程如下圖所示(其中+CONTINUE表示以部分重同步進行):
技術分享圖片

部分重同步功能由下面3個部分組成:

  • master的復制偏移量和slave的復制偏移量
  • master的復制積壓緩沖區
  • 服務器的運行ID

3.2.1 復制偏移量

master和slave都分別維護一個復制偏移量。若master和slave的復制偏移量不同,那麽說明master和slave並未處於一致狀態:

  • master每次向slave傳播N個字節的數據時,就將自己的復制偏移量的值+N。
  • slave每次收到master傳播的N個字節數據時,就將自己的復制偏移量+N。

3.2.2 復制積壓緩沖區

master維護的一個固定長度的FIFO隊列,默認1MB。master進行命令傳播時,不僅會將命令發送給所有slave,還會將命令寫入到復制積壓緩沖區中。

當slave重連到master時,slave通過PSYNC命令將自己的復制偏移量offset發送給master,master會根據此復制偏移量來決定對slave執行何種同步操作:

若offset之後的數據,仍然存在於復制積壓緩沖區內,那麽master將對slave執行部分重同步操作。
若offset之後的數據,已不存在於復制積壓緩沖區內,那麽master將對slave執行完整重同步操作。

復制積壓緩沖區的大小:second * write_size_per_second

second: 服務器斷線後重新連接上master所需要的平均時間
write_size_per_second: master平均每秒產生的寫命令數量

3.2.3 服務器運行ID

服務器運行ID在服務器啟動時自動生成,由40個隨機的16進制字符組成。當slave對master進行初次復制時,master會將自己的運行ID傳送給slave。當slave斷線重連到master時,slave向當前連接的master發送之前保存的運行ID:

  • 若ID相同,master可以繼續嘗試執行部分重同步操作
  • 若ID不同,master將對slave執行完整重同步操作

4 集群

集群通過分片進行數據共享,並提供復制和故障轉移功能。節點和單機服務器在數據庫方面的一個區別:節點只能使用0號數據庫,而單機服務器沒有此限制。一個Redis集群由多個Node組成,剛開始每個Node相互獨立,通過CLUSTER MEET命令將各個Node連接起來,構成一個集群。舉例:

node1> CLUSTER MEET node2_ip node2_port
node1> CLUSTER NODES   ## 查看集群中的節點信息,可以看到node1, node2
node1> CLUSTER MEET node3_ip node3_port
node1> CLUSTER NODES   ## 查看集群中的節點信息,可以看到node1, node2, node3

Redis服務器在啟動時會根據cluster-enable配置項是否為yes來決定是否開啟服務器的集群模式。

4.1 結構

集群節點的數據結構如下:

struct clusterNode {
    mstime_t ctime;                      // 創建節點的時間
    char name[REDIS_CLUSTER_NAMELEN];    // 節點的名字,由40個16進制組成
    int flag;  // 節點的標識,標識節點的角色(master/slave),以及節點目前所處的狀態
    uint64_t configEpoch;  // 節點當前的配置紀元,用於實現故障轉移
    char ip[REDIS_IP_STR_LEN];  // 節點的IP地址
    int port;   // 節點的端口號
    clusterLink *link;   // 保存連接節點所需的有關信息
    //....
};

typedef struct clusterLink {
    mstime_t ctime;   // 連接的創建時間
    int fd;   // TCP套接字描述符
    sds sndbuf;  // 輸出緩沖區,保存著等待發送給其他節點的消息
    sds rcvbuf;  // 輸入緩沖區,保存著從其他節點接收到的消息
    struct clusterNode *node;   // 與這個連接相關聯的節點,如果沒有的話就為NULL
} clusterLink;
// 註意,每個節點保存著一個clusterState結構,用於記錄當前節點視角下,集群目前所處的狀態
typedef struct clusterState {
    clusterNode *myself;    // 指向當前節點的指針
    uint64_t currentEpoch;  // 集群當前的配置紀元,用於實現故障轉移
    int state;  // 集群當前的狀態:在線 or 下線
    int size;   // 集群中至少處理這一個槽的節點的數量
    dict *nodes;  // 集群節點名單,字典的鍵為節點的名字,字典的值為節點對應的clusterNode結構
    clusterNode *slots[16384]
} clusterState;

節點的數據結構大概如下所示:
技術分享圖片

4.2 槽指派

Redis集群通過分片保存數據庫中的鍵值對:集群的整個數據庫被分為16384個槽(slot),數據庫中的每個鍵都屬於其中一個槽,集群中每個節點可以處理:0~16384個槽。
若集群中的16384個槽都有節點在處理,集群處於上線狀態;否則集群處於下線狀態。上節中node1~node3節點連接在同一集群,但仍然處於下線狀態,因為3個節點沒有處理任何槽:

node1> CLUSTER INFO
node1> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
node2> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
node3> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383
node1> CLUSTER INFO

4.2.1 記錄節點的槽指派信息

struct clusterNode {
    /*二進制數組,數組長度為16384/8個字節,共16384個二進制位。以0位起始索引,16383為終止索引。根據索引i上的二進制位的值判斷該節點是否處理槽i。二進制位為0表示不負責處理。
    如下表示處理的槽位:0 ~ 6、9、16382
    |字節 |     slots[0]    |    slots[1] ~ slots[2047]
    |索引 | 0 1 2 3 4 5 6 7 | 8 9 10 11 12 ...  16382 16383
    |值   | 1 1 1 1 1 1 1 0 | 0 1 0  0  0  ...    1     0 
    */
    unsigned char slots[16384/8];
    // 用於記錄節點負責處理的槽的數量,即slots數組中值為1的二進制位的數量
    int numslots;
}

因為取出和設置slots數組中的任意二進制位的值復雜度為O(1),因此程序檢查節點是否負責處理某個槽,或將某個槽指派給某節點負責,復雜度均為O(1)。

4.2.2 傳播節點的槽指派信息

節點會將自己的slots數組通過消息發送給集群中的其他Node,以此來告知其他Node自己目前負責處理哪些槽。當Node1收到Node2的slots數組時,Node1會在自己的clusterState.nodes字典中查找Node2對應的clusterNode結構,並對結構中的slots數組更新或保存。

因為節點槽信息的傳播,集群中任何一個節點都會知道數據庫的16384個槽被指派給了集群中的哪個節點。如果只是將槽指派信息保存在各個節點的clusterNode.slots數組,那麽無法高效知道某個槽是否被指派、指派到了哪個節點等問題。因為程序需要遍歷clusterState.nodes字典中的所有clusterNode結構,檢查這些結構的slots數組,直到找到負責處理的槽的Node為止,復雜度O(N):N為clusterState.nodes字典保存的clusterNode結構的數量。

為了解決上述問題,clusterState結構增加了slots數組,如下:

typedef struct clusterState {
    clusterNode *slots[16384];   // 每個槽對應的節點信息,如果沒有對應節點,指向為null
} clusterState;

4.2.3 槽信息

綜上,一共記錄了兩個數組信息。

struct clusterNode {
    unsigned char slots[16384/8];
}

typedef struct clusterState {
    clusterNode *slots[16384];   // 每個槽對應的節點信息,如果沒有對應節點,指向為null
} clusterState;
  • clusterState.slots,記錄槽的指派信息,快速定位某個槽在哪個節點上
  • clusterNode.slots,記錄節點有哪些槽,集群間發送槽信息時更快。

4.2.4 重新分片

Redis集群的重新分片可以將源節點上任意數量的槽指派給目標節點,並且槽所屬的鍵值對也會從源節點移動到目標節點
在進行重新分片過程中,源節點目標節點遷移一個槽的過程中,可能存在這種情況:屬於被遷移的槽的一部分鍵值對保存在源節點裏,而另一部分鍵值對保存在目標節點裏。此時當客戶端向源節點發送命令,且該命令恰好就屬於正在被遷移的槽時:

  • 源節點會先在自己的數據庫裏查找制定鍵,若找到,則執行命令
  • 若沒找到,那麽該鍵可能已經被遷移到目標節點源節點向客戶端返回一個ASK錯誤,引導客戶端指向目標節點,並再次發送之前想要執行的命令。

4.3 復制與故障轉移

Redis集群中的節點分為master和slave,其中master用於處理槽,而slave則用於復制某個master,並在master下線時,代替下線的master繼續處理命令請求。

設置從節點的命令:CLUSTER REPLICATE <node_id>
該命令可以讓接收命令的節點成為node_id所指定節點的slave,並開始對主節點進行復制。

一旦節點成為slave,並開始復制某個master這一信息會通過消息發送給集群中的其他節點,最終集群中的所有節點都會知道某個slave正在復制某個master。集群中的所有節點都會在代表master的clusterNode結構中,如下:

struct clusterNode {
    /*正在復制這個master的slave的數量*/
    int numslaves;
    /*每個數組項都指向一個正在復制這個master的slave*/
    struct clusterNode **slaves;
}

4.3.1 故障檢測

集群中的每個節點都會定期地向集群中的其他節點發送PING,若接收PING的節點沒有在規定時間內響應PONG,那麽發送PING的節點將會把未響應PING的節點標記為疑似下線。

集群中的各個節點會通過相互發送消息來交換集群中各個節點的狀態信息,如某個節點處於在線狀態、疑似下線狀態、已下線狀態。例如

masterA通過消息得知masterB認為masterC進入了疑似下線狀態時,masterA會在自己的clusterState.nodes字典中找到masterC對應的clusterNode結構,並將masterB的下線報告添加到clusterNode結構的fail_reports鏈表裏

struct clusterNode {
    list *fail_reports;  //鏈表,記錄所有其他節點對該節點的下線報告
}
typedef struct clusterNodeFailReport {
    struct clusterNode *node;  // 報告目標節點已經下線的節點
    // 最後一次從node節點收到下線報告的時間,程序使用該時間戳來檢查下線報告是否過期
    // 與當前時間相差太久的下線報告會被刪除
    mstime_t time; 
} clusterNodeFailReport;
  • 疑似下線:那麽發送PING的節點將會把未響應PING的節點標記為疑似下線
  • 下線:若集群裏,超過半數負責處理槽的master都將某個masterX報告為疑似下線,則該master將被標記為下線。將masterX標記為下線的master會向集群廣播masterX下線的消息,所有收到該消息的節點都會立即將masterX標記為下線。
    技術分享圖片

4.3.2 故障轉移

當一個slave發現自己正在復制的masterX進入了下線狀態,slave開始對下線的master進行故障轉移:

  1. masterX的所有slave裏,會有一個slaveX被選中
  2. 被選中的slaveX會執行SLAVEOF no one命令,稱為新的master
  3. 新的master會撤銷所有對maserX的槽指派,並將這些槽全部指派給自己
  4. 新的master想集群廣播PONG,讓集群中其他節點知道該節點已經由slave轉變為master,並且該master接管了原本由已下線的master負責的所有槽
  5. 新的master開始接受和負責處理相關槽的命令請求。

4.3.3 選舉master

知識點

  • 集群的配置紀元是一個自增計數器,從0開始
  • 當集群裏某節點開始一次故障轉移操作時,紀元值自增1
  • 對於每個配置紀元,集群中每個負責處理槽的master都有一次投票的機會,而第一個向master要求投票的slave將獲得master的投票

流程如下:

  1. 當slave發現自己正在復制的master進入已下線狀態,slave會向集群廣播一條消息,要求所有收到這條消息、並具有投票權的master向自己投票
  2. 若一個master具有投票權,且尚未投票給其他slave,那麽該master將為要求自己投票的slave返回ACK消息,表示自己支持該slave稱為新的master
  3. 每個參與選舉的slave都會接受到ACK消息,並根據收到的消息來統計獲得的票數。票數過半的slave將被選舉為新的master
  4. 若一個配置紀元中,沒有slave稱為master,集群進入下一個配置紀元,繼續選舉

Redis原理