memcached與redis實現的對比

版權聲明:本文由田京昆原創文章,轉載請註明出處: 
文章原文連接:https://www.qcloud.com/community/article/129react

來源:騰雲閣 https://www.qcloud.com/communitynginx

 

memcached和redis,做爲近些年最經常使用的緩存服務器,相信你們對它們再熟悉不過了。前兩年還在學校時,我曾經讀過它們的主要源碼,現在寫篇筆記從我的角度簡單對比一下它們的實現方式,權當作複習,有理解錯誤之處,歡迎指正。c++

文中使用的架構類的圖片大多來自於網絡,有部分圖與最新實現有出入,文中已經指出。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裏面支持了集羣功能,這部分的代碼尚未研究,後續再跟進。

相關文章
相關標籤/搜索