首頁  >  文章  >  資料庫  >  memcached與redis實現的對比

memcached與redis實現的對比

巴扎黑
巴扎黑原創
2017-03-19 16:52:31885瀏覽

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里面支持了集群功能,这部分的代码还没有研究,后续再跟进。

以上是memcached與redis實現的對比的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn