3w字深度好文|Redis面試全攻略,讀完這個就能夠和麪試官大戰幾個回合了

0x00.前言

衆所周知數據結構和算法是面試重點,咱們持續發力是十分明智的,要否則最後確定是要吃虧的,少打打遊戲刷刷微博能夠改變咱們的生活水平哦。html

                  

不過本文不是要講述數據結構和算法的,而是另一個面試重點Redis,由於Redis也是跨語言的共同技術點,不管是Java仍是C++都會問到,因此是個高頻面試點。node

筆者是2017年纔開始接觸Redis的,期間本身搭過單機版和集羣版,不過如今公司大一些都徹底是運維來實現的,咱們使用者只須要在web頁面進行相關申請便可,不少細節都被屏蔽了,這樣固然很方便啦,不過咱們仍是要深刻理解一下的。git

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

以前筆者寫過幾篇左右Redis的文章,可是知識點都分散着不利於閱讀,因此本次就把以前的文章進行彙總補充,來造成一個全一些的集合,但願對關注個人讀者有所幫助就足夠啦。web

文中列出來的考點較多而且累計達3w+字 ,所以建議讀者收藏,以備不時之需,經過本文你將瞭解到如下內容面試

  • Redis的做者和發展簡史
  • Redis經常使用數據結構及其實現
  • Redis的SDS和C中字符串的原理和對比
  • Redis有序集合ZSet的底層設計和實現
  • Redis有序集合ZSet和跳躍鏈表問題
  • Redis字典的實現及漸進式Rehash過程
  • Redis單線程運行模式的基本原理和流程
  • Redis反應堆模式的原理和設計實現
  • Redis持久化方案及其基本原理
  • 集羣版Redis和Gossip協議
  • Redis內存回收機制和基本原理
  • Redis數據同步機制和基本原理

話很少說,時速400千米的大白號 開始加速!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事件與命令回覆處理器相關聯,當客戶端嘗試讀取結果時產生可寫事件,此時服務器端觸發命令回覆響應,並將數據結果寫入套接字,完成以後服務端接觸該套接字與命令回覆處理器之間的關聯;

                  

x07. 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-cli
127.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     # 第一個參數長度爲 6
SELECT     # 第一個參數
$1     # 第二參數長度爲 1
8     # 第二參數
*3     # 3個參數
$3     # 第一個參數長度爲 4
SET     # 第一個參數
$4     # 第二參數長度爲 4
name     # 第二個參數
$4     # 第三個參數長度爲 4
Jhon     # 第二參數長度爲 4
複製代碼

AOF配置:

[redis@abc]$ more ~/redis/conf/redis.conf
dir "/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。

來解釋一下各個屬性的含義,借鑑網上一張很是好的圖,其中紅線驗證了咱們的考慮點二、綠線驗證了咱們的考慮點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算法是個糟糕的選擇,由於它不三不四:出於效率選擇來講,它過於重量級和昂貴,出於正確性選擇它又不夠安全。

馬丁的那篇文章是在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對系統時鐘的要求並不須要徹底精確,只要偏差不超過必定範圍不會產生影響,在實際環境中是徹底合理的,經過恰當的運維徹底能夠避免時鐘發生大的跳動

分佈式系統自己就很複雜,機制和理論的效果須要必定的數學推導做爲依據,馬丁和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發表文章以後世界各地的分佈式系統專家和愛好者都積極發表本身的見解,筆者在評論中發現了一個熟悉的名字:

0xFF.關於我

相關文章
相關標籤/搜索