Redis全攻略

0x00.前言

Redis是跨語言的共同技術點,無論是Java還是C++都會問到,所以是個高頻面試點。

筆者是2017年纔開始接觸Redis的,期間自己搭過單機版和集羣版,不過現在大一些的公司都完全是運維來實現的,我們使用者只需要在web頁面進行相關申請即可,很多細節都被屏蔽了,這樣當然很方便啦,不過我們還是要深入理解一下的。

在工作幾年中筆者接觸過Redis、類Redis的SSDB和Pika、谷歌的Key-Value存儲引擎LevelDB、FackBook的Key-Value存儲引擎RocksDB等NoSQL,其中Redis是基於標準C語言開發的,是工程中和學習上都非常優秀的開源項目。

文中列出來的考點較多並且累計達3w+字 ,因此建議讀者收藏,以備不時之需,通過本文你將瞭解到以下內容:

  • Redis的作者和發展簡史

  • Redis常用數據結構及其實現

  • Redis的SDS和C中字符串的原理和對比

  • Redis有序集合ZSet的底層設計和實現

  • Redis有序集合ZSet和跳躍鏈表問題

  • Redis字典的實現及漸進式Rehash過程

  • Redis單線程運行模式的基本原理和流程

  • Redis反應堆模式的原理和設計實現

  • Redis持久化方案及其基本原理

  • 集羣版Redis和Gossip協議

  • Redis內存回收機制和基本原理

  • Redis數據同步機制和基本原理

0x01. 什麼是Redis及其重要性?

Redis是一個使用ANSI C編寫的開源、支持網絡、基於內存、可選持久化的高性能鍵值對數據庫。

Redis之父是來自意大利的西西里島的Salvatore Sanfilippo,Github網名antirez,筆者找了作者的一些簡要信息並翻譯了一下,如圖:

從2009年第一個版本起Redis已經走過了10個年頭,目前Redis仍然是最流行的key-value型內存數據庫之一。

優秀的開源項目離不開大公司的支持,在2013年5月之前,其開發由VMware贊助,而2013年5月至2015年6月期間,其開發由畢威拓贊助,從2015年6月開始,Redis的開發由Redis Labs贊助。

筆者也使用過一些其他的NoSQL,有的支持的value類型非常單一,因此很多操作都必須在客戶端實現,比如value是一個結構化的數據,需要修改其中某個字段就需要整體讀出來修改再整體寫入,顯得很笨重,但是Redis的value支持多種類型,實現了很多操作在服務端就可以完成了,這個對客戶端而言非常方便。

 

當然Redis由於是內存型的數據庫,數據量存儲量有限而且分佈式集羣成本也會非常高,因此有很多公司開發了基於SSD的類Redis系統,比如360開發的SSDB、Pika等數據庫,但是筆者認爲從0到1的難度是大於從1到2的難度的,毋庸置疑Redis是NoSQL中濃墨重彩的一筆,值得我們去深入研究和使用。

 

Redis提供了Java、C/C++、C#、 PHP 、JavaScript、 Perl 、Object-C、Python、Ruby、Erlang、Golang等多種主流語言的客戶端,因此無論使用者是什麼語言棧總會找到屬於自己的那款客戶端,受衆非常廣。

 

筆者查了datanyze.com網站看了下Redis和MySQL的最新市場份額和排名對比以及全球Top站點的部署量對比(網站數據2019.12):



可以看到Redis總體份額排名第9並且在全球Top100站點中部署數量與MySQL基本持平,所以Redis還是有一定的江湖地位的。

0x02. 簡述Redis常用的數據結構及其如何實現的?

Redis支持的常用5種數據類型指的是value類型,分別爲:字符串String、列表List、哈希Hash、集合Set、有序集合Zset,但是Redis後續又豐富了幾種數據類型分別是Bitmaps、HyperLogLogs、GEO。

由於Redis是基於標準C寫的,只有最基礎的數據類型,因此Redis爲了滿足對外使用的5種數據類型,開發了屬於自己獨有的一套基礎數據結構,使用這些數據結構來實現5種數據類型。

Redis底層的數據結構包括:簡單動態數組SDS、鏈表、字典、跳躍鏈表、整數集合、壓縮列表、對象。

Redis爲了平衡空間和時間效率,針對value的具體類型在底層會採用不同的數據結構來實現,其中哈希表和壓縮列表是複用比較多的數據結構,如下圖展示了對外數據類型和底層數據結構之間的映射關係:


從圖中可以看到ziplist壓縮列表可以作爲Zset、Set、List三種數據類型的底層實現,看來很強大,壓縮列表是一種爲了節約內存而開發的且經過特殊編碼之後的連續內存塊順序型數據結構,底層結構還是比較複雜的。

0x03. Redis的SDS和C中字符串相比有什麼優勢?

在C語言中使用N+1長度的字符數組來表示字符串,尾部使用'\0'作爲結尾標誌,對於此種實現無法滿足Redis對於安全性、效率、豐富的功能的要求,因此Redis單獨封裝了SDS簡單動態字符串結構。

在理解SDS的優勢之前需要先看下SDS的實現細節,找了github最新的src/sds.h的定義看下:


 

typedef char *sds;

/*這個用不到 忽略即可*/
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];

};

/*不同長度的header 8 16 32 64共4種 都給出了四個成員
len:當前使用的空間大小;alloc去掉header和結尾空字符的最大空間大小
flags:8位的標記 下面關於SDS_TYPE_x的宏定義只有5種 3bit足夠了 5bit沒有用
buf:這個跟C語言中的字符數組是一樣的,從typedef char* sds可以知道就是這樣的。
buf的最大長度是2^n 其中n爲sdshdr的類型,如當選擇sdshdr16,buf_max=2^16。
*/
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3

看了前面的定義,筆者畫了個圖:

從圖中可以知道sds本質分爲三部分:header、buf、null結尾符,其中header可以認爲是整個sds的指引部分,給定了使用的空間大小、最大分配大小等信息,再用一張網上的圖來清晰看下sdshdr8的實例:


 

在sds.h/sds.c源碼中可清楚地看到sds完整的實現細節,本文就不展開了要不然篇幅就過長了,快速進入主題說下sds的優勢:

  • O(1)獲取長度: C字符串需要遍歷而sds中有len可以直接獲得;

  • 防止緩衝區溢出bufferoverflow: 當sds需要對字符串進行修改時,首先藉助於len和alloc檢查空間是否滿足修改所需的要求,如果空間不夠的話,SDS會自動擴展空間,避免了像C字符串操作中的覆蓋情況;

  • 有效降低內存分配次數:C字符串在涉及增加或者清除操作時會改變底層數組的大小造成重新分配、sds使用了空間預分配和惰性空間釋放機制,說白了就是每次在擴展時是成倍的多分配的,在縮容是也是先留着並不正式歸還給OS,這兩個機制也是比較好理解的;

  • 二進制安全:C語言字符串只能保存ascii碼,對於圖片、音頻等信息無法保存,sds是二進制安全的,寫入什麼讀取就是什麼,不做任何過濾和限制;

 

老規矩上一張黃健宏大神總結好的圖:

0x04. Redis的字典是如何實現的?簡述漸進式rehash過程

字典算是Redis中常用數據類型中的明星成員了,前面說過字典可以基於ziplist和hashtable來實現,我們只討論基於hashtable實現的原理。

字典是個層次非常明顯的數據類型,如圖:

有了個大概的概念,我們看下最新的src/dict.h源碼定義:

//哈希節點結構
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

//封裝的是字典的操作函數指針
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
//哈希表結構 該部分是理解字典的關鍵
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

//字典結構
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

C語言的好處在於定義必須是由最底層向外的,因此我們可以看到一個明顯的層次變化,於是筆者又畫一圖來展現具體的層次概念:


 

  • 關於dictEntry

dictEntry是哈希表節點,也就是我們存儲數據地方,其保護的成員有:key,v,next指針。key保存着鍵值對中的鍵,v保存着鍵值對中的值,值可以是一個指針或者是uint64_t或者是int64_t。next是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接在一次,以此來解決哈希衝突的問題。

如圖爲兩個衝突的哈希節點的連接關係:

  • 關於dictht

從源碼看哈希表包括的成員有table、size、used、sizemask。table是一個數組,數組中的每個元素都是一個指向dictEntry結構的指針, 每個dictEntry結構保存着一個鍵值對;size 屬性記錄了哈希表table的大小,而used屬性則記錄了哈希表目前已有節點的數量。sizemask等於size-1和哈希值計算一個鍵在table數組的索引,也就是計算index時用到的。

如上圖展示了一個大小爲4的table中的哈希節點情況,其中k1和k0在index=2發生了哈希衝突,進行開鏈表存在,本質上是先存儲的k0,k1放置是發生衝突爲了保證效率直接放在衝突鏈表的最前面,因爲該鏈表沒有尾指針。

  • 關於dict

從源碼中看到dict結構體就是字典的定義,包含的成員有type,privdata、ht、rehashidx。其中dictType指針類型的type指向了操作字典的api,理解爲函數指針即可,ht是包含2個dictht的數組,也就是字典包含了2個哈希表,rehashidx進行rehash時使用的變量,privdata配合dictType指向的函數作爲參數使用,這樣就對字典的幾個成員有了初步的認識。

 

  • 字典的哈希算法

//僞碼:使用哈希函數,計算鍵key的哈希值
hash = dict->type->hashFunction(key);
//僞碼:使用哈希表的sizemask和哈希值,計算出在ht[0]或許ht[1]的索引值
index = hash & dict->ht[x].sizemask;
//源碼定義
#define dictHashKey(d, key) (d)->type->hashFunction(key)

redis使用MurmurHash算法計算哈希值,該算法最初由Austin Appleby在2008年發明,MurmurHash算法的無論數據輸入情況如何都可以給出隨機分佈性較好的哈希值並且計算速度非常快,目前有MurmurHash2和MurmurHash3等版本。

  • 普通Rehash重新散列

哈希表保存的鍵值對數量是動態變化的,爲了讓哈希表的負載因子維持在一個合理的範圍之內,就需要對哈希表進行擴縮容。

擴縮容是通過執行rehash重新散列來完成,對字典的哈希表執行普通rehash的基本步驟爲分配空間->逐個遷移->交換哈希表,詳細過程如下:

  1. 爲字典的ht[1]哈希表分配空間,分配的空間大小取決於要執行的操作以及ht[0]當前包含的鍵值對數量:
    擴展操作時ht[1]的大小爲第一個大於等於ht[0].used*2的2^n;
    收縮操作時ht[1]的大小爲第一個大於等於ht[0].used的2^n ;

    擴展時比如h[0].used=200,那麼需要選擇大於400的第一個2的冪,也就是2^9=512。

  2. 將保存在ht[0]中的所有鍵值對重新計算鍵的哈希值和索引值rehash到ht[1]上;

  3. 重複rehash直到ht[0]包含的所有鍵值對全部遷移到了ht[1]之後釋放 ht[0], 將ht[1]設置爲 ht[0],並在ht[1]新創建一個空白哈希表, 爲下一次rehash做準備。

  • 漸進Rehash過程

Redis的rehash動作並不是一次性完成的,而是分多次、漸進式地完成的,原因在於當哈希表裏保存的鍵值對數量很大時, 一次性將這些鍵值對全部rehash到ht[1]可能會導致服務器在一段時間內停止服務,這個是無法接受的。

針對這種情況Redis採用了漸進式rehash,過程的詳細步驟:

  1. 爲ht[1]分配空間,這個過程和普通Rehash沒有區別;

  2. 將rehashidx設置爲0,表示rehash工作正式開始,同時這個rehashidx是遞增的,從0開始表示從數組第一個元素開始rehash。

  3. 在rehash進行期間,每次對字典執行增刪改查操作時,順帶將ht[0]哈希表在rehashidx索引上的鍵值對rehash到 ht[1],完成後將rehashidx加1,指向下一個需要rehash的鍵值對。

  4. 隨着字典操作的不斷執行,最終ht[0]的所有鍵值對都會被rehash至ht[1],再將rehashidx屬性的值設爲-1來表示 rehash操作已完成。

漸進式 rehash的思想在於將rehash鍵值對所需的計算工作分散到對字典的每個添加、刪除、查找和更新操作上,從而避免了集中式rehash而帶來的阻塞問題

看到這裏不禁去想這種捎帶腳式的rehash會不會導致整個過程非常漫長?如果某個value一直沒有操作那麼需要擴容時由於一直不用所以影響不大,需要縮容時如果一直不處理可能造成內存浪費,具體的還沒來得及研究,先埋個問題吧

0x05. 講講4.0之前版本的Redis的單線程運行模式

本質上Redis並不是單純的單線程服務模型,一些輔助工作比如持久化刷盤、惰性刪除等任務是由BIO線程來完成的,這裏說的單線程主要是說與客戶端交互完成命令請求和回覆的工作線程。

至於Antirez大佬當時是怎麼想的設計爲單線程不得而知,只能從幾個角度來分析,來確定單線程模型的選擇原因。

5.1 單線程模式的考量

CPU並非瓶頸:多線程模型主要是爲了充分利用多核CPU,讓線程在IO阻塞時被掛起讓出CPU使用權交給其他線程,充分提高CPU的使用率,但是這個場景在Redis並不明顯,因爲CPU並不是Redis的瓶頸,Redis的所有操作都是基於內存的,處理事件極快,因此使用多線程來切換線程提高CPU利用率的需求並不強烈;

內存纔是瓶頸:單個Redis實例對單核的利用已經很好了,但是Redis的瓶頸在於內存,設想64核的機器假如內存只有16GB,那麼多線程Redis有什麼用武之地?

複雜的Value類型:Redis有豐富的數據結構,並不是簡單的Key-Value型的NoSQL,這也是Redis備受歡迎的原因,其中常用的Hash、Zset、List等結構在value很大時,CURD的操作會很複雜,如果採用多線程模式在進行相同key操作時就需要加鎖來進行同步,這樣就可能造成死鎖問題。

這時候你會問:將key做hash分配給相同的線程來處理就可以解決呀,確實是這樣的,這樣的話就需要在Redis中增加key的hash處理以及多線程負載均衡的處理,從而Redis的實現就成爲多線程模式了,好像確實也沒有什麼問題,但是Antirez並沒有這麼做,大神這麼做肯定是有原因的,果不其然,我們見到了集羣化的Redis;

集羣化擴展:目前的機器都是多核的,但是內存一般128GB/64GB算是比較普遍了,但是Redis在使用內存60%以上穩定性就不如50%的性能了(至少筆者在使用集羣化Redis時超過70%時,集羣failover的頻率會更高),因此在數據較大時,當Redis作爲主存,就必須使用多臺機器構建集羣化的Redis數據庫系統,這樣以來Redis的單線程模式又被集羣化的處理所擴展了;

 

軟件工程角度:單線程無論從開發和維護都比多線程要容易非常多,並且也能提高服務的穩定性,無鎖化處理讓單線程的Redis在開發和維護上都具備相當大的優勢;

 

類Redis系統:Redis的設計秉承實用第一和工程化,雖然有很多理論上優秀的設計模式,但是並不一定適用自己,軟件設計過程就是權衡的過程。業內也有許多類Redis的NoSQL,比如360基礎架構組開發的Pika系統,基於SSD和Rocks存儲引擎,上層封裝一層協議轉換,來實現Redis所有功能的模擬,感興趣的可以研究和使用。

5.2 Redis的文件事件和時間事件

Redis作爲單線程服務要處理的工作一點也不少,Redis是事件驅動的服務器,主要的事件類型就是:文件事件類型和時間事件類型,其中時間事件是理解單線程邏輯模型的關鍵。

 

  • 時間事件

Redis的時間事件分爲兩類:

  1. 定時事件:任務在等待指定大小的等待時間之後就執行,執行完成就不再執行,只觸發一次;

  2. 週期事件:任務每隔一定時間就執行,執行完成之後等待下一次執行,會週期性的觸發;

     

  • 週期性時間事件

Redis中大部分是週期事件,週期事件主要是服務器定期對自身運行情況進行檢測和調整,從而保證穩定性,這項工作主要是ServerCron函數來完成的,週期事件的內容主要包括:

  1. 刪除數據庫的key

  2. 觸發RDB和AOF持久化

  3. 主從同步

  4. 集羣化保活

  5. 關閉清理死客戶端鏈接

  6. 統計更新服務器的內存、key數量等信息

可見 Redis的週期性事件雖然主要處理輔助任務,但是對整個服務的穩定運行,起到至關重要的作用。

 

  • 時間事件的無序鏈表

Redis的每個時間事件分爲三個部分:

  1. 事件ID 全局唯一 依次遞增

  2. 觸發時間戳 ms級精度

  3. 事件處理函數 事件回調函數

時間事件Time_Event結構:

Redis的時間事件是存儲在鏈表中的,並且是按照ID存儲的,新事件在頭部舊事件在尾部,但是並不是按照即將被執行的順序存儲的。

 

也就是第一個元素50ms後執行,但是第三個可能30ms後執行,這樣的話Redis每次從鏈表中獲取最近要執行的事件時,都需要進行O(N)遍歷,顯然性能不是最好的,最好的情況肯定是類似於最小棧MinStack的思路,然而Antirez大佬卻選擇了無序鏈表的方式。

 

選擇無序鏈表也是適合Redis場景的,因爲Redis中的時間事件數量並不多,即使進行O(N)遍歷性能損失也微乎其微,也就不必每次插入新事件時進行鏈表重排。

 

Redis存儲時間事件的無序鏈表如圖:

5.3 單線程模式中事件調度和執行

Redis服務中因爲包含了時間事件和文件事件,事情也就變得複雜了,服務器要決定何時處理文件事件、何時處理時間事件、並且還要明確知道處理時間的時間長度,因此事件的執行和調度就成爲重點。

 

Redis服務器會輪流處理文件事件和時間事件,這兩種事件的處理都是同步、有序、原子地執行的,服務器也不會終止正在執行的事件,也不會對事件進行搶佔。

  • 事件執行調度規則

文件事件是隨機出現的,如果處理完成一次文件事件後,仍然沒有其他文件事件到來,服務器將繼續等待,在文件事件的不斷執行中,時間會逐漸向最早的時間事件所設置的到達時間逼近並最終來到到達時間,這時服務器就可以開始處理到達的時間事件了。

 

由於時間事件在文件事件之後執行,並且事件之間不會出現搶佔,所以時間事件的實際處理時間一般會比設定的時間稍晚一些。

 

  • 事件執行調度的代碼實現

Redis源碼ae.c中對事件調度和執行的詳細過程在aeProcessEvents中實現的,具體的代碼如下:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
int aeProcessEvents(aeEventLoop *eventLoop, int flags){  int processed = 0, numevents;  if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS))    return 0;
  if (eventLoop->maxfd != -1 ||    ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {    int j;    aeTimeEvent *shortest = NULL;    struct timeval tv, *tvp;
    if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))      shortest = aeSearchNearestTimer(eventLoop);    if (shortest) {      long now_sec, now_ms;      aeGetTime(&now_sec, &now_ms);      tvp = &tv;      long long ms =        (shortest->when_sec - now_sec)*1000 +        shortest->when_ms - now_ms;
      if (ms > 0) {        tvp->tv_sec = ms/1000;        tvp->tv_usec = (ms % 1000)*1000;      } else {        tvp->tv_sec = 0;        tvp->tv_usec = 0;      }    } else {      if (flags & AE_DONT_WAIT) {        tv.tv_sec = tv.tv_usec = 0;        tvp = &tv;      } else {        tvp = NULL; /* wait forever */      }    }    numevents = aeApiPoll(eventLoop, tvp);    if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)      eventLoop->aftersleep(eventLoop);
    for (j = 0; j < numevents; j++) {      aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];      int mask = eventLoop->fired[j].mask;      int fd = eventLoop->fired[j].fd;      int fired = 0;      int invert = fe->mask & AE_BARRIER;      if (!invert && fe->mask & mask & AE_READABLE) {        fe->rfileProc(eventLoop,fd,fe->clientData,mask);        fired++;      }      if (fe->mask & mask & AE_WRITABLE) {        if (!fired || fe->wfileProc != fe->rfileProc) {          fe->wfileProc(eventLoop,fd,fe->clientData,mask);          fired++;        }      }      if (invert && fe->mask & mask & AE_READABLE) {        if (!fired || fe->wfileProc != fe->rfileProc) {          fe->rfileProc(eventLoop,fd,fe->clientData,mask);          fired++;        }      }      processed++;    }  }  /* Check time events */  if (flags & AE_TIME_EVENTS)    processed += processTimeEvents(eventLoop);  return processed;}
  • 事件執行和調度的僞碼

     

上面的源碼可能讀起來並不直觀,在《Redis設計與實現》書中給出了僞代碼實現:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
def aeProcessEvents()  #獲取當前最近的待執行的時間事件  time_event = aeGetNearestTimer()  #計算最近執行事件與當前時間的差值  remain_gap_time = time_event.when - uinx_time_now()  #判斷時間事件是否已經到期 則重置 馬上執行  if remain_gap_time < 0:    remain_gap_time = 0  #阻塞等待文件事件 具體的阻塞等待時間由remain_gap_time決定  #如果remain_gap_time爲0 那麼不阻塞立刻返回  aeApiPoll(remain_gap_time)  #處理所有文件事件  ProcessAllFileEvent()  #處理所有時間事件  ProcessAllTimeEvent()

可以看到Redis服務器是邊阻塞邊執行的,具體的阻塞事件由最近待執行時間事件的等待時間決定的,在阻塞該最小等待時間返回之後,開始處理事件任務,並且先執行文件事件、再執行時間事件,所有即使時間事件要即刻執行,也需要等待文件事件完成之後再執行時間事件,所以比預期的稍晚。

 

  • 事件調度和執行流程

 

0x06. 談談對Redis的反應堆模式的認識

Redis基於Reactor模式(反應堆模式)開發了自己的網絡模型,形成了一個完備的基於IO複用的事件驅動服務器,但是不由得浮現幾個問題:  

  1.  爲什麼要使用Reactor模式呢?

  2.  Redis如何實現自己的Reactor模式?

6.1 Reactor模式

單純的epoll/kqueue可以單機支持數萬併發,單純從性能的角度而言毫無問題,但是技術實現和軟件設計仍然存在一些差異。

設想這樣一種場景:

  • epoll/kqueue將收集到的可讀寫事件全部放入隊列中等待業務線程的處理,此時線程池的工作線程拿到任務進行處理,實際場景中可能有很多種請求類型,工作線程每拿到一種任務就進行相應的處理,處理完成之後繼續處理其他類型的任務

  • 工作線程需要關注各種不同類型的請求,對於不同的請求選擇不同的處理方法,因此請求類型的增加會讓工作線程複雜度增加,維護起來也變得越來越困難

上面的場景其實和高併發網絡模型很相似,如果我們在epoll/kqueue的基礎上進行業務區分,並且對每一種業務設置相應的處理函數,每次來任務之後對任務進行識別和分發,每種處理函數只處理一種業務,這種模型更加符合OO的設計理念,這也是Reactor反應堆模式的設計思路。

反應堆模式是一種對象行爲的設計模式,主要同於同步IO,異步IO有Proactor模式,這裏不詳細講述Proactor模式,二者的主要區別就是Reactor是同步IO,Proactor是異步IO,理論上Proactor效率更高,但是Proactor模式需要操作系統在內核層面對異步IO進行支持,Linux的Boost.asio就是Proactor模式的代表,Windows有IOCP。

 

網上比較經典的一張Reactor模式的類圖:

 

圖中給出了5個部件分別爲:

  1. handle 可以理解爲讀寫事件 可以註冊到Reactor進行監控

  2. Sync event demultiplexer 可以理解爲epoll/kqueue/select等作爲IO事件的採集器

  3. Dispatcher 提供註冊/刪除事件並進行分發,作爲事件分發器

  4. Event Handler 事件處理器 完成具體事件的回調 供Dispatcher調用

  5. Concrete Event Handler 具體請求處理函數

 

更簡潔的流程如下:

 

循環前先將待監控的事件進行註冊,當監控中的Socket讀寫事件到來時,事件採集器epoll等IO複用工具檢測到並且將事件返回給事件分發器Dispatcher,分發器根據讀、寫、異常等情況進行分發給事件處理器,事件處理器進而根據事件具體類型來調度相應的實現函數來完成任務。

6.2 Reactor模式在Redis中的實現

Redis處理客戶端業務(文件事件)的基本流程:

 

  • Redis的IO複用的選擇

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
#ifdef HAVE_EVPORT#include "ae_evport.c"#else    #ifdef HAVE_EPOLL    #include "ae_epoll.c"    #else        #ifdef HAVE_KQUEUE        #include "ae_kqueue.c"        #else        #include "ae_select.c"        #endif    #endif#endif

 

Redis中支持多種IO複用,源碼中使用相應的宏定義進行選擇,編譯時就可以獲取當前系統支持的最優的IO複用函數來使用,從而實現了Redis的優秀的可移植特性。

 

  • Redis的任務事件隊列

由於Redis的是單線程處理業務的,因此IO複用程序將讀寫事件同步的逐一放入隊列中,如果當前隊列已經滿了,那麼只能出一個入一個,但是由於Redis正常情況下處理得很快,不太會出現隊列滿遲遲無法放任務的情況,但是當執行某些阻塞操作時將導致長時間的阻塞,無法處理新任務。

 

  • Redis事件分派器

事件的可讀寫是從服務器角度看的,分派看到的事件類型包括:

  1. AE_READABLE 客戶端寫數據、關閉連接、新連接到達

  2. AE_WRITEABLE 客戶端讀數據

特別地,當一個套接字連接同時可讀可寫時,服務器會優先處理讀事件再處理寫事件,也就是讀優先。

 

  • Redis事件處理器

Redis將文件事件進行歸類,編寫了多個事件處理器函數,其中包括:

  1. 連接應答處理器:實現新連接的建立

  2. 命令請求處理器:處理客戶端的新命令

  3. 命令回覆處理器:返回客戶端的請求結果

  4. 複製處理器:實現主從服務器的數據複製

 

  • Redis C/S一次完整的交互

Redis服務器的主線程處於循環中,此時Client向Redis服務器發起連接請求,假如是6379端口,監聽端口在IO複用工具下檢測到AE_READABLE事件,並將該事件放入TaskQueue中,等待被處理,事件分派器獲取這個讀事件,進一步確定是新連接請求,就將該事件交給連接應答處理器建立連接;

 

建立連接後Client向服務器發送了一個get命令,仍然被IO複用檢測處理放入隊列,被事件分派器處理指派給命令請求處理器,調用相應程序進行執行;

 

服務器將套接字的AE_WRITEABLE事件與命令回覆處理器相關聯,當客戶端嘗試讀取結果時產生可寫事件,此時服務器端觸發命令回覆響應,並將數據結果寫入套接字,完成之後服務端接觸該套接字與命令回覆處理器之間的關聯;

0x07. Redis是如何做持久化的及其基本原理

通俗講持久化就是將內存中的數據寫入非易失介質中,比如機械磁盤和SSD。

 

在服務器發生宕機時,作爲內存數據庫Redis裏的所有數據將會丟失,因此Redis提供了持久化兩大利器:RDB和AOF

  1. RDB 將數據庫快照以二進制的方式保存到磁盤中。

  2. AOF 以協議文本方式,將所有對數據庫進行過寫入的命令和參數記錄到 AOF 文件,從而記錄數據庫狀態。

 

  • 查看RDB配置

  •  
  •  
  •  
  •  
  •  
  •  
[redis@abc]$ cat /abc/redis/conf/redis.conf   save 900 1  save 300 10  save 60 10000  dbfilename "dump.rdb" dir "/data/dbs/redis/rdbstro" 

前三行都是對觸發RDB的一個條件, 如第一行表示每900秒鐘有一條數據被修改則觸發RDB,依次類推;只要一條滿足就會進行RDB持久化;

第四行dbfilename指定了把內存裏的數據庫寫入本地文件的名稱,該文件是進行壓縮後的二進制文件;

第五行dir指定了RDB二進制文件存放目錄 ;

 

  • 修改RDB配置

在命令行裏進行配置,服務器重啓纔會生效:

  •  
  •  
  •  
  •  
  •  
  •  
[redis@abc]$ bin/redis-cli127.0.0.1:6379> CONFIG GET save 1) "save"2) "900 1 300 10 60 10000"127.0.0.1:6379> CONFIG SET save "21600 1000" OK

7.1 RDB的SAVE和BGSAVE

RDB文件適合數據的容災備份與恢復,通過RDB文件恢復數據庫耗時較短,可以快速恢復數據。

 

RDB持久化只會週期性的保存數據,在未觸發下一次存儲時服務宕機,就會丟失增量數據。當數據量較大的情況下,fork子進程這個操作很消耗cpu,可能會發生長達秒級別的阻塞情況。

 

SAVE是阻塞式持久化,執行命令時Redis主進程把內存數據寫入到RDB文件中直到創建完畢,期間Redis不能處理任何命令。

 

BGSAVE屬於非阻塞式持久化,創建一個子進程把內存中數據寫入RDB文件裏同時主進程處理命令請求。

 

如圖展示了bgsave的簡單流程:

 

  • BGSAVE實現細節

 

RDB方式的持久化是通過快照實現的,符合條件時Redis會自動將內存數據進行快照並存儲在硬盤上,以BGSAVE爲例,一次完整數據快照的過程:

 

  1. Redis使用fork函數創建子進程;

  2. 父進程繼續接收並處理命令請求,子進程將內存數據寫入臨時文件;

  3. 子進程寫入所有數據後會用臨時文件替換舊RDB文件;

 

執行fork的時OS會使用寫時拷貝策略,對子進程進行快照過程優化。

 

Redis在進行快照過程中不會修改RDB文件,只有快照結束後纔會將舊的文件替換成新的,也就是任何時候RDB文件都是完整的。

 

我們可以通過定時備份RDB文件來實現Redis數據庫備份,RDB文件是經過壓縮的,佔用的空間會小於內存中的數據大小。


除了自動快照還可以手動發送SAVE或BGSAVE命令讓Redis執行快照。通過RDB方式實現持久化,由於RDB保存頻率的限制,如果數據很重要則考慮使用AOF方式進行持久化。

7.2 AOF詳解

在使用AOF持久化方式時,Redis會將每一個收到的寫命令都通過Write函數追加到文件中類似於MySQL的binlog。換言之AOF是通過保存對redis服務端的寫命令來記錄數據庫狀態的。

 

AOF文件有自己的存儲協議格式:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
[redis@abc]$ more appendonly.aof *2     # 2個參數$6     # 第一個參數長度爲 6SELECT     # 第一個參數$1     # 第二參數長度爲 18     # 第二參數*3     # 3個參數$3     # 第一個參數長度爲 4SET     # 第一個參數$4     # 第二參數長度爲 4name     # 第二個參數$4     # 第三個參數長度爲 4Jhon     # 第二參數長度爲 4

AOF配置:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
[redis@abc]$ more ~/redis/conf/redis.confdir "/data/dbs/redis/abcd"           #AOF文件存放目錄appendonly yes                       #開啓AOF持久化,默認關閉appendfilename "appendonly.aof"      #AOF文件名稱(默認)appendfsync no                       #AOF持久化策略auto-aof-rewrite-percentage 100      #觸發AOF文件重寫的條件(默認)auto-aof-rewrite-min-size 64mb       #觸發AOF文件重寫的條件(默認)

  當開啓AOF後,服務端每執行一次寫操作就會把該條命令追加到一個單獨的AOF緩衝區的末尾,然後把AOF緩衝區的內容寫入AOF文件裏,由於磁盤緩衝區的存在寫入AOF文件之後,並不代表數據已經落盤了,而何時進行文件同步則是根據配置的appendfsync來進行配置:

 

appendfsync選項:always、everysec和no:

  • always:服務器在每執行一個事件就把AOF緩衝區的內容強制性的寫入硬盤上的AOF文件裏,保證了數據持久化的完整性,效率是最慢的但最安全的;

  • everysec:服務端每隔一秒纔會進行一次文件同步把內存緩衝區裏的AOF緩存數據真正寫入AOF文件裏,兼顧了效率和完整性,極端情況服務器宕機只會丟失一秒內對Redis數據庫的寫操作;

  • no:表示默認系統的緩存區寫入磁盤的機制,不做程序強制,數據安全性和完整性差一些。

 

AOF比RDB文件更大,並且在存儲命令的過程中增長更快,爲了壓縮AOF的持久化文件,Redis提供了重寫機制以此來實現控制AOF文件的增長。

 

AOF重寫實現的理論基礎是這樣的:

  1. 執行set hello world 50次 

  2. 最後執行一次 set hello china

  3. 最終對於AOF文件而言前面50次都是無意義的,AOF重寫就是將key只保存最後的狀態。

  4. 重寫期間的數據一致性問題

 

子進程在進行 AOF 重寫期間, 主進程還需要繼續處理命令, 而新的命令可能對現有的數據進行修改, 會出現數據庫的數據和重寫後的 AOF 文件中的數據不一致。

 

因此Redis 增加了一個 AOF 重寫緩存, 這個緩存在 fork 出子進程之後開始啓用, Redis 主進程在接到新的寫命令之後, 除了會將這個寫命令的協議內容追加到現有的 AOF 文件之外, 還會追加到這個緩存中。

 

當子進程完成 AOF 重寫之後向父進程發送一個完成信號, 父進程在接到完成信號之後會調用信號處理函數,完成以下工作:

  1. 將 AOF 重寫緩存中的內容全部寫入到新 AOF 文件中

  2. 對新的 AOF 文件進行改名,覆蓋原有的 AOF 文件

  3. AOF重寫的阻塞性

 

整個 AOF 後臺重寫過程中只有最後寫入緩存和改名操作會造成主進程阻塞, 在其他時候AOF 後臺重寫都不會對主進程造成阻塞, 將 AOF 重寫對性能造成的影響降到了最低。

 

AOF 重寫可以由用戶通過調用 BGREWRITEAOF 手動觸發。

服務器在 AOF 功能開啓的情況下,會維持以下三個變量:

  1. 當前 AOF 文件大小 

  2. 最後一次 重寫之後, AOF 文件大小的變量 

  3. AOF文件大小增長百分比

     

每次當 serverCron 函數執行時, 它都會檢查以下條件是否全部滿足, 如果是的話, 就會觸發自動的 AOF 重寫:

  1. 沒有 BGSAVE 命令在進行 防止於RDB的衝突

  2. 沒有 BGREWRITEAOF 在進行 防止和手動AOF衝突

  3. 當前 AOF 文件大小至少大於設定值 基本要求 太小沒意義

  4. 當前 AOF 文件大小和最後一次 AOF 重寫後的大小之間的比率大於等於指定的增長百分比

7.3 Redis的數據恢復

Redis的數據恢復優先級

  1. 如果只配置 AOF ,重啓時加載 AOF 文件恢復數據;

  2. 如果同時配置了 RDB 和 AOF ,啓動只加載 AOF 文件恢復數據;

  3. 如果只配置 RDB,啓動將加載 dump 文件恢復數據。

     

拷貝 AOF 文件到 Redis 的數據目錄,啓動 redis-server AOF 的數據恢復過程:Redis 虛擬一個客戶端,讀取AOF文件恢復 Redis 命令和參數,然後執行命令從而恢復數據,這些過程主要在loadAppendOnlyFile() 中實現。

 

拷貝 RDB 文件到 Redis 的數據目錄,啓動 redis-server即可,因爲RDB文件和重啓前保存的是真實數據而不是命令狀態和參數。

 

新型的混合型持久化

RDB和AOF都有各自的缺點:

  1. RDB是每隔一段時間持久化一次, 故障時就會丟失宕機時刻與上一次持久化之間的數據,無法保證數據完整性

  2. AOF存儲的是指令序列, 恢復重放時要花費很長時間並且文件更大

 

Redis 4.0 提供了更好的混合持久化選項: 創建出一個同時包含 RDB 數據和 AOF 數據的 AOF 文件, 其中 RDB 數據位於 AOF 文件的開頭, 它們儲存了服務器開始執行重寫操作時的數據庫狀態,至於那些在重寫操作執行之後執行的 Redis 命令, 則會繼續以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 數據之後。

 

持久化實戰

在實際使用中需要根據Redis作爲主存還是緩存、數據完整性和缺失性的要求、CPU和內存情況等諸多因素來確定適合自己的持久化方案,一般來說穩妥的做法包括:

  1. 最安全的做法是RDB與AOF同時使用,即使AOF損壞無法修復,還可以用RDB來恢復數據,當然在持久化時對性能也會有影響。

  2. Redis當簡單緩存,沒有緩存也不會造成緩存雪崩只使用RDB即可。

  3. 不推薦單獨使用AOF,因爲AOF對於數據的恢復載入比RDB慢,所以使用AOF的時候,最好還是有RDB作爲備份。

  4. 採用新版本Redis 4.0的持久化新方案。

0x08.談談Redis的ZIPLIST的底層設計和實現

先不看Redis的對ziplist的具體實現,我們先來想一下如果我們來設計這個數據結構需要做哪些方面的考慮呢?思考式地學習收穫更大呦!

 

  • 考慮點1:連續內存的雙面性

連續型內存減少了內存碎片,但是連續大內存又不容易滿足。這個非常好理解,你和好基友三人去做地鐵,你們三個挨着坐肯定不浪費空間,但是地鐵裏很多人都是單獨出行的,大家都不願意緊挨着,就這樣有2個的位置有1個的位置,可是3個連續的確實不好找呀,來張圖:

 

  • 考慮點2: 壓縮列表承載元素的多樣性

待設計結構和數組不一樣,數組是已經強制約定了類型,所以我們可以根據元素類型和個數來確定索引的偏移量,但是壓縮列表對元素的類型沒有約束,也就是說不知道是什麼數據類型和長度,這個有點像TCP粘包拆包的做法了,需要我們指定結尾符或者指定單個存儲的元素的長度,要不然數據都粘在一起了。

 

  • 考慮點3:屬性的常數級耗時獲取

就是說我們解決了前面兩點考慮,但是作爲一個整體,壓縮列表需要常數級消耗提供一些總體信息,比如總長度、已存儲元素數量、尾節點位置(實現尾部的快速插入和刪除)等,這樣對於操作壓縮列表意義很大。

 

  • 考慮點4:數據結構對增刪的支持

理論上我們設計的數據結構要很好地支持增刪操作,當然凡事必有權衡,沒有什麼數據結構是完美的,我們邊設計邊調整吧。

 

  • 考慮點5:如何節約內存

我們要節約內存就需要特殊情況特殊處理,所謂變長設計,也就是不像雙向鏈表一樣固定使用兩個pre和next指針來實現,這樣空間消耗更大,因此可能需要使用變長編碼。

ziplist總體結構

大概想了這麼多,我們來看看Redis是如何考慮的,筆者又畫了一張總覽簡圖:

從圖中我們基本上可以看到幾個主要部分:zlbytes、zltail、zllen、zlentry、zlend。


來解釋一下各個屬性的含義,借鑑網上一張非常好的圖,其中紅線驗證了我們的考慮點2、綠線驗證了我們的考慮點3:

 

 

來看下ziplist.c中對ziplist的申請和擴容操作,加深對上面幾個屬性的理解:

/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
    ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
    ZIPLIST_LENGTH(zl) = 0;
    zl[bytes-1] = ZIP_END;
    return zl;
}

/* Resize the ziplist. */
unsigned char *ziplistResize(unsigned char *zl, unsigned int len) {
    zl = zrealloc(zl,len);
    ZIPLIST_BYTES(zl) = intrev32ifbe(len);
    zl[len-1] = ZIP_END;
    return zl;
}

zlentry的實現

  • encoding編碼和content存儲

我們再來看看zlentry的實現,encoding的具體內容取決於content的類型和長度,其中當content是字符串時encoding的首字節的高2bit表示字符串類型,當content是整數時,encoding的首字節高2bit固定爲11,從Redis源碼的註釋中可以看的比較清楚,筆者對再做一層漢語版的註釋:

 /*
 ###########字符串存儲詳解###############
 #### encoding部分分爲三種類型:1字節、2字節、5字節 ####
 #### 最高2bit表示是哪種長度的字符串 分別是00 01 10 各自對應1字節 2字節 5字節 ####

 #### 當最高2bit=00時 表示encoding=1字節 剩餘6bit 2^6=64 可表示範圍0~63####
 #### 當最高2bit=01時 表示encoding=2字節 剩餘14bit 2^14=16384 可表示範圍0~16383####
 #### 當最高2bit=11時 表示encoding=5字節 比較特殊 用後4字節 剩餘32bit 2^32=42億多####
 * |00pppppp| - 1 byte
 *      String value with length less than or equal to 63 bytes (6 bits).
 *      "pppppp" represents the unsigned 6 bit length.
 * |01pppppp|qqqqqqqq| - 2 bytes
 *      String value with length less than or equal to 16383 bytes (14 bits).
 *      IMPORTANT: The 14 bit number is stored in big endian.
 * |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes
 *      String value with length greater than or equal to 16384 bytes.
 *      Only the 4 bytes following the first byte represents the length
 *      up to 32^2-1. The 6 lower bits of the first byte are not used and
 *      are set to zero.
 *      IMPORTANT: The 32 bit number is stored in big endian.

 *########################字符串存儲和整數存儲的分界線####################*
 *#### 高2bit固定爲11 其後2bit 分別爲00 01 10 11 表示存儲的整數類型
 * |11000000| - 3 bytes
 *      Integer encoded as int16_t (2 bytes).
 * |11010000| - 5 bytes
 *      Integer encoded as int32_t (4 bytes).
 * |11100000| - 9 bytes
 *      Integer encoded as int64_t (8 bytes).
 * |11110000| - 4 bytes
 *      Integer encoded as 24 bit signed (3 bytes).
 * |11111110| - 2 bytes
 *      Integer encoded as 8 bit signed (1 byte).
 * |1111xxxx| - (with xxxx between 0000 and 1101) immediate 4 bit integer.
 *      Unsigned integer from 0 to 12. The encoded value is actually from
 *      1 to 13 because 0000 and 1111 can not be used, so 1 should be
 *      subtracted from the encoded 4 bit value to obtain the right value.
 * |11111111| - End of ziplist special entry.
*/

content保存節點內容,其內容可以是字節數組和各種類型的整數,它的類型和長度決定了encoding的編碼,對照上面的註釋來看兩個例子吧:

保存字節數組:編碼的最高兩位00表示節點保存的是一個字節數組,編碼的後六位001011記錄了字節數組的長度11,content 屬性保存着節點的值 "hello world"。

保存整數:編碼爲11000000表示節點保存的是一個int16_t類型的整數值,content屬性保存着節點的值10086。

  • prevlen屬性

最後來說一下prevlen這個屬性,該屬性也比較關鍵,前面一直在說壓縮列表是爲了節約內存設計的,然而prevlen屬性就恰好起到了這個作用,回想一下鏈表要想獲取前面的節點需要使用指針實現,壓縮列表由於元素的多樣性也無法像數組一樣來實現,所以使用prevlen屬性記錄前一個節點的大小來進行指向。

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

 

  1. 如果前一節點的長度小於254字節,那麼prevlen屬性的長度爲1字節, 前一節點的長度就保存在這一個字節裏面。

  2. 如果前一節點的長度大於等於254字節,那麼prevlen屬性的長度爲5字節,第一字節會被設置爲0xFE,之後的四個字節則用於保存前一節點的長度。

 

思考:注意一下這裏的第一字節設置的是0xFE而不是0xFF,想下這是爲什麼呢?


沒錯!前面提到了zlend是個特殊值設置爲0xFF表示壓縮列表的結束,因此這裏不可以設置爲0xFF,關於這個問題在redis有個issue,有人提出來antirez的ziplist中的註釋寫的不對,最終antirez發現註釋寫錯了,然後愉快地修改了,哈哈!

 

再思考一個問題,爲什麼prevlen的長度要麼是1字節要麼是5字節呢?爲啥沒有2字節、3字節、4字節這些中間態的長度呢?要解答這個問題就引出了今天的一個關鍵問題:連鎖更新問題。

 

連鎖更新問題

試想這樣一種增加節點的場景:

如果在壓縮列表的頭部增加一個新節點,並且長度大於254字節,所以其後面節點的prevlen必須是5字節,然而在增加新節點之前其prevlen是1字節,必須進行擴展,極端情況下如果一直都需要擴展那麼將產生連鎖反應:

試想另外一種刪除節點的場景:

如果需要刪除的節點時小節點,該節點前面的節點是大節點,這樣當把小節點刪除時,其後面的節點就要保持其前面大節點的長度,面臨着擴展的問題:

理解了連鎖更新問題,再來看看爲什麼要麼1字節要麼5字節的問題吧,如果是2-4字節那麼可能產生連鎖反應的概率就更大了,相反直接給到最大5字節會大大降低連鎖更新的概率,所以筆者也認爲這種內存的小小浪費也是值得的。

 

從ziplist的設計來看,壓縮列表並不擅長修改操作,這樣會導致內存拷貝問題,並且當壓縮列表存儲的數據量超過某個閾值之後查找指定元素帶來的遍歷損耗也會增加。

0x09.談談Redis的Zset和跳躍鏈表問題

ZSet結構同時包含一個字典和一個跳躍表,跳躍表按score從小到大保存所有集合元素。字典保存着從member到score的映射。兩種結構通過指針共享相同元素的member和score,不浪費額外內存。

 

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

ZSet中的字典和跳錶佈局:

注:圖片源自網絡

 

9.1 ZSet中跳躍鏈表的實現細節

  • 隨機層數的實現原理

跳錶是一個概率型的數據結構,元素的插入層數是隨機指定的。Willam Pugh在論文中描述了它的計算過程如下:

  1. 指定節點最大層數 MaxLevel,指定概率 p, 默認層數 lvl 爲1 

  2. 生成一個0~1的隨機數r,若r<p,且lvl<MaxLevel ,則lvl ++

  3. 重複第 2 步,直至生成的r >p 爲止,此時的 lvl 就是要插入的層數。

 

論文中生成隨機層數的僞碼:

 

在Redis中對跳錶的實現基本上也是遵循這個思想的,只不過有微小差異,看下Redis關於跳錶層數的隨機源碼src/z_set.c:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
/* Returns a random level for the new skiplist node we are going to create. * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL * (both inclusive), with a powerlaw-alike distribution where higher * levels are less likely to be returned. */int zslRandomLevel(void) {    int level = 1;    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))        level += 1;    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;}

其中兩個宏的定義在redis.h中:

  •  
  •  
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

可以看到while中的:

  •  
(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)

第一眼看到這個公式,因爲涉及位運算有些詫異,需要研究一下Antirez爲什麼使用位運算來這麼寫?

最開始的猜測是random()返回的是浮點數[0-1],於是乎在線找了個浮點數轉二進制的工具,輸入0.25看了下結果:

可以看到0.25的32bit轉換16進制結果爲0x3e800000,如果與0xFFFF做與運算結果是0,好像也符合預期,再試一個0.5:

可以看到0.5的32bit轉換16進制結果爲0x3f000000,如果與0xFFFF做與運算結果還是0,不符合預期。

 

我印象中C語言的math庫好像並沒有直接random函數,所以就去Redis源碼中找找看,於是下載了3.2版本代碼,也並沒有找到random()的實現,不過找到了其他幾個地方的應用:

 

random()在dict.c中的使用

random()在cluster.c中的使用

看到這裏的取模運算,後知後覺地發現原以爲random()是個[0-1]的浮點數,但是現在看來是uint32纔對,這樣Antirez的式子就好理解了。

  •  
ZSKIPLIST_P*0xFFFF

由於ZSKIPLIST_P=0.25,所以相當於0xFFFF右移2位變爲0x3FFF,假設random()比較均勻,在進行0xFFFF高16位清零之後,低16位取值就落在0x0000-0xFFFF之間,這樣while爲真的概率只有1/4。更一般地說爲真的概率爲1/ZSKIPLIST_P。

 

對於隨機層數的實現並不統一,重要的是隨機數生成,LevelDB中對跳錶層數的生成代碼:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
template <typename Key, typename Value>int SkipList<Key, Value>::randomLevel() {
  static const unsigned int kBranching = 4;  int height = 1;  while (height < kMaxLevel && ((::Next(rnd_) % kBranching) == 0)) {    height++;  }  assert(height > 0);  assert(height <= kMaxLevel);  return height;}
uint32_t Next( uint32_t& seed) {  seed = seed & 0x7fffffffu;
  if (seed == 0 || seed == 2147483647L) {     seed = 1;  }  static const uint32_t M = 2147483647L;  static const uint64_t A = 16807;  uint64_t product = seed * A;  seed = static_cast<uint32_t>((product >> 31) + (product & M));  if (seed > M) {    seed -= M;  }  return seed;}

可以看到leveldb使用隨機數與kBranching取模,如果值爲0就增加一層,這樣雖然沒有使用浮點數,但是也實現了概率平衡。

 

  • 跳錶結點的平均層數

我們很容易看出,產生越高的節點層數出現概率越低,無論如何層數總是滿足冪次定律越大的數出現的概率越小。

如果某件事的發生頻率和它的某個屬性成冪關係,那麼這個頻率就可以稱之爲符合冪次定律。冪次定律的表現是少數幾個事件的發生頻率佔了整個發生頻率的大部分, 而其餘的大多數事件只佔整個發生頻率的一個小部分。

冪次定律應用到跳錶的隨機層數來說就是大部分的節點層數都是黃色部分,只有少數是綠色部分,並且概率很低。

 

定量的分析如下:

  1. 節點層數至少爲1,大於1的節點層數滿足一個概率分佈。

  2. 節點層數恰好等於1的概率爲p^0(1-p)。

  3. 節點層數恰好等於2的概率爲p^1(1-p)。

  4. 節點層數恰好等於3的概率爲p^2(1-p)。

  5. 節點層數恰好等於4的概率爲p^3(1-p)。

  6. 依次遞推節點層數恰好等於K的概率爲p^(k-1)(1-p)

 

如果要求節點的平均層數,那麼也就轉換成了求概率分佈的期望問題了,靈魂畫手大白再次上線:

表中P爲概率,V爲對應取值,給出了所有取值和概率的可能,因此就可以求這個概率分佈的期望了。

 

方括號裏面的式子其實就是高一年級學的等比數列,常用技巧錯位相減求和,從中可以看到結點層數的期望值與1-p成反比。對於Redis而言,當p=0.25時結點層數的期望是1.33。

 

在Redis源碼中有詳盡的關於插入和刪除調整跳錶的過程,本文就不展開了,代碼並不算難懂,都是純C寫的沒有那麼多炫技特效,大膽讀起來。

0x0A.談談集羣版Redis和Gossip協議

集羣版的Redis聽起來很高大上,確實相比單實例一主一從或者一主多從模式來說複雜了許多,互聯網的架構總是隨着業務的發展不斷演進的。

A.1 關於集羣的一些基礎

  • 單實例Redis架構

最開始的一主N從加上讀寫分離,Redis作爲緩存單實例貌似也還不錯,並且有Sentinel哨兵機制,可以實現主從故障遷移。

 

單實例一主兩從+讀寫分離結構:

注:圖片來自網絡

 

單實例的由於本質上只有一臺Master作爲存儲,就算機器爲128GB的內存,一般建議使用率也不要超過70%-80%,所以最多使用100GB數據就已經很多了,實際中50%就不錯了,以爲數據量太大也會降低服務的穩定性,因爲數據量太大意味着持久化成本高,可能嚴重阻塞服務,甚至最終切主。

 

如果單實例只作爲緩存使用,那麼除了在服務故障或者阻塞時會出現緩存擊穿問題,可能會有很多請求一起搞死MySQL。

 

如果單實例作爲主存,那麼問題就比較大了,因爲涉及到持久化問題,無論是bgsave還是aof都會造成刷盤阻塞,此時造成服務請求成功率下降,這個並不是單實例可以解決的,因爲由於作爲主存儲,持久化是必須的。

 

所以我們期待一個多主多從的Redis系統,這樣無論作爲主存還是作爲緩存,壓力和穩定性都會提升,儘管如此,筆者還是建議:Redis儘量不要做主存儲!

 

  • 集羣與分片

要支持集羣首先要克服的就是分片問題,也就是一致性哈希問題,常見的方案有三種:

客戶端分片:這種情況主要是類似於哈希取模的做法,當客戶端對服務端的數量完全掌握和控制時,可以簡單使用。

 

中間層分片:這種情況是在客戶端和服務器端之間增加中間層,充當管理者和調度者,客戶端的請求打向中間層,由中間層實現請求的轉發和回收,當然中間層最重要的作用是對多臺服務器的動態管理。

 

服務端分片:不使用中間層實現去中心化的管理模式,客戶端直接向服務器中任意結點請求,如果被請求的Node沒有所需數據,則像客戶端回覆MOVED,並告訴客戶端所需數據的存儲位置,這個過程實際上是客戶端和服務端共同配合,進行請求重定向來完成的。

 

  • 中間層分片的集羣版Redis

前面提到了變爲N主N從可以有效提高處理能力和穩定性,但是這樣就面臨一致性哈希的問題,也就是動態擴縮容時的數據問題。

 

在Redis官方發佈集羣版本之前,業內有一些方案迫不及待要用起自研版本的Redis集羣,其中包括國內豌豆莢的Codis、國外Twiter的twemproxy。

 

核心思想都是在多個Redis服務器和客戶端Client中間增加分片層,由分片層來完成數據的一致性哈希和分片問題,每一家的做法有一定的區別,但是要解決的核心問題都是多臺Redis場景下的擴縮容、故障轉移、數據完整性、數據一致性、請求處理延時等問題。

業內Codis配合LVS等多種做法實現Redis集羣的方案有很多都應用到生成環境中,表現都還不錯,主要是官方集羣版本在Redis3.0纔出現,對其穩定性如何,很多公司都不願做小白鼠,不過事實上經過迭代目前已經到了Redis5.x版本,官方集羣版本還是很不錯的,至少筆者這麼認爲。

 

  • 服務端分片的官方集羣版本

官方版本區別於上面的Codis和Twemproxy,實現了服務器層的Sharding分片技術,換句話說官方沒有中間層,而是多個服務結點本身實現了分片,當然也可以認爲實現sharding的這部分功能被融合到了Redis服務本身中,並沒有單獨的Sharding模塊。

 

之前的文章也提到了官方集羣引入slot的概念進行數據分片,之後將數據slot分配到多個Master結點,Master結點再配置N個從結點,從而組成了多實例sharding版本的官方集羣架構。

 

Redis Cluster 是一個可以在多個 Redis 節點之間進行數據共享的分佈式集羣,在服務端,通過節點之間的特殊協議進行通訊,這個特殊協議就充當了中間層的管理部分的通信協議,這個協議稱作Gossip流言協議。

 

分佈式系統一致性協議的目的就是爲了解決集羣中多結點狀態通知的問題,是管理集羣的基礎,如圖展示了基於Gossip協議的官方集羣架構圖:

 

注:圖片來自網絡

A.2 Redis Cluster的基本運行原理

  • 結點狀態信息結構

Cluster中的每個節點都維護一份在自己看來當前整個集羣的狀態,主要包括:

  1. 當前集羣狀態

  2. 集羣中各節點所負責的slots信息,及其migrate狀態

  3. 集羣中各節點的master-slave狀態

  4. 集羣中各節點的存活狀態及不可達投票

 

也就是說上面的信息,就是集羣中Node相互八卦傳播流言蜚語的內容主題,而且比較全面,既有自己的更有別人的,這麼一來大家都相互傳,最終信息就全面而且準確了,區別於拜占庭帝國問題,信息的可信度很高。

 

基於Gossip協議當集羣狀態變化時,如新節點加入、slot遷移、節點宕機、slave提升爲新Master,我們希望這些變化儘快的被發現,傳播到整個集羣的所有節點並達成一致。節點之間相互的心跳(PING,PONG,MEET)及其攜帶的數據是集羣狀態傳播最主要的途徑。

 

  • Gossip協議的概念

gossip 協議(gossip protocol)又稱 epidemic 協議(epidemic protocol),是基於流行病傳播方式的節點或者進程之間信息交換的協議。

在分佈式系統中被廣泛使用,比如我們可以使用 gossip 協議來確保網絡中所有節點的數據一樣。

gossip protocol 最初是由施樂公司帕洛阿爾託研究中心(Palo Alto Research Center)的研究員艾倫·德默斯(Alan Demers)於1987年創造的。

https://www.iteblog.com/archives/2505.html

Gossip協議已經是P2P網絡中比較成熟的協議了。Gossip協議的最大的好處是,即使集羣節點的數量增加,每個節點的負載也不會增加很多,幾乎是恆定的。這就允許Consul管理的集羣規模能橫向擴展到數千個節點。

Gossip算法又被稱爲反熵(Anti-Entropy),熵是物理學上的一個概念,代表雜亂無章,而反熵就是在雜亂無章中尋求一致,這充分說明了Gossip的特點:在一個有界網絡中,每個節點都隨機地與其他節點通信,經過一番雜亂無章的通信,最終所有節點的狀態都會達成一致。每個節點可能知道所有其他節點,也可能僅知道幾個鄰居節點,只要這些節可以通過網絡連通,最終他們的狀態都是一致的,當然這也是疫情傳播的特點。

https://www.backendcloud.cn/2017/11/12/raft-gossip/

上面的描述都比較學術,其實Gossip協議對於我們吃瓜羣衆來說一點也不陌生,Gossip協議也成爲流言協議,說白了就是八卦協議,這種傳播規模和傳播速度都是非常快的,你可以體會一下。所以計算機中的很多算法都是源自生活,而又高於生活的。

 

  • Gossip協議的使用

Redis 集羣是去中心化的,彼此之間狀態同步靠 gossip 協議通信,集羣的消息有以下幾種類型:

  1. Meet 通過「cluster meet ip port」命令,已有集羣的節點會向新的節點發送邀請,加入現有集羣。

  2. Ping  節點每秒會向集羣中其他節點發送 ping 消息,消息中帶有自己已知的兩個節點的地址、槽、狀態信息、最後一次通信時間等。

  3. Pong  節點收到 ping 消息後會回覆 pong 消息,消息中同樣帶有自己已知的兩個節點信息。

  4. Fail  節點 ping 不通某節點後,會向集羣所有節點廣播該節點掛掉的消息。其他節點收到消息後標記已下線。

 

由於去中心化和通信機制,Redis Cluster 選擇了最終一致性和基本可用。例如當加入新節點時(meet),只有邀請節點和被邀請節點知道這件事,其餘節點要等待 ping 消息一層一層擴散。

 

除了 Fail 是立即全網通知的,其他諸如新節點、節點重上線、從節點選舉成爲主節點、槽變化等,都需要等待被通知到,也就是Gossip協議是最終一致性的協議。

 

由於 gossip 協議對服務器時間的要求較高,否則時間戳不準確會影響節點判斷消息的有效性。另外節點數量增多後的網絡開銷也會對服務器產生壓力,同時結點數太多,意味着達到最終一致性的時間也相對變長,因此官方推薦最大節點數爲1000左右。

 

如圖展示了新加入結點服務器時的通信交互圖:

                                               注:圖片來自網絡

 

總起來說Redis官方集羣是一個去中心化的類P2P網絡,P2P早些年非常流行,像電驢、BT什麼的都是P2P網絡。

 

在Redis集羣中Gossip協議充當了去中心化的通信協議的角色,依據制定的通信規則來實現整個集羣的無中心管理節點的自治行爲。

 

  • 基於Gossip協議的故障檢測

集羣中的每個節點都會定期地向集羣中的其他節點發送PING消息,以此交換各個節點狀態信息,檢測各個節點狀態:在線狀態、疑似下線狀態PFAIL、已下線狀態FAIL。

 

自己保存信息:當主節點A通過消息得知主節點B認爲主節點D進入了疑似下線(PFAIL)狀態時,主節點A會在自己的clusterState.nodes字典中找到主節點D所對應的clusterNode結構,並將主節點B的下線報告添加到clusterNode結構的fail_reports鏈表中,並後續關於結點D疑似下線的狀態通過Gossip協議通知其他節點。

 

一起裁定:如果集羣裏面,半數以上的主節點都將主節點D報告爲疑似下線,那麼主節點D將被標記爲已下線(FAIL)狀態,將主節點D標記爲已下線的節點會向集羣廣播主節點D的FAIL消息,所有收到FAIL消息的節點都會立即更新nodes裏面主節點D狀態標記爲已下線。

 

最終裁定:將 node 標記爲 FAIL 需要滿足以下兩個條件:

  1. 有半數以上的主節點將 node 標記爲 PFAIL 狀態。

  2. 當前節點也將 node 標記爲 PFAIL 狀態。

 

也就是說當前節點發現其他結點疑似掛掉了,那麼就寫在自己的小本本上,等着通知給其他好基友,讓他們自己也看看,最後又一半以上的好基友都認爲那個節點掛了,並且那個節點自己也認爲自己掛了,那麼就是真的掛了,過程還是比較嚴謹的。

0x0B.談談對Redis的內存回收機制的理解

Redis作爲內存型數據庫,如果單純的只進不出早晚就撐爆了,事實上很多把Redis當做主存儲DB用的傢伙們早晚會嚐到這個苦果,當然除非你家廠子確實不差錢,數T級別的內存都毛毛雨,或者數據增長一定程度之後不再增長的場景,就另當別論了。

 

爲了讓Redis服務安全穩定的運行,讓使用內存保持在一定的閾值內是非常有必要的,因此我們就需要刪除該刪除的,清理該清理的,把內存留給需要的鍵值對,試想一條大河需要設置幾個警戒水位來確保不決堤不枯竭,Redis也是一樣的,只不過Redis只關心決堤即可,來一張圖:

圖中設定機器內存爲128GB,佔用64GB算是比較安全的水平,如果內存接近80%也就是100GB左右,那麼認爲Redis目前承載能力已經比較大了,具體的比例可以根據公司和個人的業務經驗來確定。

 

筆者只是想表達出於安全和穩定的考慮,不要覺得128GB的內存就意味着存儲128GB的數據,都是要打折的。

B.1 回收的內存從哪裏來

Redis佔用的內存是分爲兩部分:存儲鍵值對消耗和本身運行消耗。顯然後者我們無法回收,因此只能從鍵值對下手了,鍵值對可以分爲幾種:帶過期的、不帶過期的、熱點數據、冷數據。對於帶過期的鍵值是需要刪除的,如果刪除了所有的過期鍵值對之後內存仍然不足怎麼辦?那隻能把部分數據給踢掉了。

 

 

B.2 如何實施過期鍵值對的刪除

要實施對鍵值對的刪除我們需要明白如下幾點:

  • 帶過期超時的鍵值對存儲在哪裏

  • 如何判斷帶超時的鍵值對是否可以被刪除了?

  • 刪除機制有哪些以及如何選擇

1.鍵值對的存儲

老規矩來到github看下源碼,src/server.h中給的redisDb結構體給出了答案:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

Redis本質上就是一個大的key-value,key就是字符串,value有是幾種對象:字符串、列表、有序列表、集合、哈希等,這些key-value都是存儲在redisDb的dict中的,來看下黃健宏畫的一張非常讚的圖:

看到這裏,對於刪除機制又清晰了一步,我們只要把redisDb中dict中的目標key-value刪掉就行,不過貌似沒有這麼簡單,Redis對於過期鍵值對肯定有自己的組織規則,讓我們繼續研究吧!

redisDb的expires成員的類型也是dict,和鍵值對是一樣的,本質上expires是dict的子集,expires保存的是所有帶過期的鍵值對,稱之爲過期字典吧,它纔是我們研究的重點。

對於鍵,我們可以設置絕對和相對過期時間、以及查看剩餘時間:

  1. 使用EXPIRE和PEXPIRE來實現鍵值對的秒級和毫秒級生存時間設定,這是相對時長的過期設置

  2. 使用EXPIREAT和EXPIREAT來實現鍵值對在某個秒級和毫秒級時間戳時進行過期刪除,屬於絕對過期設置

  3. 通過TTL和PTTL來查看帶有生存時間的鍵值對的剩餘過期時間

上述三組命令在設計緩存時用處比較大,有心的讀者可以留意。

過期字典expires和鍵值對空間dict存儲的內容並不完全一樣,過期字典expires的key是指向Redis對應對象的指針,其value是long long型的unix時間戳,前面的EXPIRE和PEXPIRE相對時長最終也會轉換爲時間戳,來看下過期字典expires的結構,筆者畫了個圖:

2. 鍵值對的過期刪除判斷

判斷鍵是否過期可刪除,需要先查過期字典是否存在該值,如果存在則進一步判斷過期時間戳和當前時間戳的相對大小,做出刪除判斷,簡單的流程如圖:

 

3. 鍵值對的刪除策略

經過前面的幾個環節,我們知道了Redis的兩種存儲位置:鍵空間和過期字典,以及過期字典expires的結構、判斷是否過期的方法,那麼該如何實施刪除呢?

先拋開Redis來想一下可能的幾種刪除策略:

  • 定時刪除:在設置鍵的過期時間的同時,創建定時器,讓定時器在鍵過期時間到來時,即刻執行鍵值對的刪除;

  • 定期刪除:每隔特定的時間對數據庫進行一次掃描,檢測並刪除其中的過期鍵值對;

  • 惰性刪除:鍵值對過期暫時不進行刪除,至於刪除的時機與鍵值對的使用有關,當獲取鍵時先查看其是否過期,過期就刪除,否則就保留;

在上述的三種策略中定時刪除和定期刪除屬於不同時間粒度的主動刪除,惰性刪除屬於被動刪除。

三種策略都有各自的優缺點:定時刪除對內存使用率有優勢,但是對CPU不友好,惰性刪除對內存不友好,如果某些鍵值對一直不被使用,那麼會造成一定量的內存浪費,定期刪除是定時刪除和惰性刪除的折中。

Reids採用的是惰性刪除和定時刪除的結合,一般來說可以藉助最小堆來實現定時器,不過Redis的設計考慮到時間事件的有限種類和數量,使用了無序鏈表存儲時間事件,這樣如果在此基礎上實現定時刪除,就意味着O(N)遍歷獲取最近需要刪除的數據。

但是我覺得antirez如果非要使用定時刪除,那麼他肯定不會使用原來的無序鏈表機制,所以個人認爲已存在的無序鏈表不能作爲Redis不使用定時刪除的根本理由,冒昧猜測唯一可能的是antirez覺得沒有必要使用定時刪除。

4. 定期刪除的實現細節

定期刪除聽着很簡單,但是如何控制執行的頻率和時長呢?

試想一下如果執行頻率太少就退化爲惰性刪除了,如果執行時間太長又和定時刪除類似了,想想還確實是個難題!並且執行定期刪除的時機也需要考慮,所以我們繼續來看看Redis是如何實現定期刪除的吧!筆者在src/expire.c文件中找到了activeExpireCycle函數,定期刪除就是由此函數實現的,在代碼中antirez做了比較詳盡的註釋,不過都是英文的,試着讀了一下模模糊糊弄個大概,所以學習英文並閱讀外文資料是很重要的學習途徑。

先貼一下代碼,核心部分算上註釋大約210行,具體看下:

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
                                                   we do extra efforts. */

void activeExpireCycle(int type) {
    /* Adjust the running parameters according to the configured expire
     * effort. The default effort is 1, and the maximum configurable effort
     * is 10. */
    unsigned long
    effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                           ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4*effort,
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
                                 ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4*effort,
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                                  2*effort,
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-
                                    effort;

    /* This function has some global state in order to continue the work
     * incrementally across calls. */
    static unsigned int current_db = 0; /* Last DB tested. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */

    int j, iteration = 0;
    int dbs_per_call = CRON_DBS_PER_CALL;
    long long start = ustime(), timelimit, elapsed;

    /* When clients are paused the dataset should be static not just from the
     * POV of clients not being able to write, but also from the POV of
     * expires and evictions of keys not being performed. */
    if (clientsArePaused()) return;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exit
         * for time limit, unless the percentage of estimated stale keys is
         * too high. Also never repeat a fast cycle for the same period
         * as the fast cycle total duration itself. */
        if (!timelimit_exit &&
            server.stat_expired_stale_perc < config_cycle_acceptable_stale)
            return;

        if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2)
            return;

        last_fast_cycle = start;
    }

    /* We usually should test CRON_DBS_PER_CALL per iteration, with
     * two exceptions:
     *
     * 1) Don't test more DBs than we have.
     * 2) If last time we hit the time limit, we want to scan all DBs
     * in this iteration, as there is work to do in some DB and we don't want
     * expired keys to use memory for too much time. */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

    /* We can use at max 'config_cycle_slow_time_perc' percentage of CPU
     * time per iteration. Since this function gets called with a frequency of
     * server.hz times per second, the following is the max amount of
     * microseconds we can spend in this function. */
    timelimit = config_cycle_slow_time_perc*1000000/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = config_cycle_fast_duration; /* in microseconds. */

    /* Accumulate some global stats as we expire keys, to have some idea
     * about the number of keys that are already logically expired, but still
     * existing inside the database. */
    long total_sampled = 0;
    long total_expired = 0;

    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        /* Expired and checked in a single loop. */
        unsigned long expired, sampled;

        redisDb *db = server.db+(current_db % server.dbnum);

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs. */
        current_db++;

        /* Continue to expire if at the end of the cycle more than 25%
         * of the keys were expired. */
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* If there is nothing to expire try next DB ASAP. */
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            slots = dictSlots(db->expires);
            now = mstime();

            /* When there are less than 1% filled slots, sampling the key
             * space is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap. */
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /* The main collection cycle. Sample random keys among keys
             * with an expire set, checking for expired ones. */
            expired = 0;
            sampled = 0;
            ttl_sum = 0;
            ttl_samples = 0;

            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            /* Here we access the low level representation of the hash table
             * for speed concerns: this makes this code coupled with dict.c,
             * but it hardly changed in ten years.
             *
             * Note that certain places of the hash table may be empty,
             * so we want also a stop condition about the number of
             * buckets that we scanned. However scanning for free buckets
             * is very fast: we are in the cache line scanning a sequential
             * array of NULL pointers, so we can scan a lot more buckets
             * than keys in the same time. */
            long max_buckets = num*20;
            long checked_buckets = 0;

            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    if (table == 1 && !dictIsRehashing(db->expires)) break;

                    unsigned long idx = db->expires_cursor;
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    long long ttl;

                    /* Scan the current bucket of the current table. */
                    checked_buckets++;
                    while(de) {
                        /* Get the next entry now since this entry may get
                         * deleted. */
                        dictEntry *e = de;
                        de = de->next;

                        ttl = dictGetSignedIntegerVal(e)-now;
                        if (activeExpireCycleTryExpire(db,e,now)) expired++;
                        if (ttl > 0) {
                            /* We want the average TTL of keys yet
                             * not expired. */
                            ttl_sum += ttl;
                            ttl_samples++;
                        }
                        sampled++;
                    }
                }
                db->expires_cursor++;
            }
            total_expired += expired;
            total_sampled += sampled;

            /* Update the average TTL stats for this database. */
            if (ttl_samples) {
                long long avg_ttl = ttl_sum/ttl_samples;

                /* Do a simple running average with a few samples.
                 * We just use the current estimate with a weight of 2%
                 * and the previous estimate with a weight of 98%. */
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
            }

            /* We can't block forever here even if there are many keys to
             * expire. So after a given amount of milliseconds return to the
             * caller waiting for the other active expire cycle. */
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                elapsed = ustime()-start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
            /* We don't repeat the cycle for the current database if there are
             * an acceptable amount of stale keys (logically expired but yet
             * not reclained). */
        } while ((expired*100/sampled) > config_cycle_acceptable_stale);
    }

    elapsed = ustime()-start;
    server.stat_expire_cycle_time_used += elapsed;
    latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);

    /* Update our estimate of keys existing but yet to be expired.
     * Running average with this sample accounting for 5%. */
    double current_perc;
    if (total_sampled) {
        current_perc = (double)total_expired/total_sampled;
    } else
        current_perc = 0;
    server.stat_expired_stale_perc = (current_perc*0.05)+
                                     (server.stat_expired_stale_perc*0.95);
}

說實話這個代碼細節比較多,由於筆者對Redis源碼瞭解不多,只能做個模糊版本的解讀,所以難免有問題,還是建議有條件的讀者自行前往源碼區閱讀,拋磚引玉看下筆者的模糊版本:

  • 該算法是個自適應的過程,當過期的key比較少時那麼就花費很少的cpu時間來處理,如果過期的key很多就採用激進的方式來處理,避免大量的內存消耗,可以理解爲判斷過期鍵多就多跑幾次,少則少跑幾次;

  • 由於Redis中有很多數據庫db,該算法會逐個掃描,本次結束時繼續向後面的db掃描,是個閉環的過程

  • 定期刪除有快速循環和慢速循環兩種模式,主要採用慢速循環模式,其循環頻率主要取決於server.hz,通常設置爲10,也就是每秒執行10次慢循環定期刪除,執行過程中如果耗時超過25%的CPU時間就停止;

  • 慢速循環的執行時間相對較長,會出現超時問題,快速循環模式的執行時間不超過1ms,也就是執行時間更短,但是執行的次數更多,在執行過程中發現某個db中抽樣的key中過期key佔比低於25%則跳過;

主體意思:定期刪除是個自適應的閉環並且概率化的抽樣掃描過程,過程中都有執行時間和cpu時間的限制,如果觸發閾值就停止,可以說是儘量在不影響對客戶端的響應下潤物細無聲地進行的。

5. DEL刪除鍵值對

在Redis4.0之前執行del操作時如果key-value很大,那麼可能導致阻塞,在新版本中引入了BIO線程以及一些新的命令,實現了del的延時懶刪除,最後會有BIO線程來實現內存的清理回收。

B.2 內存淘汰機制

爲了保證Redis的安全穩定運行,設置了一個max-memory的閾值,那麼當內存用量到達閾值,新寫入的鍵值對無法寫入,此時就需要內存淘汰機制,在Redis的配置中有幾種淘汰策略可以選擇,詳細如下:

  • noeviction: 當內存不足以容納新寫入數據時,新寫入操作會報錯;

  • allkeys-lru:當內存不足以容納新寫入數據時,在鍵空間中移除最近最少使用的 key;

  • allkeys-random:當內存不足以容納新寫入數據時,在鍵空間中隨機移除某個 key;

  • volatile-lru:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,移除最近最少使用的 key;

  • volatile-random:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,隨機移除某個 key;

  • volatile-ttl:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,有更早過期時間的 key 優先移除;

後三種策略都是針對過期字典的處理,但是在過期字典爲空時會noeviction一樣返回寫入失敗,毫無策略地隨機刪除也不太可取,所以一般選擇第二種allkeys-lru基於LRU策略進行淘汰。

個人認爲antirez一向都是工程化思維,善於使用概率化設計來做近似實現,LRU算法也不例外,Redis中實現了近似LRU算法,並且經過幾個版本的迭代效果已經比較接近理論LRU算法的效果了,這個也是個不錯的內容,由於篇幅限制,本文計劃後續單獨講LRU算法時再進行詳細討論。

過期健刪除策略強調的是對過期健的操作,如果有健過期而內存足夠,Redis不會使用內存淘汰機制來騰退空間,這時會優先使用過期健刪除策略刪除過期健。

內存淘汰機制強調的是對內存數據的淘汰操作,當內存不足時,即使有的健沒有到達過期時間或者根本沒有設置過期也要根據一定的策略來刪除一部分,騰退空間保證新數據的寫入。

0x0C.談談對Redis數據同步機制和原理的理解

理解持久化和數據同步的關係,需要從單點故障和高可用兩個角度來分析:

C.1 單點宕機故障

假如我們現在只有一臺作爲緩存的Redis機器,通過持久化將熱點數據寫到磁盤,某時刻該Redis單點機器發生故障宕機,此期間緩存失效,主存儲服務將承受所有的請求壓力倍增,監控程序將宕機Redis機器拉起。

重啓之後,該機器可以Load磁盤RDB數據進行快速恢復,恢復的時間取決於數據量的多少,一般秒級到分鐘級不等,恢復完成保證之前的熱點數據還在,這樣存儲系統的CacheMiss就會降低,有效降低了緩存擊穿的影響。

在單點Redis中持久化機制非常有用,只寫文字容易讓大家睡着,我畫了張圖:

作爲一個高可用的緩存系統單點宕機是不允許的,因此就出現了主從架構,對主節點的數據進行多個備份,如果主節點掛點,可以立刻切換狀態最好的從節點爲主節點,對外提供寫服務,並且其他從節點向新主節點同步數據,確保整個Redis緩存系統的高可用。

如圖展示了一個一主兩從讀寫分離的Redis系統主節點故障遷移的過程,整個過程並沒有停止正常工作,大大提高了系統的高可用:


 

從上面的兩點分析可以得出個小結論【劃重點】:
持久化讓單點故障不再可怕,數據同步爲高可用插上翅膀。

我們理解了數據同步對Redis的重要作用,接下來繼續看數據同步的實現原理和過程、重難點等細節問題吧!

C.2 Redis系統中的CAP理論

對分佈式存儲有了解的讀者一定知道CAP理論,說來慚愧筆者在2018年3月份換工作的時候,去Face++曠視科技面後端開發崗位時就遇到了CAP理論,除了CAP理論問題之外其他問題都在射程內,所以最終還是拿了Offer。

在理論計算機科學中,CAP定理又被稱作布魯爾定理Brewer's theorem,這個定理起源於加州大學伯克利分校的計算機科學家埃裏克·布魯爾在2000年的分佈式計算原理研討會PODC上提出的一個猜想。

在2002年麻省理工學院的賽斯·吉爾伯特和南希·林奇發表了布魯爾猜想的證明,使之成爲一個定理。它指出對於一個分佈式計算系統來說,不可能同時滿足以下三點:

  • C Consistent 一致性 連貫性

  • A Availability 可用性

  • P Partition Tolerance 分區容忍性

來看一張阮一峯大佬畫的圖:

舉個簡單的例子,說明一下CP和AP的兼容性:
理解CP和AP的關鍵在於分區容忍性P,網絡分區在分佈式存儲中再平常不過了,即使機器在一個機房,也不可能全都在一個機架或一臺交換機。

 

這樣在局域網就會出現網絡抖動,筆者做過1年多DPI對於網絡傳輸中最深刻的三個名詞:丟包、亂序、重傳。所以我們看來風平浪靜的網絡,在服務器來說可能是風大浪急,一不小心就不通了,所以當網絡出現斷開時,這時就出現了網絡分區問題。

 

對於Redis數據同步而言,假設從結點和主結點在兩個機架上,某時刻發生網絡斷開,如果此時Redis讀寫分離,那麼從結點的數據必然無法與主繼續同步數據。在這種情況下,如果繼續在從結點讀取數據就造成數據不一致問題,如果強制保證數據一致從結點就無法提供服務造成不可用問題,從而看出在P的影響下C和A無法兼顧。

其他幾種情況就不深入了,從上面我們可以得出結論:當Redis多臺機器分佈在不同的網絡中,如果出現網絡故障,那麼數據一致性和服務可用性無法兼顧,Redis系統對此必須做出選擇,事實上Redis選擇了可用性,或者說Redis選擇了另外一種最終一致性。

C.3 Redis的最終一致性和複製

Redis選擇了最終一致性,也就是不保證主從數據在任何時刻都是一致的,並且Redis主從同步默認是異步的,親愛的盆友們不要暈!不要蒙圈!

我來一下解釋同步複製和異步複製(注意:考慮讀者的感受 我並沒有寫成同步同步和異步同步 哈哈):


 

一圖勝千言,看紅色的數字就知道同步複製和異步複製的區別了:

  • 異步複製:當客戶端向主結點寫了hello world,主節點寫成功之後就向客戶端回覆OK,這樣主節點和客戶端的交互就完成了,之後主節點向從結點同步hello world,從結點完成之後向主節點回復OK,整個過程客戶端不需要等待從結點同步完成,因此整個過程是異步實現的。

  • 同步複製:當客戶端向主結點寫了hello world,主節點向從結點同步hello world,從結點完成之後向主節點回復OK,之後主節點向客戶端回覆OK,整個過程客戶端需要等待從結點同步完成,因此整個過程是同步實現的。

Redis選擇異步複製可以避免客戶端的等待,更符合現實要求,不過這個複製方式可以修改,根據自己需求而定吧。

1.從從複製
假如Redis高可用系統中有一主四從,如果四個從同時向主節點進行數據同步,主節點的壓力會比較大,考慮到Redis的最終一致性,因此Redis後續推出了從從複製,從而將單層複製結構演進爲多層複制結構,筆者畫了個圖看下:

2.全量複製和增量複製

全量複製是從結點因爲故障恢復或者新添加從結點時出現的初始化階段的數據複製,這種複製是將主節點的數據全部同步到從結點來完成的,所以成本大但又不可避免。

增量複製是主從結點正常工作之後的每個時刻進行的數據複製方式,涓涓細流同步數據,這種同步方式又輕又快,優點確實不少,不過如果沒有全量複製打下基礎增量複製也沒戲,所以二者不是矛盾存在而是相互依存的。

3.全量複製過程分析

Redis的全量複製過程主要分三個階段

  • 快照階段:從結點向主結點發起SYNC全量複製命令,主節點執行bgsave將內存中全部數據生成快照併發送給從結點,從結點釋放舊內存載入並解析新快照,主節點同時將此階段所產生的新的寫命令存儲到緩衝區。

  • 緩衝階段:主節點向從節點同步存儲在緩衝區的操作命令,這部分命令主節點是bgsave之後到從結點載入快照這個時間段內的新增命令,需要記錄要不然就出現數據丟失。

  • 增量階段:緩衝區同步完成之後,主節點正常向從結點同步增量操作命令,至此主從保持基本一致的步調。

借鑑參考1的一張圖表,寫的很好:

考慮一個多從併發全量複製問題
如果此時有多個從結點同時向主結點發起全量同步請求會怎樣?

Redis主結點是個聰明又誠實的傢伙,比如現在有3個從結點A/B/C陸續向主節點發起SYNC全量同步請求。

  • 主節點在對A進行bgsave的同時,B和C的SYNC命令到來了,那麼主節點就一鍋燴,把針對A的快照數據和緩衝區數據同時同步給ABC,這樣提高了效率又保證了正確性。

  • 主節點對A的快照已經完成並且現在正在進行緩衝區同步,那麼只能等A完成之後,再對B和C進行和A一樣的操作過程,來實現新節點的全量同步,所以主節點並沒有偷懶而是重複了這個過程,雖然繁瑣但是保證了正確性。

再考慮一個快照複製循環問題
主節點執行bgsave是比較耗時且耗內存的操作,期間從結點也經歷裝載舊數據->釋放內存->裝載新數據的過程,內存先升後降再升的動態過程,從而知道無論主節點執行快照還是從結點裝載數據都是需要時間和資源的。

拋開對性能的影響,試想如果主節點快照時間是1分鐘,在期間有1w條新命令到來,這些新命令都將寫到緩衝區,如果緩衝區比較小隻有8k,那麼在快照完成之後,主節點緩衝區也只有8k命令丟失了2k命令,那麼此時從結點進行全量同步就缺失了數據,是一次錯誤的全量同步。

無奈之下,從結點會再次發起SYNC命令,從而陷入循環,因此緩衝區大小的設置很重要,二話不說再來一張圖:

4.增量複製過程分析

增量複製過程稍微簡單一些,但是非常有用,試想複雜的網絡環境下,並不是每次斷開都無法恢復,如果每次斷開恢復後就要進行全量複製,那豈不是要把主節點搞死,所以增量複製算是對複雜網絡環境下數據複製過程的一個優化,允許一段時間的落後,最終追上就行。

增量複製是個典型的生產者-消費者模型,使用定長環形數組(隊列)來實現,如果buffer滿了那麼新數據將覆蓋老數據,因此從結點在複製數據的同時向主節點反饋自己的偏移量,從而確保數據不缺失。

這個過程非常好理解,kakfa這種MQ也是這樣的,所以在合理設置buffer大小的前提下,理論上從的消費能力是大於主的生產能力的,大部分只有在網絡斷開時間過長時會出現buffer被覆蓋,從結點消費滯後的情況,此時只能進行全量複製了。

5.無盤複製

理解無盤複製之前先看下什麼是有盤複製呢?

所謂盤是指磁盤,可能是機械磁盤或者SSD,但是無論哪一種相比內存都更慢,我們都知道IO操作在服務端的耗時是佔大頭的,因此對於全量複製這種高IO耗時的操作來說,尤其當服務併發比較大且還在進行其他操作時對Redis服務本身的影響是比較大大,之前的模式時這樣的:

在Redis2.8.18版本之後,開發了無盤複製,也就是避免了生成的RDB文件落盤再加載再網絡傳輸的過程,而是流式的遍歷發送過程,主節點一邊遍歷內存數據,一邊將數據序列化發送給從結點,從結點沒有變化,仍然將數據依次存儲到本地磁盤,完成傳輸之後進行內存加載,可見無盤複製是對IO更友好

0x0D.談談基於Redis的分佈式鎖和Redlock算法

D.1 基於Redis的分佈式鎖簡介

最初分佈式鎖藉助於setnx和expire命令,但是這兩個命令不是原子操作,如果執行setnx之後獲取鎖但是此時客戶端掛掉,這樣無法執行expire設置過期時間就導致鎖一直無法被釋放,因此在2.8版本中Antirez爲setnx增加了參數擴展,使得setnx和expire具備原子操作性。

在單Matster-Slave的Redis系統中,正常情況下Client向Master獲取鎖之後同步給Slave,如果Client獲取鎖成功之後Master節點掛掉,並且未將該鎖同步到Slave,之後在Sentinel的幫助下Slave升級爲Master但是並沒有之前未同步的鎖的信息,此時如果有新的Client要在新Master獲取鎖,那麼將可能出現兩個Client持有同一把鎖的問題,來看個圖來想下這個過程:

爲了保證自己的鎖只能自己釋放需要增加唯一性的校驗,綜上基於單Redis節點的獲取鎖和釋放鎖的簡單過程如下:

// 獲取鎖 unique_value作爲唯一性的校驗
SET resource_name unique_value NX PX 30000

// 釋放鎖 比較unique_value是否相等 避免誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

這就是基於單Redis的分佈式鎖的幾個要點。

D.2 Redlock算法基本過程

Redlock算法是Antirez在單Redis節點基礎上引入的高可用模式。在Redis的分佈式環境中,我們假設有N個完全互相獨立的Redis節點,在N個Redis實例上使用與在Redis單實例下相同方法獲取鎖和釋放鎖。

現在假設有5個Redis主節點(大於3的奇數個),這樣基本保證他們不會同時都宕掉,獲取鎖和釋放鎖的過程中,客戶端會執行以下操作:

  • 獲取當前Unix時間,以毫秒爲單位

  • 依次嘗試從5個實例,使用相同的key和具有唯一性的value獲取鎖
    當向Redis請求獲取鎖時,客戶端應該設置一個網絡連接和響應超時時間,這個超時時間應該小於鎖的失效時間,這樣可以避免客戶端死等

  • 客戶端使用當前時間減去開始獲取鎖時間就得到獲取鎖使用的時間。當且僅當從半數以上的Redis節點取到鎖,並且使用的時間小於鎖失效時間時,鎖纔算獲取成功

  • 如果取到了鎖,key的真正有效時間等於有效時間減去獲取鎖所使用的時間,這個很重要

  • 如果因爲某些原因,獲取鎖失敗(沒有在半數以上實例取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖,無論Redis實例是否加鎖成功,因爲可能服務端響應消息丟失了但是實際成功了,畢竟多釋放一次也不會有問題

上述的5個步驟是Redlock算法的重要過程,也是面試的熱點,有心的讀者還是記錄一下吧!

D.3 Redlock算法是否安全的爭論

1.關於馬丁·克萊普曼博士

2016年2月8號分佈式系統的專家馬丁·克萊普曼博士(Martin Kleppmann)在一篇文章How to do distributed locking 指出分佈式鎖設計的一些原則並且對Antirez的Redlock算法提出了一些質疑。筆者找到了馬丁·克萊普曼博士的個人網站以及一些簡介,一起看下:

搜狗翻譯看一下:

1.我是劍橋大學計算機科學與技術系的高級研究助理和附屬講師,由勒弗烏爾姆信託早期職業獎學金和艾薩克牛頓信託基金資助。我致力於本地優先的協作軟件和分佈式系統安全
2.我也是劍橋科珀斯克里斯蒂學院計算機科學研究的研究員和主任,我在那裏從事本科教學。
3.2017年,我爲奧雷利出版了一本名爲《設計數據密集型應用》的書。它涵蓋了廣泛的數據庫和分佈式數據處理系統的體系結構,是該出版社最暢銷書之一。
4.我經常在會議上發言,我的演講錄音已經被觀看了超過15萬次。
5.我參與過各種開源項目,包括自動合併、Apache Avro和Apache Samza。
6.2007年至2014年間,我是一名工業軟件工程師和企業家。我共同創立了Rapportive(2012年被領英收購)和Go Test(2009年被紅門軟件收購)。
7.我創作了幾部音樂作品,包括《二月之死》(德語),這是唐克·德拉克特對該書的音樂戲劇改編,於2007年首映,共有150人蔘與。

大牛就是大牛,能教書、能出書、能寫開源軟件、能創業、能寫音樂劇,優秀的人哪方面也優秀,服氣了。

2.馬丁博士文章的主要觀點

馬丁·克萊普曼在文章中談及了分佈式系統的很多基礎問題,特別是分佈式計算的異步模型,文章分爲兩大部分前半部分講述分佈式鎖的一些原則,後半部分針對Redlock提出一些看法:

  • Martin指出即使我們擁有一個完美實現的分佈式鎖,在沒有共享資源參與進來提供某種fencing柵欄機制的前提下,我們仍然不可能獲得足夠的安全性

  • Martin指出,由於Redlock本質上是建立在一個同步模型之上,對系統的時間有很強的要求,本身的安全性是不夠的

針對fencing機制馬丁給出了一個時序圖

獲取鎖的客戶端在持有鎖時可能會暫停一段較長的時間,儘管鎖有一個超時時間,避免了崩潰的客戶端可能永遠持有鎖並且永遠不會釋放它,但是如果客戶端的暫停持續的時間長於鎖的到期時間,並且客戶沒有意識到它已經到期,那麼它可能會繼續進行一些不安全的更改,換言之由於客戶端阻塞導致的持有的鎖到期而不自知

針對這種情況馬丁指出要增加fencing機制,具體來說是fencing token隔離令牌機制,同樣給出了一張時序圖:

 

客戶端1獲得鎖並且獲得序號爲33的令牌,但隨後它進入長時間暫停,直至鎖超時過期,客戶端2獲取鎖並且獲得序號爲34的令牌,然後將其寫入發送到存儲服務。隨後,客戶端1復活並將其寫入發送到存儲服務,然而存儲服務器記得它已經處理了具有較高令牌號的寫入34,因此它拒絕令牌33的請求

Redlock算法並沒有這種唯一且遞增的fencing token生成機制,這也意味着Redlock算法不能避免由於客戶端阻塞帶來的鎖過期後的操作問題,因此是不安全的。

這個觀點筆者覺得並沒有徹底解決問題,因爲如果客戶端1的寫入操作是必須要執行成功的,但是由於阻塞超時無法再寫入同樣就產生了一個錯誤的結果,客戶端2將可能在這個錯誤的結果上進行操作,那麼任何操作都註定是錯誤的

3.馬丁博士對Redlock的質疑

馬丁·克萊普曼指出Redlock是個強依賴系統時間的算法,這樣就可能帶來很多不一致問題,他給出了個例子一起看下:

假設多節點Redis系統有五個節點A/B/C/D/E和兩個客戶端C1和C2,如果其中一個Redis節點上的時鐘向前跳躍會發生什麼?

  • 客戶端C1獲得了對節點A、B、c的鎖定,由於網絡問題,法到達節點D和節點E

  • 節點C上的時鐘向前跳,導致鎖提前過期

  • 客戶端C2在節點C、D、E上獲得鎖定,由於網絡問題,無法到達A和B

  • 客戶端C1和客戶端C2現在都認爲他們自己持有鎖

分佈式異步模型:
上面這種情況之所以有可能發生,本質上是因爲Redlock的安全性對Redis節點系統時鐘有強依賴,一旦系統時鐘變得不準確,算法的安全性也就無法保證。

馬丁其實是要指出分佈式算法研究中的一些基礎性問題,好的分佈式算法應該基於異步模型,算法的安全性不應該依賴於任何記時假設

分佈式異步模型中進程和消息可能會延遲任意長的時間,系統時鐘也可能以任意方式出錯。這些因素不應該影響它的安全性,只可能影響到它的活性,即使在非常極端的情況下,算法最多是不能在有限的時間內給出結果,而不應該給出錯誤的結果,這樣的算法在現實中是存在的比如Paxos/Raft,按這個標準衡量Redlock的安全級別是達不到的。

4.馬丁博士文章結論和基本觀點

馬丁表達了自己的觀點,把鎖的用途分爲兩種:

  • 效率第一
    使用分佈式鎖只是爲了協調多個客戶端的一些簡單工作,鎖偶爾失效也會產生其它的不良後果,就像你收發兩份相同的郵件一樣,無傷大雅

  • 正確第一
    使用分佈式鎖要求在任何情況下都不允許鎖失效的情況發生,一旦發生失效就可能意味着數據不一致、數據丟失、文件損壞或者其它嚴重的問題,就像給患者服用重複劑量的藥物一樣,後果嚴重

最後馬丁出了如下的結論:

  • 爲了效率而使用分佈式鎖
    單Redis節點的鎖方案就足夠了Redlock則是個過重而昂貴的設計

  • 爲了正確而使用分佈式鎖
    Redlock不是建立在異步模型上的一個足夠強的算法,它對於系統模型的假設中包含很多危險的成分

馬丁認爲Redlock算法是個糟糕的選擇,因爲它不倫不類:出於效率選擇來說,它過於重量級和昂貴,出於正確性選擇它又不夠安全。

5.Antirez的反擊

馬丁的那篇文章是在2016.2.8發表之後Antirez反應很快,他發表了"Is Redlock safe?"進行逐一反駁,文章地址如下:

 

http://antirez.com/news/101

Antirez認爲馬丁的文章對於Redlock的批評可以概括爲兩個方面:

  • 帶有自動過期功能的分佈式鎖,必須提供某種fencing柵欄機制來保證對共享資源的真正互斥保護,Redlock算法提供不了這樣一種機制

  • Redlock算法構建在一個不夠安全的系統模型之上,它對於系統的記時假設有比較強的要求,而這些要求在現實的系統中是無法保證的

Antirez對這兩方面分別進行了細緻地反駁。

關於fencing機制

Antirez提出了質疑:既然在鎖失效的情況下已經存在一種fencing機制能繼續保持資源的互斥訪問了,那爲什麼還要使用一個分佈式鎖並且還要求它提供那麼強的安全性保證呢?

退一步講Redlock雖然提供不了遞增的fencing token隔離令牌,但利用Redlock產生的隨機字符串可以達到同樣的效果,這個隨機字符串雖然不是遞增的,但卻是唯一的。

關於記時假設

Antirez針對算法在記時模型假設集中反駁,馬丁認爲Redlock失效情況主要有三種:

  • 1.時鐘發生跳躍

  • 2.長時間的GC pause

  • 3.長時間的網絡延遲

後兩種情況來說,Redlock在當初之處進行了相關設計和考量,對這兩種問題引起的後果有一定的抵抗力。
時鐘跳躍對於Redlock影響較大,這種情況一旦發生Redlock是沒法正常工作的。
Antirez指出Redlock對系統時鐘的要求並不需要完全精確,只要誤差不超過一定範圍不會產生影響,在實際環境中是完全合理的,通過恰當的運維完全可以避免時鐘發生大的跳動

6.馬丁的總結和思考

分佈式系統本身就很複雜,機制和理論的效果需要一定的數學推導作爲依據,馬丁和Antirez都是這個領域的專家,對於一些問題都會有自己的看法和思考,更重要的是很多時候問題本身並沒有完美的解決方案

這次爭論是布式系統領域非常好的一次思想的碰撞,很多網友都發表了自己的看法和認識,馬丁博士也在Antirez做出反應一段時間之後再次發表了自己的一些觀點:

 

For me, this is the most important point: I don’t care who is right or wrong in this debate — I care about learning from others’ work, so that we can avoid repeating old mistakes, and make things better in future. So much great work has already been done for us: by standing on the shoulders of giants, we can build better software.

 

By all means, test ideas by arguing them and checking whether they stand up to scrutiny by others. That’s part of the learning process. But the goal should be to learn, not to convince others that you are right. Sometimes that just means to stop and think for a while.

簡單翻譯下就是:
對馬丁而言並不在乎誰對誰錯,他更關心於從他人的工作中汲取經驗來避免自己的錯誤重複工作,正如我們是站在巨人的肩膀上才能做出更好的成績。

另外通過別人的爭論和檢驗才更能讓自己的想法經得起考驗,我們的目標是相互學習而不是說服別人相信你是對的,所謂一人計短思考辯駁才能更加接近真理

在Antirez發表文章之後世界各地的分佈式系統專家和愛好者都積極發表自己的看法,筆者在評論中發現了一個熟悉的名字:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章