首頁 資料庫 mysql教程 詳細介紹memcached與redis實現的比較的圖文程式碼

詳細介紹memcached與redis實現的比較的圖文程式碼

Mar 09, 2017 am 11:19 AM

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

熱AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool

Undress AI Tool

免費脫衣圖片

Clothoff.io

Clothoff.io

AI脫衣器

Video Face Swap

Video Face Swap

使用我們完全免費的人工智慧換臉工具,輕鬆在任何影片中換臉!

熱工具

記事本++7.3.1

記事本++7.3.1

好用且免費的程式碼編輯器

SublimeText3漢化版

SublimeText3漢化版

中文版,非常好用

禪工作室 13.0.1

禪工作室 13.0.1

強大的PHP整合開發環境

Dreamweaver CS6

Dreamweaver CS6

視覺化網頁開發工具

SublimeText3 Mac版

SublimeText3 Mac版

神級程式碼編輯軟體(SublimeText3)

redis集群模式怎麼搭建 redis集群模式怎麼搭建 Apr 10, 2025 pm 10:15 PM

Redis集群模式通過分片將Redis實例部署到多個服務器,提高可擴展性和可用性。搭建步驟如下:創建奇數個Redis實例,端口不同;創建3個sentinel實例,監控Redis實例並進行故障轉移;配置sentinel配置文件,添加監控Redis實例信息和故障轉移設置;配置Redis實例配置文件,啟用集群模式並指定集群信息文件路徑;創建nodes.conf文件,包含各Redis實例的信息;啟動集群,執行create命令創建集群並指定副本數量;登錄集群執行CLUSTER INFO命令驗證集群狀態;使

redis數據怎麼清空 redis數據怎麼清空 Apr 10, 2025 pm 10:06 PM

如何清空 Redis 數據:使用 FLUSHALL 命令清除所有鍵值。使用 FLUSHDB 命令清除當前選定數據庫的鍵值。使用 SELECT 切換數據庫,再使用 FLUSHDB 清除多個數據庫。使用 DEL 命令刪除特定鍵。使用 redis-cli 工具清空數據。

redis怎麼讀取隊列 redis怎麼讀取隊列 Apr 10, 2025 pm 10:12 PM

要從 Redis 讀取隊列,需要獲取隊列名稱、使用 LPOP 命令讀取元素,並處理空隊列。具體步驟如下:獲取隊列名稱:以 "queue:" 前綴命名,如 "queue:my-queue"。使用 LPOP 命令:從隊列頭部彈出元素並返回其值,如 LPOP queue:my-queue。處理空隊列:如果隊列為空,LPOP 返回 nil,可先檢查隊列是否存在再讀取元素。

centos redis如何配置Lua腳本執行時間 centos redis如何配置Lua腳本執行時間 Apr 14, 2025 pm 02:12 PM

在CentOS系統上,您可以通過修改Redis配置文件或使用Redis命令來限制Lua腳本的執行時間,從而防止惡意腳本佔用過多資源。方法一:修改Redis配置文件定位Redis配置文件:Redis配置文件通常位於/etc/redis/redis.conf。編輯配置文件:使用文本編輯器(例如vi或nano)打開配置文件:sudovi/etc/redis/redis.conf設置Lua腳本執行時間限制:在配置文件中添加或修改以下行,設置Lua腳本的最大執行時間(單位:毫秒)

redis指令怎麼用 redis指令怎麼用 Apr 10, 2025 pm 08:45 PM

使用 Redis 指令需要以下步驟:打開 Redis 客戶端。輸入指令(動詞 鍵 值)。提供所需參數(因指令而異)。按 Enter 執行指令。 Redis 返迴響應,指示操作結果(通常為 OK 或 -ERR)。

redis怎麼使用鎖 redis怎麼使用鎖 Apr 10, 2025 pm 08:39 PM

使用Redis進行鎖操作需要通過SETNX命令獲取鎖,然後使用EXPIRE命令設置過期時間。具體步驟為:(1) 使用SETNX命令嘗試設置一個鍵值對;(2) 使用EXPIRE命令為鎖設置過期時間;(3) 當不再需要鎖時,使用DEL命令刪除該鎖。

redis命令行怎麼用 redis命令行怎麼用 Apr 10, 2025 pm 10:18 PM

使用 Redis 命令行工具 (redis-cli) 可通過以下步驟管理和操作 Redis:連接到服務器,指定地址和端口。使用命令名稱和參數向服務器發送命令。使用 HELP 命令查看特定命令的幫助信息。使用 QUIT 命令退出命令行工具。

redis過期策略怎麼設置 redis過期策略怎麼設置 Apr 10, 2025 pm 10:03 PM

Redis數據過期策略有兩種:定期刪除:定期掃描刪除過期鍵,可通過 expired-time-cap-remove-count、expired-time-cap-remove-delay 參數設置。惰性刪除:僅在讀取或寫入鍵時檢查刪除過期鍵,可通過 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-user-del 參數設置。

See all articles