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寫的。
說到底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就是目前字元串的長度。
字元串解決了,所有的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
有了dict,資料庫就好實作了。所有資料讀存儲在dict中,key存儲成dictentry中的key(string),用void* 指向一個redis object,它可以是5種類型中的任何一種。如下圖,結構構造是這樣,不過這個圖已經過時了,有一些與redis 3.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裡面的資料随機選取出來,如果過期,則删除,否則再選,直到規定的時間到。即随機選取過期的資料删除,這個操作的時間分兩種,一種較長,一種較短,一般執行短時間的删除,每隔一定的時間,執行一次長時間的删除。這樣可以有效的緩解光采用惰性删除而導緻的記憶體浪費問題。
3、redis資料庫持久化
redis和memcached的最大不同,就是redis支援資料持久化,這也是很多人選擇使用redis而不是memcached的最大原因。redis的持久化,分為兩種政策,使用者可以配置使用不同的政策。
rdb持久化
使用者執行save或者bgsave的時候,就會觸發rdb持久化操作。rdb持久化操作的核心思想就是把資料庫原封不動的儲存在檔案裡。
那如何存儲呢?如下圖, 首先存儲一個redis字元串,起到驗證的作用,表示是rdb檔案,然後儲存redis的版本資訊,然後是具體的資料庫,然後存儲結束符eof,最後用檢驗和。關鍵就是databases,看它的名字也知道,它存儲了多個資料庫,資料庫按照編号順序存儲,0号資料庫存儲完了,才輪到1,然後是2, 一直到最後一個資料庫。
每一個資料庫存儲方式如下,首先一個1位元組的常量selectdb,表示切換db了,然後下一個接上資料庫的編号,它的長度是可變的,然後接下來就是具體的key-value對的資料了。
由上面的代碼也可以看出,存儲的時候,先檢查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效率不行,而且不好管理。
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檔案的載入。
整個aof持久化的設計,個人認為相當精彩。其中有很多地方,值得膜拜。
4、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的事務,感覺實作很簡單,實際用處也不是太大。
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結構體。
當使用者往一個頻道發送消息的時候,首先會在redis server中的pubsub_channels裡面查找該頻道,然後往它的客戶清單發送消息。然後在redis server裡面的pubsub_patterns裡面查找比對的模式,然後往client裡面發送消息。 這裡并沒有去除重複的客戶,在pubsub_channels可能已經給某一個client發過message了,然後在pubsub_patterns中可能還會給使用者再發一次(甚至更多次)。 估計redis認為這是客戶程式自己的問題,是以不處理。
五、總結
總的來看,redis比memcached的功能多很多,實作也更複雜。 不過memcached更專注于儲存key-value資料(這已經能滿足大多數使用場景了),而redis提供更豐富的資料結構及其他的一些功能。
不能說redis比memcached好,隻是從源碼閱讀的角度來看,redis的價值或許更大一點。 另外,redis 3.0裡面支援了叢集功能,這部分的代碼還沒有研究,後續再跟進。
<b></b>
<b>本文來自雲栖社群合作夥伴"dbaplus",原文釋出時間:2016-10-31</b>