memcached與redis實現的對比

騰雲閣發表於2016-10-31

memcached和redis,作為近些年最常用的快取伺服器,相信大家對它們再熟悉不過了。前兩年還在學校時,我曾經讀過它們的主要原始碼,如今寫篇筆記從個人角度簡單對比一下它們的實現方式,權當做複習,有理解錯誤之處,歡迎指正。

文中使用的架構類的圖片大多來自於網路,有部分圖與最新實現有出入,文中已經指出。

一. 綜述

讀一個軟體的原始碼,首先要弄懂軟體是用作幹什麼的,那memcached和redis是幹啥的?眾所周知,資料一般會放在資料庫中,但是查詢資料會相對比較慢,特別是使用者很多時,頻繁的查詢,需要耗費大量的時間。怎麼辦呢?資料放在哪裡查詢快?那肯定是記憶體中。memcached和redis就是將資料儲存在記憶體中,按照key-value的方式查詢,可以大幅度提高效率。所以一般它們都用做快取伺服器,快取常用的資料,需要查詢的時候,直接從它們那兒獲取,減少查詢資料庫的次數,提高查詢效率。

二. 服務方式

memcached和redis怎麼提供服務呢?它們是獨立的程式,需要的話,還可以讓他們變成daemon程式,所以我們的使用者程式要使用memcached和redis的服務的話,就需要程式間通訊了。考慮到使用者程式和memcached和redis不一定在同一臺機器上,所以還需要支援網路間通訊。因此,memcached和redis自己本身就是網路伺服器,使用者程式通過與他們通過網路來傳輸資料,顯然最簡單和最常用的就是使用tcp連線了。另外,memcached和redis都支援udp協議。而且當使用者程式和memcached和redis在同一機器時,還可以使用unix域套接字通訊。

三. 事件模型

下面開始講他們具體是怎麼實現的了。首先來看一下它們的事件模型。

自從epoll出來以後,幾乎所有的網路伺服器全都拋棄select和poll,換成了epoll。redis也一樣,只不多它還提供對select和poll的支援,可以自己配置使用哪一個,但是一般都是用epoll。另外針對BSD,還支援使用kqueue。而memcached是基於libevent的,不過libevent底層也是使用epoll的,所以可以認為它們都是使用epoll。epoll的特性這裡就不介紹了,網上介紹文章很多。

它們都使用epoll來做事件迴圈,不過redis是單執行緒的伺服器(redis也是多執行緒的,只不過除了主執行緒以外,其他執行緒沒有event loop,只是會進行一些後臺儲存工作),而memcached是多執行緒的。 redis的事件模型很簡單,只有一個event loop,是簡單的reactor實現。不過redis事件模型中有一個亮點,我們知道epoll是針對fd的,它返回的就緒事件也是隻有fd,redis裡面的fd就是伺服器與客戶端連線的socket的fd,但是處理的時候,需要根據這個fd找到具體的客戶端的資訊,怎麼找呢?通常的處理方式就是用紅黑樹將fd與客戶端資訊儲存起來,通過fd查詢,效率是lgn。不過redis比較特殊,redis的客戶端的數量上限可以設定,即可以知道同一時刻,redis所開啟的fd的上限,而我們知道,程式的fd在同一時刻是不會重複的(fd只有關閉後才能複用),所以redis使用一個陣列,將fd作為陣列的下標,陣列的元素就是客戶端的資訊,這樣,直接通過fd就能定位客戶端資訊,查詢效率是O(1),還省去了複雜的紅黑樹的實現(我曾經用c寫一個網路伺服器,就因為要保持fd和connect對應關係,不想自己寫紅黑樹,然後用了STL裡面的set,導致專案變成了c++的,最後專案使用g++編譯,這事我不說誰知道?)。顯然這種方式只能針對connection數量上限已確定,並且不是太大的網路伺服器,像nginx這種http伺服器就不適用,nginx就是自己寫了紅黑樹。

而memcached是多執行緒的,使用master-worker的方式,主執行緒監聽埠,建立連線,然後順序分配給各個工作執行緒。每一個從執行緒都有一個event loop,它們服務不同的客戶端。master執行緒和worker執行緒之間使用管道通訊,每一個工作執行緒都會建立一個管道,然後儲存寫端和讀端,並且將讀端加入event loop,監聽可讀事件。同時,每個從執行緒都有一個就緒連線佇列,主執行緒連線連線後,將連線的item放入這個佇列,然後往該執行緒的管道的寫端寫入一個connect命令,這樣event loop中加入的管道讀端就會就緒,從執行緒讀取命令,解析命令發現是有連線,然後就會去自己的就緒佇列中獲取連線,並進行處理。多執行緒的優勢就是可以充分發揮多核的優勢,不過編寫程式麻煩一點,memcached裡面就有各種鎖和條件變數來進行執行緒同步。

四. 記憶體分配

memcached和redis的核心任務都是在記憶體中運算元據,記憶體管理自然是核心的內容。

首先看看他們的記憶體分配方式。memcached是有自己得記憶體池的,即預先分配一大塊記憶體,然後接下來分配記憶體就從記憶體池中分配,這樣可以減少記憶體分配的次數,提高效率,這也是大部分網路伺服器的實現方式,只不過各個記憶體池的管理方式根據具體情況而不同。而redis沒有自己得記憶體池,而是直接使用時分配,即什麼時候需要什麼時候分配,記憶體管理的事交給核心,自己只負責取和釋放(redis既是單執行緒,又沒有自己的記憶體池,是不是感覺實現的太簡單了?那是因為它的重點都放在資料庫模組了)。不過redis支援使用tcmalloc來替換glibc的malloc,前者是google的產品,比glibc的malloc快。

由於redis沒有自己的記憶體池,所以記憶體申請和釋放的管理就簡單很多,直接malloc和free即可,十分方便。而memcached是支援記憶體池的,所以記憶體申請是從記憶體池中獲取,而free也是還給記憶體池,所以需要很多額外的管理操作,實現起來麻煩很多,具體的會在後面memcached的slab機制講解中分析。

五. 資料庫實現

接下來看看他們的最核心內容,各自資料庫的實現。

1. memcached資料庫實現

memcached只支援key-value,即只能一個key對於一個value。它的資料在記憶體中也是這樣以key-value對的方式儲存,它使用slab機制。

首先看memcached是如何儲存資料的,即儲存key-value對。如下圖,每一個key-value對都儲存在一個item結構中,包含了相關的屬性和key和value的值。

item是儲存key-value對的,當item多的時候,怎麼查詢特定的item是個問題。所以memcached維護了一個hash表,它用於快速查詢item。hash表適用開鏈法(與redis一樣)解決鍵的衝突,每一個hash表的桶裡面儲存了一個連結串列,連結串列節點就是item的指標,如上圖中的h_next就是指桶裡面的連結串列的下一個節點。 hash表支援擴容(item的數量是桶的數量的1.5以上時擴容),有一個primary_hashtable,還有一個old_hashtable,其中正常適用primary_hashtable,但是擴容的時候,將old_hashtable = primary_hashtable,然後primary_hashtable設定為新申請的hash表(桶的數量乘以2),然後依次將old_hashtable 裡面的資料往新的hash表裡面移動,並用一個變數expand_bucket記錄以及移動了多少個桶,移動完成後,再free原來的old_hashtable 即可(redis也是有兩個hash表,也是移動,不過不是後臺執行緒完成,而是每次移動一個桶)。擴容的操作,專門有一個後臺擴容的執行緒來完成,需要擴容的時候,使用條件變數通知它,完成擴容後,它又考試阻塞等待擴容的條件變數。這樣在擴容的時候,查詢一個item可能會在primary_hashtable和old_hashtable的任意一箇中,需要根據比較它的桶的位置和expand_bucket的大小來比較確定它在哪個表裡。

item是從哪裡分配的呢?從slab中。如下圖,memcached有很多slabclass,它們管理slab,每一個slab其實是trunk的集合,真正的item是在trunk中分配的,一個trunk分配一個item。一個slab中的trunk的大小一樣,不同的slab,trunk的大小按比例遞增,需要新申請一個item的時候,根據它的大小來選擇trunk,規則是比它大的最小的那個trunk。這樣,不同大小的item就分配在不同的slab中,歸不同的slabclass管理。 這樣的缺點是會有部分記憶體浪費,因為一個trunk可能比item大,如圖2,分配100B的item的時候,選擇112的trunk,但是會有12B的浪費,這部分記憶體資源沒有使用。



如上圖,整個構造就是這樣,slabclass管理slab,一個slabclass有一個slab_list,可以管理多個slab,同一個slabclass中的slab的trunk大小都一樣。slabclass有一個指標slot,儲存了未分配的item已經被free掉的item(不是真的free記憶體,只是不用了而已),有item不用的時候,就放入slot的頭部,這樣每次需要在當前slab中分配item的時候,直接取slot取即可,不用管item是未分配過的還是被釋放掉的。

然後,每一個slabclass對應一個連結串列,有head陣列和tail陣列,它們分別儲存了連結串列的頭節點和尾節點。連結串列中的節點就是改slabclass所分配的item,新分配的放在頭部,連結串列越往後的item,表示它已經很久沒有被使用了。當slabclass的記憶體不足,需要刪除一些過期item的時候,就可以從連結串列的尾部開始刪除,沒錯,這個連結串列就是為了實現LRU。光靠它還不行,因為連結串列的查詢是O(n)的,所以定位item的時候,使用hash表,這已經有了,所有分配的item已經在hash表中了,所以,hash用於查詢item,然後連結串列有用儲存item的最近使用順序,這也是lru的標準實現方法。

每次需要新分配item的時候,找到slabclass對於的連結串列,從尾部往前找,看item是否已經過期,過期的話,直接就用這個過期的item當做新的item。沒有過期的,則需要從slab中分配trunk,如果slab用完了,則需要往slabclass中新增slab了。

memcached支援設定過期時間,即expire time,但是內部並不定期檢查資料是否過期,而是客戶程式使用該資料的時候,memcached會檢查expire time,如果過期,直接返回錯誤。這樣的優點是,不需要額外的cpu來進行expire time的檢查,缺點是有可能過期資料很久不被使用,則一直沒有被釋放,佔用記憶體。

memcached是多執行緒的,而且只維護了一個資料庫,所以可能有多個客戶程式操作同一個資料,這就有可能產生問題。比如,A已經把資料更改了,然後B也更改了改資料,那麼A的操作就被覆蓋了,而可能A不知道,A任務資料現在的狀態時他改完後的那個值,這樣就可能產生問題。為了解決這個問題,memcached使用了CAS協議,簡單說就是item儲存一個64位的unsigned int值,標記資料的版本,每更新一次(資料值有修改),版本號增加,然後每次對資料進行更改操作,需要比對客戶程式傳來的版本號和伺服器這邊item的版本號是否一致,一致則可進行更改操作,否則提示髒資料。

以上就是memcached如何實現一個key-value的資料庫的介紹。

2. redis資料庫實現

首先redis資料庫的功能強大一些,因為不像memcached只支援儲存字串,redis支援string, list, set,sorted set,hash table 5種資料結構。例如儲存一個人的資訊就可以使用hash table,用人的名字做key,然後name super, age 24, 通過key 和 name,就可以取到名字super,或者通過key和age,就可以取到年齡24。這樣,當只需要取得age的時候,不需要把人的整個資訊取回來,然後從裡面找age,直接獲取age即可,高效方便。

為了實現這些資料結構,redis定義了抽象的物件redis object,如下圖。每一個物件有型別,一共5種:字串,連結串列,集合,有序集合,雜湊表。 同時,為了提高效率,redis為每種型別準備了多種實現方式,根據特定的場景來選擇合適的實現方式,encoding就是表示物件的實現方式的。然後還有記錄了物件的lru,即上次被訪問的時間,同時在redis 伺服器中會記錄一個當前的時間(近似值,因為這個時間只是每隔一定時間,伺服器進行自動維護的時候才更新),它們兩個只差就可以計算出物件多久沒有被訪問了。 然後redis object中還有引用計數,這是為了共享物件,然後確定物件的刪除時間用的。最後使用一個void*指標來指向物件的真正內容。正式由於使用了抽象redis object,使得資料庫運算元據時方便很多,全部統一使用redis object物件即可,需要區分物件型別的時候,再根據type來判斷。而且正式由於採用了這種物件導向的方法,讓redis的程式碼看起來很像c++程式碼,其實全是用c寫的。

//#define REDIS_STRING 0    // 字串型別
//#define REDIS_LIST 1        // 連結串列型別
//#define REDIS_SET 2        // 集合型別(無序的),可以求差集,並集等
//#define REDIS_ZSET 3        // 有序的集合型別
//#define REDIS_HASH 4        // 雜湊型別

//#define REDIS_ENCODING_RAW 0     /* Raw representation */ //raw  未加工
//#define REDIS_ENCODING_INT 1     /* Encoded as integer */
//#define REDIS_ENCODING_HT 2      /* Encoded as hash table */
//#define REDIS_ENCODING_ZIPMAP 3  /* Encoded as zipmap */
//#define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
//#define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
//#define REDIS_ENCODING_INTSET 6  /* Encoded as intset */
//#define REDIS_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
//#define REDIS_ENCODING_EMBSTR 8  /* Embedded sds 
                                                                     string encoding */

typedef struct redisObject {
    unsigned type:4;            // 物件的型別,包括 /* Object types */
    unsigned encoding:4;        // 底部為了節省空間,一種type的資料,
                                                // 可   以採用不同的儲存方式
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
    int refcount;         // 引用計數
    void *ptr;
} robj;

說到底redis還是一個key-value的資料庫,不管它支援多少種資料結構,最終儲存的還是以key-value的方式,只不過value可以是連結串列,set,sorted set,hash table等。和memcached一樣,所有的key都是string,而set,sorted set,hash table等具體儲存的時候也用到了string。 而c沒有現成的string,所以redis的首要任務就是實現一個string,取名叫sds(simple dynamic string),如下的程式碼, 非常簡單的一個結構體,len儲存改string的記憶體總長度,free表示還有多少位元組沒有使用,而buf儲存具體的資料,顯然len-free就是目前字串的長度。

struct sdshdr {
    int len;
    int free;
    char buf[];
};

字串解決了,所有的key都存成sds就行了,那麼key和value怎麼關聯呢?key-value的格式在指令碼語言中很好處理,直接使用字典即可,C沒有字典,怎麼辦呢?自己寫一個唄(redis十分熱衷於造輪子)。看下面的程式碼,privdata存額外資訊,用的很少,至少我們發現。 dictht是具體的雜湊表,一個dict對應兩張雜湊表,這是為了擴容(包括rehashidx也是為了擴容)。dictType儲存了雜湊表的屬性。redis還為dict實現了迭代器(所以說看起來像c++程式碼)。

雜湊表的具體實現是和mc類似的做法,也是使用開鏈法來解決衝突,不過裡面用到了一些小技巧。比如使用dictType儲存函式指標,可以動態配置桶裡面元素的操作方法。又比如dictht中儲存的sizemask取size(桶的數量)-1,用它與key做&操作來代替取餘運算,加快速度等等。總的來看,dict裡面有兩個雜湊表,每個雜湊表的桶裡面儲存dictEntry連結串列,dictEntry儲存具體的key和value。

前面說過,一個dict對於兩個dictht,是為了擴容(其實還有縮容)。正常的時候,dict只使用dictht[0],當dict[0]中已有entry的數量與桶的數量達到一定的比例後,就會觸發擴容和縮容操作,我們統稱為rehash,這時,為dictht[1]申請rehash後的大小的記憶體,然後把dictht[0]裡的資料往dictht[1]裡面移動,並用rehashidx記錄當前已經移動萬的桶的數量,當所有桶都移完後,rehash完成,這時將dictht[1]變成dictht[0], 將原來的dictht[0]變成dictht[1],並變為null即可。不同於memcached,這裡不用開一個後臺執行緒來做,而是就在event loop中完成,並且rehash不是一次性完成,而是分成多次,每次使用者操作dict之前,redis移動一個桶的資料,直到rehash完成。這樣就把移動分成多個小移動完成,把rehash的時間開銷均分到使用者每個操作上,這樣避免了使用者一個請求導致rehash的時候,需要等待很長時間,直到rehash完成才有返回的情況。不過在rehash期間,每個操作都變慢了點,而且使用者還不知道redis在他的請求中間新增了移動資料的操作,感覺redis太賤了 :-D

typedef struct dict {
    dictType *type;    // 雜湊表的相關屬性
    void *privdata;    // 額外資訊
    dictht ht[2];    // 兩張雜湊表,分主和副,用於擴容
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */ // 記錄當前資料遷移的位置,在擴容的時候用的
    int iterators; /* number of iterators currently running */    // 目前存在的迭代器的數量
} dict;

typedef struct dictht {
    dictEntry **table;  // dictEntry是item,多個item組成hash桶裡面的連結串列,table則是多個連結串列頭指標組成的陣列的指標
    unsigned long size;    // 這個就是桶的數量
    // sizemask取size - 1, 然後一個資料來的時候,通過計算出的hashkey, 讓hashkey & sizemask來確定它要放的桶的位置
    // 當size取2^n的時候,sizemask就是1...111,這樣就和hashkey % size有一樣的效果,但是使用&會快很多。這就是原因
    unsigned long sizemask;  
    unsigned long used;        // 已經數值的dictEntry數量
} dictht;

typedef struct dictType {
    unsigned int (*hashFunction)(const void *key);     // hash的方法
    void *(*keyDup)(void *privdata, const void *key);    // key的複製方法
    void *(*valDup)(void *privdata, const void *obj);    // value的複製方法
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);    // key之間的比較
    void (*keyDestructor)(void *privdata, void *key);    // key的析構
    void (*valDestructor)(void *privdata, void *obj);    // value的析構
} dictType;

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    struct dictEntry *next;
} dictEntry;

有了dict,資料庫就好實現了。所有資料讀儲存在dict中,key儲存成dictEntry中的key(string),用void* 指向一個redis object,它可以是5種型別中的任何一種。如下圖,結構構造是這樣,不過這個圖已經過時了,有一些與redis3.0不符合的地方。

5中type的物件,每一個都至少有兩種底層實現方式。string有3種:REDIS_ENCODING_RAW, REDIS_ENCIDING_INT, REDIS_ENCODING_EMBSTR, list有:普通雙向連結串列和壓縮連結串列,壓縮連結串列簡單的說,就是講陣列改造成連結串列,連續的空間,然後通過儲存字串的大小資訊來模擬連結串列,相對普通連結串列來說可以節省空間,不過有副作用,由於是連續的空間,所以改變記憶體大小的時候,需要重新分配,並且由於儲存了字串的位元組大小,所有有可能引起連續更新(具體實現請詳細看程式碼)。set有dict和intset(全是整數的時候使用它來儲存), sorted set有:skiplist和ziplist, hashtable實現有壓縮列表和dict和ziplist。skiplist就是跳錶,它有接近於紅黑樹的效率,但是實現起來比紅黑樹簡單很多,所以被採用(奇怪,這裡又不造輪子了,難道因為這個輪子有點難?)。 hash table可以使用dict實現,則改dict中,每個dictentry中key儲存了key(這是雜湊表中的鍵值對的key),而value則儲存了value,它們都是string。 而set中的dict,每個dictentry中key儲存了set中具體的一個元素的值,value則為null。圖中的zset(有序集合)有誤,zset使用skiplist和ziplist實現,首先skiplist很好理解,就把它當做紅黑樹的替代品就行,和紅黑樹一樣,它也可以排序。怎麼用ziplist儲存zset呢?首先在zset中,每個set中的元素都有一個分值score,用它來排序。所以在ziplist中,按照分值大小,先存元素,再存它的score,再存下一個元素,然後score。這樣連續儲存,所以插入或者刪除的時候,都需要重新分配記憶體。所以當元素超過一定數量,或者某個元素的字元數超過一定數量,redis就會選擇使用skiplist來實現zset(如果當前使用的是ziplist,會將這個ziplist中的資料取出,存入一個新的skiplist,然後刪除改ziplist,這就是底層實現轉換,其餘型別的redis object也是可以轉換的)。 另外,ziplist如何實現hashtable呢?其實也很簡單,就是儲存一個key,儲存一個value,再儲存一個key,再儲存一個value。還是順序儲存,與zset實現類似,所以當元素超過一定數量,或者某個元素的字元數超過一定數量時,就會轉換成hashtable來實現。各種底層實現方式是可以轉換的,redis可以根據情況選擇最合適的實現方式,這也是這樣使用類似物件導向的實現方式的好處。

需要指出的是,使用skiplist來實現zset的時候,其實還用了一個dict,這個dict儲存一樣的鍵值對。為什麼呢?因為skiplist的查詢只是lgn的(可能變成n),而dict可以到O(1), 所以使用一個dict來加速查詢,由於skiplist和dict可以指向同一個redis object,所以不會浪費太多記憶體。另外使用ziplist實現zset的時候,為什麼不用dict來加速查詢呢?因為ziplist支援的元素個數很少(個數多時就轉換成skiplist了),順序遍歷也很快,所以不用dict了。

這樣看來,上面的dict,dictType,dictHt,dictEntry,redis object都是很有考量的,它們配合實現了一個具有物件導向色彩的靈活、高效資料庫。不得不說,redis資料庫的設計還是很厲害的。

與memcached不同的是,redis的資料庫不止一個,預設就有16個,編號0-15。客戶可以選擇使用哪一個資料庫,預設使用0號資料庫。 不同的資料庫資料不共享,即在不同的資料庫中可以存在同樣的key,但是在同一個資料庫中,key必須是唯一的。

redis也支援expire time的設定,我們看上面的redis object,裡面沒有儲存expire的欄位,那redis怎麼記錄資料的expire time呢? redis是為每個資料庫又增加了一個dict,這個dict叫expire dict,它裡面的dict entry裡面的key就是數對的key,而value全是資料為64位int的redis object,這個int就是expire time。這樣,判斷一個key是否過期的時候,去expire dict裡面找到它,取出expire time比對當前時間即可。為什麼這樣做呢? 因為並不是所有的key都會設定過期時間,所以,對於不設定expire time的key來說,儲存一個expire time會浪費空間,而是用expire dict來單獨儲存的話,可以根據需要靈活使用記憶體(檢測到key過期時,會把它從expire dict中刪除)。

redis的expire 機制是怎樣的呢? 與memcahed類似,redis也是惰性刪除,即要用到資料時,先檢查key是否過期,過期則刪除,然後返回錯誤。單純的靠惰性刪除,上面說過可能會導致記憶體浪費,所以redis也有補充方案,redis裡面有個定時執行的函式,叫servercron,它是維護伺服器的函式,在它裡面,會對過期資料進行刪除,注意不是全刪,而是在一定的時間內,對每個資料庫的expire dict裡面的資料隨機選取出來,如果過期,則刪除,否則再選,直到規定的時間到。即隨機選取過期的資料刪除,這個操作的時間分兩種,一種較長,一種較短,一般執行短時間的刪除,每隔一定的時間,執行一次長時間的刪除。這樣可以有效的緩解光采用惰性刪除而導致的記憶體浪費問題。

以上就是redis的資料的實現,與memcached不同,redis還支援資料持久化,這個下面介紹。

4.redis資料庫持久化

redis和memcached的最大不同,就是redis支援資料持久化,這也是很多人選擇使用redis而不是memcached的最大原因。 redis的持久化,分為兩種策略,使用者可以配置使用不同的策略。

4.1 RDB持久化

使用者執行save或者bgsave的時候,就會觸發RDB持久化操作。RDB持久化操作的核心思想就是把資料庫原封不動的儲存在檔案裡。

那如何儲存呢?如下圖, 首先儲存一個REDIS字串,起到驗證的作用,表示是RDB檔案,然後儲存redis的版本資訊,然後是具體的資料庫,然後儲存結束符EOF,最後用檢驗和。關鍵就是databases,看它的名字也知道,它儲存了多個資料庫,資料庫按照編號順序儲存,0號資料庫儲存完了,才輪到1,然後是2, 一直到最後一個資料庫。

每一個資料庫儲存方式如下,首先一個1位元組的常量SELECTDB,表示切換db了,然後下一個接上資料庫的編號,它的長度是可變的,然後接下來就是具體的key-value對的資料了。

int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
                        long long expiretime, long long now)
{
    /* Save the expire time */
    if (expiretime != -1) {
        /* If this key is already expired skip it */
        if (expiretime < now) return 0;
        if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
        if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
    }

    /* Save type, key, value */
    if (rdbSaveObjectType(rdb,val) == -1) return -1;
    if (rdbSaveStringObject(rdb,key) == -1) return -1;
    if (rdbSaveObject(rdb,val) == -1) return -1;
    return 1;
}

由上面的程式碼也可以看出,儲存的時候,先檢查expire time,如果已經過期,不存就行了,否則,則將expire time存下來,注意,及時是儲存expire time,也是先儲存它的型別為REDIS_RDB_OPCODE_EXPIRETIME_MS,然後再儲存具體過期時間。接下來儲存真正的key-value對,首先儲存value的型別,然後儲存key(它按照字串儲存),然後儲存value,如下圖。

在rdbsaveobject中,會根據val的不同型別,按照不同的方式儲存,不過從根本上來看,最終都是轉換成字串儲存,比如val是一個linklist,那麼先儲存整個list的位元組數,然後遍歷這個list,把資料取出來,依次按照string寫入檔案。對於hash table,也是先計算位元組數,然後依次取出hash table中的dictEntry,按照string的方式儲存它的key和value,然後儲存下一個dictEntry。 總之,RDB的儲存方式,對一個key-value對,會先儲存expire time(如果有的話),然後是value的型別,然後儲存key(字串方式),然後根據value的型別和底層實現方式,將value轉換成字串儲存。這裡面為了實現資料壓縮,以及能夠根據檔案恢復資料,redis使用了很多編碼的技巧,有些我也沒太看懂,不過關鍵還是要理解思想,不要在意這些細節。

儲存了RDB檔案,當redis再啟動的時候,就根據RDB檔案來恢復資料庫。由於以及在RDB檔案中儲存了資料庫的號碼,以及它包含的key-value對,以及每個key-value對中value的具體型別,實現方式,和資料,redis只要順序讀取檔案,然後恢復object即可。由於儲存了expire time,發現當前的時間已經比expire time大了,即資料已經超時了,則不恢復這個key-value對即可。

儲存RDB檔案是一個很巨大的工程,所以redis還提供後臺儲存的機制。即執行bgsave的時候,redis fork出一個子程式,讓子程式來執行儲存的工作,而父程式繼續提供redis正常的資料庫服務。由於子程式複製了父程式的地址空間,即子程式擁有父程式fork時的資料庫,子程式執行save的操作,把它從父程式那兒繼承來的資料庫寫入一個temp檔案即可。在子程式複製期間,redis會記錄資料庫的修改次數(dirty)。當子程式完成時,傳送給父程式SIGUSR1訊號,父程式捕捉到這個訊號,就知道子程式完成了複製,然後父程式將子程式儲存的temp檔案改名為真正的rdb檔案(即真正儲存成功了才改成目標檔案,這才是保險的做法)。然後記錄下這一次save的結束時間。

這裡有一個問題,在子程式儲存期間,父程式的資料庫已經被修改了,而父程式只是記錄了修改的次數(dirty),被沒有進行修正操作。似乎使得RDB儲存的不是實時的資料庫,有點不太高大上的樣子。 不過後面要介紹的AOF持久化,就解決了這個問題。

除了客戶執行sava或者bgsave命令,還可以配置RDB儲存條件。即在配置檔案中配置,在t時間內,資料庫被修改了dirty次,則進行後臺儲存。redis在serve cron的時候,會根據dirty數目和上次儲存的時間,來判斷是否符合條件,符合條件的話,就進行bg save,注意,任意時刻只能有一個子程式來進行後臺儲存,因為儲存是個很費io的操作,多個程式大量io效率不行,而且不好管理。

4.2 AOF持久化

首先想一個問題,儲存資料庫一定需要像RDB那樣把資料庫裡面的所有資料儲存下來麼?有沒有別的方法?

RDB儲存的只是最終的資料庫,它是一個結果。結果是怎麼來的?是通過使用者的各個命令建立起來的,所以可以不儲存結果,而只儲存建立這個結果的命令。 redis的AOF就是這個思想,它不同RDB儲存db的資料,它儲存的是一條一條建立資料庫的命令。

我們首先來看AOF檔案的格式,它裡面儲存的是一條一條的命令,首先儲存命令長度,然後儲存命令,具體的分隔符什麼的可以自己深入研究,這都不是重點,反正知道AOF檔案儲存的是redis客戶端執行的命令即可。

redis server中有一個sds aof_buf, 如果aof持久化開啟的話,每個修改資料庫的命令都會存入這個aof_buf(儲存的是aof檔案中命令格式的字串),然後event loop沒迴圈一次,在server cron中呼叫flushaofbuf,把aof_buf中的命令寫入aof檔案(其實是write,真正寫入的是核心緩衝區),再清空aof_buf,進入下一次loop。這樣所有的資料庫的變化,都可以通過aof檔案中的命令來還原,達到了儲存資料庫的效果。

需要注意的是,flushaofbuf中呼叫的write,它只是把資料寫入了核心緩衝區,真正寫入檔案時核心自己決定的,可能需要延後一段時間。 不過redis支援配置,可以配置每次寫入後sync,則在redis裡面呼叫sync,將核心中的資料寫入檔案,這不過這要耗費一次系統呼叫,耗費時間而已。還可以配置策略為1秒鐘sync一次,則redis會開啟一個後臺執行緒(所以說redis不是單執行緒,只是單eventloop而已),這個後臺執行緒會每一秒呼叫一次sync。這裡要問了,RDB的時候為什麼沒有考慮sync的事情呢?因為RDB是一次性儲存的,不像AOF這樣多次儲存,RDB的時候呼叫一次sync也沒什麼影響,而且使用bg save的時候,子程式會自己退出(exit),這時候exit函式內會沖刷緩衝區,自動就寫入了檔案中。

再來看,如果不想使用aof_buf儲存每次的修改命令,也可以使用aof持久化。redis提供aof_rewrite,即根據現有的資料庫生成命令,然後把命令寫入aof檔案中。很奇特吧?對,就是這麼厲害。進行aof_rewrite的時候,redis變數每個資料庫,然後根據key-value對中value的具體型別,生成不同的命令,比如是list,則它生成一個儲存list的命令,這個命令裡包含了儲存該list所需要的的資料,如果這個list資料過長,還會分成多條命令,先建立這個list,然後往list裡面新增元素,總之,就是根據資料反向生成儲存資料的命令。然後將這些命令儲存aof檔案,這樣不就和aof append達到同樣的效果了麼?

再來看,aof格式也支援後臺模式。執行aof_bgrewrite的時候,也是fork一個子程式,然後讓子程式進行aof_rewrite,把它複製的資料庫寫入一個臨時檔案,然後寫完後用新號通知父程式。父程式判斷子程式的退出資訊是否正確,然後將臨時檔案更名成最終的aof檔案。好了,問題來了。在子程式持久化期間,可能父程式的資料庫有更新,怎麼把這個更新通知子程式呢?難道要用程式間通訊麼?是不是有點麻煩呢?你猜redis怎麼做的?它根本不通知子程式。什麼,不通知?那更新怎麼辦? 在子程式執行aof_bgrewrite期間,父程式會儲存所有對資料庫有更改的操作的命令(增,刪除,改等),把他們儲存在aof_rewrite_buf_blocks中,這是一個連結串列,每個block都可以儲存命令,存不下時,新申請block,然後放入連結串列後面即可,當子程式通知完成儲存後,父程式將aof_rewrite_buf_blocks的命令append 進aof檔案就可以了。多麼優美的設計,想一想自己當初還考慮用程式間通訊,別人直接用最簡單的方法就完美的解決了問題,有句話說得真對,越優秀的設計越趨於簡單,而複雜的東西往往都是靠不住的。

至於aof檔案的載入,也就是一條一條的執行aof檔案裡面的命令而已。不過考慮到這些命令就是客戶端傳送給redis的命令,所以redis乾脆生成了一個假的客戶端,它沒有和redis建立網路連線,而是直接執行命令即可。首先搞清楚,這裡的假的客戶端,並不是真正的客戶端,而是儲存在redis裡面的客戶端的資訊,裡面有寫和讀的緩衝區,它是存在於redis伺服器中的。所以,如下圖,直接讀入aof的命令,放入客戶端的讀緩衝區中,然後執行這個客戶端的命令即可。這樣就完成了aof檔案的載入。

// 建立偽客戶端
fakeClient = createFakeClient();

while(命令不為空) {
   // 獲取一條命令的引數資訊 argc, argv
   ...

    // 執行
    fakeClient->argc = argc;
    fakeClient->argv = argv;
    cmd->proc(fakeClient);
}

整個aof持久化的設計,個人認為相當精彩。其中有很多地方,值得膜拜。

5. redis的事務

redis另一個比memcached強大的地方,是它支援簡單的事務。事務簡單說就是把幾個命令合併,一次性執行全部命令。對於關係型資料庫來說,事務還有回滾機制,即事務命令要麼全部執行成功,只要有一條失敗就回滾,回到事務執行前的狀態。redis不支援回滾,它的事務只保證命令依次被執行,即使中間一條命令出錯也會繼續往下執行,所以說它只支援簡單的事務。

首先看redis事務的執行過程。首先執行multi命令,表示開始事務,然後輸入需要執行的命令,最後輸入exec執行事務。 redis伺服器收到multi命令後,會將對應的client的狀態設定為REDIS_MULTI,表示client處於事務階段,並在client的multiState結構體裡面保持事務的命令具體資訊(當然首先也會檢查命令是否能否識別,錯誤的命令不會儲存),即命令的個數和具體的各個命令,當收到exec命令後,redis會順序執行multiState裡面儲存的命令,然後儲存每個命令的返回值,當有命令發生錯誤的時候,redis不會停止事務,而是儲存錯誤資訊,然後繼續往下執行,當所有的命令都執行完後,將所有命令的返回值一起返回給客戶。redis為什麼不支援回滾呢?網上看到的解釋出現問題是由於客戶程式的問題,所以沒必要伺服器回滾,同時,不支援回滾,redis伺服器的執行高效很多。在我看來,redis的事務不是傳統關係型資料庫的事務,要求CIAD那麼非常嚴格,或者說redis的事務都不是事務,只是提供了一種方式,使得客戶端可以一次性執行多條命令而已,就把事務當做普通命令就行了,支援回滾也就沒必要了。

我們知道redis是單event loop的,在真正執行一個事物的時候(即redis收到exec命令後),事物的執行過程是不會被打斷的,所有命令都會在一個event loop中執行完。但是在使用者逐個輸入事務的命令的時候,這期間,可能已經有別的客戶修改了事務裡面用到的資料,這就可能產生問題。所以redis還提供了watch命令,使用者可以在輸入multi之前,執行watch命令,指定需要觀察的資料,這樣如果在exec之前,有其他的客戶端修改了這些被watch的資料,則exec的時候,執行到處理被修改的資料的命令的時候,會執行失敗,提示資料已經dirty。 這是如何是實現的呢? 原來在每一個redisDb中還有一個dict watched_keys,watched_kesy中dictentry的key是被watch的資料庫的key,而value則是一個list,裡面儲存的是watch它的client。同時,每個client也有一個watched_keys,裡面儲存的是這個client當前watch的key。在執行watch的時候,redis在對應的資料庫的watched_keys中找到這個key(如果沒有,則新建一個dictentry),然後在它的客戶列表中加入這個client,同時,往這個client的watched_keys中加入這個key。當有客戶執行一個命令修改資料的時候,redis首先在watched_keys中找這個key,如果發現有它,證明有client在watch它,則遍歷所有watch它的client,將這些client設定為REDIS_DIRTY_CAS,表面有watch的key被dirty了。當客戶執行的事務的時候,首先會檢查是否被設定了REDIS_DIRTY_CAS,如果是,則表明資料dirty了,事務無法執行,會立即返回錯誤,只有client沒有被設定REDIS_DIRTY_CAS的時候才能夠執行事務。 需要指出的是,執行exec後,該client的所有watch的key都會被清除,同時db中該key的client列表也會清除該client,即執行exec後,該client不再watch任何key(即使exec沒有執行成功也是一樣)。所以說redis的事務是簡單的事務,算不上真正的事務。

以上就是redis的事務,感覺實現很簡單,實際用處也不是太大。

6. redis的釋出訂閱頻道

redis支援頻道,即加入一個頻道的使用者相當於加入了一個群,客戶往頻道里面發的資訊,頻道里的所有client都能收到。

實現也很簡單,也watch_keys實現差不多,redis server中儲存了一個pubsub_channels的dict,裡面的key是頻道的名稱(顯然要唯一了),value則是一個連結串列,儲存加入了該頻道的client。同時,每個client都有一個pubsub_channels,儲存了自己關注的頻道。當用使用者往頻道發訊息的時候,首先在server中的pubsub_channels找到改頻道,然後遍歷client,給他們發訊息。而訂閱,取消訂閱頻道不夠都是操作pubsub_channels而已,很好理解。

同時,redis還支援模式頻道。即通過正則匹配頻道,如有模式頻道p, 1, 則向普通頻道p1傳送訊息時,會匹配p,1,除了往普通頻道發訊息外,還會往p,1模式頻道中的client發訊息。注意,這裡是用釋出命令裡面的普通頻道來匹配已有的模式頻道,而不是在釋出命令裡制定模式頻道,然後匹配redis裡面儲存的頻道。實現方式也很簡單,在redis server裡面有個pubsub_patterns的list(這裡為什麼不用dict?因為pubsub_patterns的個數一般較少,不需要使用dict,簡單的list就好了),它裡面儲存的是pubsubPattern結構體,裡面是模式和client資訊,如下所示,一個模式,一個client,所以如果有多個clint監聽一個pubsub_patterns的話,在list面會有多個pubsubPattern,儲存client和pubsub_patterns的對應關係。 同時,在client裡面,也有一個pubsub_patterns list,不過裡面儲存的就是它監聽的pubsub_patterns的列表(就是sds),而不是pubsubPattern結構體。

typedef struct pubsubPattern {
    redisClient *client;    // 監聽的client
    robj *pattern;            // 模式
} pubsubPattern;

當使用者往一個頻道傳送訊息的時候,首先會在redis server中的pubsub_channels裡面查詢該頻道,然後往它的客戶列表傳送訊息。然後在redis server裡面的pubsub_patterns裡面查詢匹配的模式,然後往client裡面傳送訊息。 這裡並沒有去除重複的客戶,在pubsub_channels可能已經給某一個client發過message了,然後在pubsub_patterns中可能還會給使用者再發一次(甚至更多次)。 估計redis認為這是客戶程式自己的問題,所以不處理。

/* Publish a message */
int pubsubPublishMessage(robj *channel, robj *message) {
    int receivers = 0;
    dictEntry *de;
    listNode *ln;
    listIter li;

/* Send to clients listening for that channel */
    de = dictFind(server.pubsub_channels,channel);
    if (de) {
        list *list = dictGetVal(de);
        listNode *ln;
        listIter li;

        listRewind(list,&li);
        while ((ln = listNext(&li)) != NULL) {
            redisClient *c = ln->value;

            addReply(c,shared.mbulkhdr[3]);
            addReply(c,shared.messagebulk);
            addReplyBulk(c,channel);
            addReplyBulk(c,message);
            receivers++;
        }
    }
 /* Send to clients listening to matching channels */
    if (listLength(server.pubsub_patterns)) {
        listRewind(server.pubsub_patterns,&li);
        channel = getDecodedObject(channel);
        while ((ln = listNext(&li)) != NULL) {
            pubsubPattern *pat = ln->value;

            if (stringmatchlen((char*)pat->pattern->ptr,
                                sdslen(pat->pattern->ptr),
                                (char*)channel->ptr,
                                sdslen(channel->ptr),0)) {
                addReply(pat->client,shared.mbulkhdr[4]);
                addReply(pat->client,shared.pmessagebulk);
                addReplyBulk(pat->client,pat->pattern);
                addReplyBulk(pat->client,channel);
                addReplyBulk(pat->client,message);
                receivers++;
            }
        }
        decrRefCount(channel);
    }
    return receivers;
}

六. 總結

總的來看,redis比memcached的功能多很多,實現也更復雜。 不過memcached更專注於儲存key-value資料(這已經能滿足大多數使用場景了),而redis提供更豐富的資料結構及其他的一些功能。不能說redis比memcached好,不過從原始碼閱讀的角度來看,redis的價值或許更大一點。 另外,redis3.0裡面支援了叢集功能,這部分的程式碼還沒有研究,後續再跟進。

相關文章