前言
項目中用到了redis,但用到的都是最最基本的功能,比如簡單的slave機制,數據結構只使用了字符串。但是一直聽說 redis是一個很牛的開源項目,很多公司都在用。於是我就比較奇怪,這玩意不就和 memcache 差不多嗎?僅僅是因為memcache是內存級別的,沒有持久化功能。而redis支持持久化?難道這就是它的必殺技?
帶著這個疑問,我在 網上搜了一圈。發現有個叫做huangz的程序員針對redis寫了一本書叫做《redis設計與實現》,而且業界良心搞了一個reids2.6版本的注 釋版源碼。這本書不到200頁,估計2個星期能看完吧,之後打算再看下感興趣部分的源碼。當然,如果你不知道redis是干嘛的,請自行谷歌,簡單說就是 Key-Value數據庫,而且 value 支持5種數據結構:
下面我們就從 redis 的內部結構開始說起吧:)
一、redis內部數據結構
首先需要知道,redis是用C寫的。眾所周知,任何系統對於字符串的操作都是最頻繁的,而恰巧C語言的字符串備受诟病。然後作者就封裝了一下 C 語言的字符串 char *。
總之,根據redis的業務場景,整個redis系統的底層數據支撐被設計為如下幾種:
下面我們就分別來說說這4種數據結構。
1. 簡單動態字符串sds
這個一看名字就能知道個大概了,因為字符串操作無非是增刪查改,如果使用char[]數組,那是要死人的,任何操作都是O(N)復雜度。所以,要對某些頻繁的操作實現O(1)級性能。但是我們還是得思考:
為什麼要對字符串造輪子?
因 為redis是一個key-value類型的數據庫,而key全部都是字符串,value可以是集合、hash、list等等。同時,在redis的各種 操作中,都會頻繁使用字符串的長度和append操作,對於char[]來說,長度操作是O(N)的,append會引起N次realloc。而且因為 redis分為client和server,傳輸的協議內容必須是二進制安全的,而C的字符串必須保證是\0結尾,所以在這兩點的基礎上開發sds
知 道了上面幾點就可以看下實現了,其實實現特別簡單。它通過一個結構體來代表字符串對象,內部有個len屬性記錄長度,有個free用於以後的append 操作,具體的值還是一個char[]。長度就不說了,只在插入的時候用一下,以後只需要維護len就可以O(1)拿到;對於free也很簡 單,vector不也是這麼實現的嘛。就是按照某個阈值進行翻倍疊加。
2. 雙端鏈表
這玩意當時刷數據結構與算法分析那本書看過,但是沒怎麼用到過。說白了雙端鏈表就是有2個指針,一個指向鏈表頭,一個指向鏈表尾。對每個節點而言,記錄自己的父節點和子節點,這樣雙向移動速度會快很多。
還是老問題:
為什麼要有雙端鏈表?
在Java或者C++中,都有現成的容器供我們使用,但是C沒有。於是作者自己造了一個雙端鏈表數據結構。而這個也是redis列表數據結構的基礎之一(另外一個還是壓縮列表)。而且雙端鏈表也是一個通用的數據結構被其他功能調用,比如事務。
至於實現也是比較簡單,雙端鏈表,肯定有2個指針指向鏈表頭和鏈表尾,然後內部維護一個len保存節點的數目,這樣當使用LLEN的時候就能達到O(1)復雜度了。其他的,額,對每個節點而言,都有雙向的指針,另外還有針對雙端鏈表的迭代器,也是兩個方向。
3. 字典(其實說Map更通俗)
這個雖然說經常用,但是對於redis來說確實是重中之重。畢竟redis就是一個key-value的數據庫,而key被稱為鍵空間(key space),這個鍵空間就是由字典實現的。第二個用途就是用作hash類型的其中一種底層實現。下面分別來說明。
對於字典的實現這裡簡單說明一下即可,因為很簡單。
字 典是通過hash表實現的。每個字典含有2個hash表,分別為ht[0]和ht[1],一般情況下使用的是ht[0],ht[1]只有在rehash的 時候才用到。為什麼呢?因為性能,我們知道,當hash表出現太多碰撞的話,查找會由O(1)增加到O(MAXLEN),redis為了性能,會在碰撞過 多的情況下發生rehash,rehash就是擴大hash表的大小,從而將碰撞率降低,當hash表大小和節點數量維持在1:1時候性能最優,就是 O(1)。另外的rehashidx字段也比較有看頭,redis支持漸進式hash,下面會講到原理。
下面講一下rehash的觸發條件:
當新插入一個鍵值對的時候,根據used/size得到一個比例,如果這個比例超過阈值,就自動觸發rehash過程。rehash分為兩種:
思 考一下,為什麼需要兩種rehash呢?答案還是為了性能,不過這點考慮的是redis服務的整體性能。當redis使用後台子進程對字典進行 rehash的時候,為了最大化利用系統的copy on write機制,子進程會暫時將自然rehash關閉,這就是dict_can_resize的作用。當持久化任務完成後,將 dict_can_resize設為true,就可以繼續進行自然rehash;但是考慮另外一種情況,當現有字典的碰撞率太高了,size是指針數組的 大小,used是hash表節點數量,那麼就必須馬上進行rehash防止再插入的值繼續碰撞,這將浪費很長時間。所以超過 dict_force_resize_ratio後,無論在進行什麼操作,都必須進行rehash。
rehash過程很簡單,分為3步:
同樣是為了性能(當用戶對一個很大的字典插入時候,你不能讓系統阻塞來完成整個字典的rehash。所以redis采用了漸進式rehash。說白了就是分步進行rehash。具體由下面2個函數完成:
上面講完了rehash過程,但是以前在組內分享redis的時候遇到過一個問題:
當進行rehash時候,我進行了增刪查改怎麼辦?是在ht[0]進行還是在ht[1]進行呢?
redis采用的策略是rehash過程中ht[0]只減不增,所以增加肯定是ht[1],查找、修改、刪除則會同時在ht[0]和ht[1]進行。
Tips: redis為了減少存儲空間,rehash還有一個特性是縮減空間,當多次進行刪除操作後,如果used/size的比例小於一個阈值(現在是10%),那麼就會觸發縮減空間rehash,過程和增加空間類似,不詳述了。
3. 跳躍表
redis 使用了跳躍表,但是我發現。。。。我竟然不知道跳躍表是什麼東東。虧我還覺得數據結構基礎還湊合呢= =。於是趕緊去看了《數據結構與算法分析》,算是知道是啥玩意的。說白了,就是鏈表+二分查找的結合體。這裡主要是研究redis的,所以就不細談這個數 據結構了。
和雙端鏈表、字典不同的是,跳躍表在reids中不是廣泛使用的,它在redis中的唯一作用就是實現有序集數據類型。所以等到集合的時候再深入了解。
上一章我們介紹了redis的內部結構:
但是,創建這些完整的數據結構是比較耗費內存的,如果對於一個特別簡單的元素,使用這些數據結構無異於大材小用。為了解決這個問題,redis在條件允許的情況下,會使用內存映射數據結構來代替內部數據結構,主要有:
當然了,因為這些結構是和內存直接打交道的,就有節省內存的優點,而又因為對內存的操作比較復雜,所以也有操作復雜,占用的CPU時間更多的缺點。
這個要掌握一個平衡,才能使redis的總體效率更好。目前,redis使用兩種內存映射數據結構。
1. 整數集合
整 數集合用於有序、無重復的保存多個整數值,它會根據元素的值,自動選擇該用什麼長度的整數類型來保存元素。比如,在一個int set中,最大的元素可以用int16_t保存,那麼這個int set的所有元素都是int16_t,當插入一個元素是int32_t的時候,int set會先將所有元素升級為int32_t,再插入這個元素。總的來說,整數集合會自動升級。
看名字我們就知道它的用途:
那麼我們看一下 intset 的定義:
1 typedef struct intset { 2 3 // 保存元素所使用的類型的長度 4 uint32_t encoding; 5 6 // 元素個數 7 uint32_t length; 8 9 // 保存元素的數組 10 int8_t contents[]; 11 12 } intset;
其中 encoding 保存的是 intset 中元素的編碼類型,比如是 int16_t還是 int32_t等等。具體的定義在 intset.c 中:
1 #define INTSET_ENC_INT16 (sizeof(int16_t)) 2 #define INTSET_ENC_INT32 (sizeof(int32_t)) 3 #define INTSET_ENC_INT64 (sizeof(int64_t))
length 肯定就是元素的個數喽,然後是具體的元素,我們發現是 int8_t 類型的,實現上它只是一個象征意義上的類型,到實際分配時候,會根據具體元素的類型選擇合適的類型。而且 contents 有兩個特點:
所以,添加元素到intset有下面幾個步驟:
簡單總結一下整數集合的特點:
2. 壓縮列表
本 質來說,壓縮列表就是由一系列特殊編碼的內存塊構成的列表,一個壓縮列表可以包含多個節點,每個節點可以保存一個長度受限的字符數組(不以為\0結尾的 char數組)或者整數。說白了,它是以內存為中心的數據結構,一般列表是以元素類型的字節總數為大小,而壓縮列表是以它最小內存塊進行擴展組成的列表。 下面我來說一下。
壓縮列表分為3個部分:
其中壓縮列表的節點值得說一下,它可以存儲兩類數據:
那麼,怎樣實現呢?很簡單,通過 encoding + length 就可以搞定。encoding 占2位,00,01,10,11表示不明的類型,只有11代表的是節點中存放的是整型,其他3個代表節點中存放的都是字符串。而根據這2位的不同,又對應著不同的長度。
所 以,由 encoding 可以知道元素的類型和這個元素的范圍(比如 encoding 為01,包括 encoding 在內的2byte 代表長度,所以最長是214 - 1;如果 encoding 為00,包括 encoding 在內的1byte 代表元素的長度,所以最大值為26 -1 )
然後添加元素大概是下面醬紫滴(對於列表來說,添加元素默認是加在列表尾巴的):
上 面吐槽了壓縮列表沒有next指針,現在發現有了= =,但是不是指針,因為壓縮列表會進行內存充分配,所以指針代表的內存地址需要一直維護,而當使用偏移量的話,就不需要更改一次維護一次。向後遍歷是通過 頭指針+節點的大小(pre_entry_length+encoding+length的總大小)就可以跳到下一個節點了
不過,說實話,壓縮列表這個設計的好處我還沒有看到,可能還需要和後面的東西結合吧。
重讀之後看到了,(^__^) 嘻嘻……
本質上面已經說的很清楚了——節省內存。所以它不像上一章講到的那種分配固定的大小,而 intset 和 ziplist 完全是根據內存定做的,一個字節也不多(當然,有些操作還是會有浪費的)。
這一章主要是講redis內部的數據結構是如何實現的,可以說是redis的根基,前面2章介紹了redis的內部數據結構:
redis的內存映射數據結構:
而這一章,就是具體將這些數據結構是如何在redis中工作的。
一張圖說明問題的本質:
之 後,我們再根據這張圖來說明redis中的數據架構為什麼是醬紫滴。前面我們已經說過,redis中有5種數據結構,而它們的底層實現都不是唯一的,所以 怎樣選擇對應的底層數據支撐呢?這就需要“多態”的思想,但是因為redis是C開發的。所以通過結構體來模仿對象的“多態”(當然,本質來說這是為了讓 自己能更好的理解)。
為了完成這個任務,redis是這樣設計的:
下面看下redisObject的定義:
/* * Redis 對象 */ typedef struct redisObject { // 類型 unsigned type:4; // 對齊位 unsigned notused:2; // 編碼方式 unsigned encoding:4; // LRU 時間(相對於 server.lruclock) unsigned lru:22; // 引用計數 int refcount; // 指向對象的值 void *ptr; } robj;
其中type、encoding、ptr是最重要的3個屬性:
舉個例子就是:
如 果一個 redisObject 的 type 屬性為 REDIS_LIST , encoding 屬性為 REDIS_ENCODING_LINKEDLIST ,那麼這個對象就是一個 Redis 列表,它的值保存在一個雙端鏈表內,而 ptr 指針就指向這個雙端鏈表;
如果一個 redisObject 的 type 屬性為 REDIS_HASH , encoding 屬性為 REDIS_ENCODING_ZIPMAP ,那麼這個對象就是一個 Redis 哈希表,它的值保存在一個 zipmap 裡,而 ptr 指針就指向這個 zipmap ;諸如此類。
所以,當執行一個操作時,redis是這麼干的:
然後reids還搞了一個內存共享,這個挺贊的:
對於一些操作來說,返回值就那幾個。對於整數來說,存入的數據也通常不會太大,所以redis通過預分配一些常見的值對象,並在多個數據結構之間(很不幸,你得時指針才能指到這裡)共享這些對象,避免了重復分配,節約內存。同時也節省了CPU時間
如圖所示:
三個列表的值分別為:
最後一個:redis對對象的管理是通過最原始的引用計數方法。
字符串是redis使用最多的數據結構,除了本身作為SET/GET的操作對象外,數據庫中的所有key,以及執行命令時提供的參數,都是用字符串作為載體的。
在上面的圖中,我們可以看見,字符串的底層可以有兩種實現:
說白了就是除了long是通過第一種存儲以外,其他類型都是通過第二種存儲滴。
然後新創建的字符串,都會默認使用第二種編碼,在將字符串作為鍵或者值保存進數據庫時,程序會嘗試轉為第一種(為了節省空間)
哈希表,嗯,它的底層實現也有兩種:
當創建新的哈希表時,默認是使用壓縮列表作為底層數據結構的,因為省內存呀。只有當觸發了阈值才會轉為字典:
列表嘛,其實就是隊列。它的底層實現也有2種:
當創建新的列表時,默認是使用壓縮列表作為底層數據結構的,還是因為省內存- -。同樣有一個觸發阈值:
阻塞命令
對於列表,基本的操作就不介紹了,因為列表本身的操作和底層實現基本一致,所以我們可以簡單的認為它具有雙端隊列的操作即可。重點討論一下列表的阻塞命令比較好玩。
當我們執行BLPOP/BRPOP/BRPOPLPUSH的時候,都可能造成客戶端的阻塞,它們被稱為列表的阻塞原語,當然阻塞原語並不是一定會造成客戶端阻塞:
上面兩條的意思很簡單,因為POP命令是刪除一個節點,那麼當沒有節點的時候,客戶端會阻塞直到一個元素添加進來,然後再執行POP命令,那麼,對客戶端的阻塞過程是這樣的:
響應的,解鈴須有系鈴人:
上面的過程說的很簡單,但是在redis內部要執行的操作可以很多的,我們用一段偽代碼來演示一下被動脫離的過程:
def handleClientsBlockedOnLists(): # 執行直到 ready_keys 為空 while server.ready_keys != NULL: # 彈出鏈表中的第一個 readyList rl = server.ready_keys.pop_first_node() # 遍歷所有因為這個鍵而被阻塞的客戶端 for client in all_client_blocking_by_key(rl.key, rl.db): # 只要還有客戶端被這個鍵阻塞,就一直從鍵中彈出元素 # 如果被阻塞客戶端執行的是 BLPOP ,那麼對鍵執行 LPOP # 如果執行的是 BRPOP ,那麼對鍵執行 RPOP element = rl.key.pop_element() if element == NULL: # 鍵為空,跳出 for 循環 # 余下的未解除阻塞的客戶端只能等待下次新元素的進入了 break else: # 清除客戶端的阻塞信息 server.blocking_keys.remove_blocking_info(client) # 將元素返回給客戶端,脫離阻塞狀態 client.reply_list_item(element)
至於主動脫離,更簡單了,通過redis的cron job來檢查時間,對於過期的blocking客戶端,直接釋放即可。偽代碼如下:
def server_cron_job(): # cron_job其他操作 ... # 遍歷所有已連接客戶端 for client in server.all_connected_client: # 如果客戶端狀態為“正在阻塞”,並且最大阻塞時限已到達 if client.state == BLOCKING and \ client.max_blocking_timestamp < current_timestamp(): # 那麼給客戶端發送空回復, 脫離阻塞狀態 client.send_empty_reply() # 並清除客戶端在服務器上的阻塞信息 server.blocking_keys.remove_blocking_info(client) # cron_job其他操作 ...
這個就是set,底層實現有2種:
對於集合來說,和前面的2種不同點在於,集合的編碼是決定於第一個添加進集合的元素:
同樣,切換也需要達到一個阈值:
然後對於集合,有3個操作的算法很好玩,但是因為沒用到過,就暫時列一下:
終於看到最後一個數據結構了,雖然只有5個- -。。。。首先從命令上就可以區分這幾種了:
繼續說有序集,這個東西我還真的沒用過。。。其他最起碼都了解過,這個算是第一次接觸。現在看來,它也算一個sort過的map,sort的依據就是score,對score排序後得到的集合。
首先還是底層實現,有2種:
這個竟然用到了跳躍表,不用這個的話,跳躍表好像都快被我忘了呢。。對於編碼的選擇,和集合類似,也是決定於第一個添加進有序集的元素:
對於編碼的轉換阈值是這樣的:
那 我們知道,如果有序集是用ziplist實現的,而ziplist終對於member和score是按次序存儲的,如 member1,score1,member2,score2...這樣的。那麼,檢索時候就蛋疼了,肯定是O(N)復雜度,既然這樣,效率一下子就沒有 了。慶幸的是,轉換成跳躍表之後,redis搞的很高明:
它用一個字典和一個跳躍表同時來存儲有序集的元素,而且因為member和score是在內存區域其字典有指針,就可以共享一塊內存,不用每個元素復制兩份。
通過使用字典結構,並將 member 作為鍵,score 作為值,有序集可以在 O(1) 復雜度內:
通過使用跳躍表,可以讓有序集支持以下兩種操作:
通過同時使用字典和跳躍表,有序集可以高效地實現按成員查找和按順序查找兩種操作。所以,對於有序集來說,redis的思路確實是很流弊的。
上面幾個小節講述了redis的數據結構的底層實現,但是沒有涉及到具體的命令,如果調研後發現redis的某種數據結構滿足需求,就可以對症下藥,去查看redis對應的API即可。
這一章主要講解redis內部的一些功能,主要分為以下4個:
那麼,我們就來逐個擊破!
事務對於剛接觸計算機的人來說可能會比較抽象。因為事務是對計算機某些操作的稱謂。通俗來說,事務就是一個命令、一組命令執行的最小單元。事務一般具有ACID屬性(redis只支持兩種,下文詳細說明):
那麼,redis是通過MULTI/DISCARD/EXEC/WATCH這4個命令來實現事務功能。對事務,我們必須知道事務安全性是一個非常重要的。
事務提供了一種“將多個命令打包,然後一次性、按順序執行”的機制,並且在事務執行期間不會中斷——意思就是在事務完成之前,客戶端的其他命令都是阻塞狀態。
以下是一個事務的例子,它先以 MULTI 開始一個事務,然後將多個命令入隊到事務中,最後 由EXEC 命令觸發事務,一並執行事務中的所有命令:
redis> MULTI OK redis> SET book-name "Mastering C++ in 21 days" 81 QUEUED redis> GET book-name QUEUED redis> SADD tag "C++" "Programming" "Mastering Series" QUEUED redis> SMEMBERS tag QUEUED redis> EXEC 1) OK 2) "Mastering C++ in 21 days" 3) (integer) 3 4) 1) "Mastering Series" 2) "C++" 3) "Programming"
一個事務主要經歷3個階段:
這 幾個過程都比較簡單,開始事務就是切換到事務模式;命令入隊就是把事務中的每條命令記錄下來,包括是第幾條命令,命令參數什麼的(當然,事務中是不能再嵌 套事務的,所以再有事務關鍵字(MULTI/DISCARD/WATCH)會立即執行的);執行事務就是一下子把剛才那個事務的命令執行完。
對於上面的WATCH來說,我們可以看成一個鎖。這個鎖在執行期間是不可以修改(類比為打開鎖)的,這樣才能保證這次事務是隔離的,安全的。那麼,WATCH是如何觸發的呢?
在 任何對數據庫鍵空間進行修改的命令執行成功後,multi.c/touchWatchKey函數都會被調用——它會檢查數據庫的watch_keys字 典,看是否有客戶端在監視被修改的鍵,如果有的話,就把這個監視的是客戶端的REDIS_DIRTY_CAS打開。之後,執行EXEC前,會對這個事務的 客戶端檢查是否REDIS_DIRTY_CAS被打開,打開的話就說明事務的安全性被破壞,直接返回失敗;反之則正常進行事務操作。
事務的ACID性質
前面說到,事務一般具有ACID屬性,但是redis只保證兩種機制:一致性和隔離性。對於原子性和持久性並沒有支持,下面說明redis為什麼這樣做。
這 個東西沒有仔細看,但是大概知道是啥功能的。我想了一下,可以使用這個功能來完成跨平台之間消息的推送。比如我開發了一個app,分別有web版本、 ios版本、Android版本、Symbian版本。那麼,我可以結合模式+頻道,將消息推送到所有安裝此應用的平台上。
這是redis2.6版本最大的亮點。但是我們好像木有用過- -所以,以後有需求的時候再好好研究一下吧。
慢 查詢日志是redis系統提供的一個查看系統性能的功能。它的每一條記錄的是一條命令的執行時間。所以,你可以在redis.conf中設置當超過 slowlog_log_slower_than的時候,將這個命令記錄下來;因為慢查詢日志是一個FIFO隊列(用鏈表實現的),所以還有一個 slowlog_max_than來限制隊列長度,如果溢出,就從隊頭刪除最舊的,將最新的添加到隊尾。
這一章是講redis內部運作機制的,所以算是redis的核心。在這一章中,將會學習到redis是如何設計成為一個非常好用的nosql數據庫的。下面我們將要討論這些話題:
帶 著這幾個問題,我們就來學習一下redis的內部運作機制,當然,我們重點是學習它為什麼要這樣設計,這樣設計為什麼是最優的?有沒有可以改進的地方呢? 對細節不必太追究,先從整體上理解redis的框架是如何搭配的,然後對哪個模塊感興趣再去看看源碼,好像2.6版本的代碼量在5W行左右吧。
嗯,好像一直用的都是默認的數據庫。廢話不說,直接上一個數據庫結構:
typedef struct redisDb { //數據庫編號 int id; //保存數據庫所有鍵值對數據,也成為鍵空間(key space) dict *dict; //保存著鍵的過期信息 dict *expires; //實現列表阻塞原語,如BLPOP dict *blocking_keys; dict *ready_keys; //用於實現WATCH命令 dict *watched_keys }
主要來介紹3個屬性:
這其中比較好的一個是redis對於過期鍵的處理,我當時看到這裡想,可以弄一個定時器,定期來檢查expire字典中的key是否到了過期時間,但是這個定時器的時間間隔不好控制,長了的話已經過期的鍵還可以訪問;短了的話,又注定會影像系統的性能。
lazy機制:
當 你不用這個鍵的時候,我才懶得刪除。當你訪問redis的某個key時,我就檢查一下這個key是否存在在expire中,如果存在就看是否過期,過期則 刪除(優化是標記一下,直接返回空,然後定時任務再慢慢刪除這個);反之再去redis的dict中取值。但是缺點也有,如果用於不訪問,內存就一直占 用。加入我給100萬個key設置了5s的過期時間,但是我很少訪問,那麼內存到最後就會爆掉。
所以,redis綜合考慮後采用了懶惰刪除和定期刪除,這兩個策略相互配合,可以很好的完成CPU和內存的平衡。
因為當前項目用到了這個,必須要好好看看啊。戰略上藐視一下,就是redis數據庫從內存持久化到文件的意思。redis一共有兩種持久化操作:
逐個來說,先搞定RDB。
對於RDB機制來說,在保存RDB文件期間,主進程會被阻塞,直到保存成功為止。但是這也分兩種實現:
整個流程可以用偽代碼表示:
def SAVE(): rdbSave() def BGSAVE(): pid = fork() if pid == 0: # 子進程保存 RDB rdbSave() elif pid > 0: # 父進程繼續處理請求,並等待子進程的完成信號 handle_request() else: # pid == -1 # 處理 fork 錯誤 handle_fork_error()
當然,寫入之後就是load了。當redis服務重啟,就會將存在的dump.rdb文件重新載入到內存中,用於數據恢復,那麼redis是怎麼做的呢?
額,這一節重點是RDB文件的結構,如果有興趣,可以自己去看下dump.rdb文件,然後對照一下很容易就明白了。
AOF是append only file的縮寫,意思是追加到唯一的文件,從上面對RDB的介紹我們知道,RDB的寫入是觸發式的,等待多少秒或者多少次寫入才會持久化到文件中,但是AOF是實時的,它會記錄你的每一個命令。
同步到AOF文件的整個過程可以分為三個階段:
對於第三點我們需要說明一下,在前面我們說到,RDB是觸發式的,AOF是實時的。這裡怎麼又說也是滿足條件了呢?原來redis對於這個條件,有以下的方式:
補充:對於第二種的流程可能比較麻煩,用一個圖來說明:
如果仔細看上面的條件,會發現一會SAVE是子線程執行的,一會是主進程執行的,那麼怎樣從根本上區分呢?
我 個人猜測是區分操作的頻率,第一種情況是服務都關閉了,主進程肯定會做好善後工作,發現AOF開啟了但是沒有寫入磁盤,於是自己麻溜就做了;第二種情況, 因為每秒都需要做,主進程不可能用一個定時器去寫入磁盤,這時候用一個子線程就可以圓滿完成;第三種情況,因為一個命令基本都是特別小的,所以執行一次操 作估計非常非常快,所以主進程再調用子線程造成的上下文切換都顯得有點得不償失了,於是主進程自己搞定。【待驗證】
對於上面三種方式來說,最好的應該是第二種,因為阻塞操作會讓 Redis 主進程無法持續處理請求,所以一般說來,阻塞操作執行得越少、完成得越快,Redis 的性能就越好。
AOF文件的還原
對於AOF文件的還原就特別簡單了,因為AOF是按照AOF協議保存的redis操作命令,所以redis會偽造一個客戶端,把AOF保存的命令重新執行一遍,執行之後就會得到一個完成的數據庫,偽代碼如下:
def READ_AND_LOAD_AOF(): # 打開並讀取 AOF 文件 file = open(aof_file_name) while file.is_not_reach_eof(): # 讀入一條協議文本格式的 Redis 命令 cmd_in_text = file.read_next_command_in_protocol_format() # 根據文本命令,查找命令函數,並創建參數和參數個數等對象 cmd, argv, argc = text_to_command(cmd_in_text) # 執行命令 execRedisCommand(cmd, argv, argc) # 關閉文件 file.close()
AOF重寫
上面提到,AOF可以對redis的每個操作都記錄,但這帶來一個問題,當redis的操作越來越多之 後,AOF文件會變得很大。而且,裡面很大一部分都是無用的操作,你如我對一個整型+1,然後-1,然後再加1,然後再-1(比如這是一個互斥鎖的開 關),那麼,過一段時間後,可能+1、-1操作就執行了幾萬次,這時候,如果能對AOF重寫,把無效的命令清除,AOF會明顯瘦身,這樣既可以減少AOF 的體積,在恢復的時候,也能用最短的指令和最少的時間來恢復整個數據庫,迫於這個構想,redis提供了對AOF的重寫。
所謂的重寫呢,其 實說的不夠明確。因為redis所針對的重寫實際上指數據庫中鍵的當前值。AOF 重寫是一個有歧義的名字,實際的重寫工作是針對數據庫的當前值來進行的,程序既不讀寫、也不使用原有的 AOF 文件。比如現在有一個列表,push了1、2、3、4,然後刪除4、刪除1、加入1,這樣列表最後的元素是1、2、3,如果不進行縮減,AOF會記錄4次 redis操作,但是AOF重寫它看的是列表最後的值:1、2、3,於是它會用一條rpush 1 2 3來完成,這樣由4條變為1條命令,恢復到最近的狀態的代價就變為最小。
整個重寫過程的偽代碼如下:
def AOF_REWRITE(tmp_tile_name): f = create(tmp_tile_name) # 遍歷所有數據庫 for db in redisServer.db: # 如果數據庫為空,那麼跳過這個數據庫 if db.is_empty(): continue # 寫入 SELECT 命令,用於切換數據庫 f.write_command("SELECT " + db.number) # 遍歷所有鍵 for key in db: # 如果鍵帶有過期時間,並且已經過期,那麼跳過這個鍵 if key.have_expire_time() and key.is_expired(): continue if key.type == String: # 用 SET key value 命令來保存字符串鍵 value = get_value_from_string(key) f.write_command("SET " + key + value) elif key.type == List: # 用 RPUSH key item1 item2 ... itemN 命令來保存列表鍵 item1, item2, ..., itemN = get_item_from_list(key) f.write_command("RPUSH " + key + item1 + item2 + ... + itemN) elif key.type == Set: # 用 SADD key member1 member2 ... memberN 命令來保存集合鍵 member1, member2, ..., memberN = get_member_from_set(key) f.write_command("SADD " + key + member1 + member2 + ... + memberN) elif key.type == Hash: # 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令來保存哈希鍵 field1, value1, field2, value2, ..., fieldN, valueN =\ get_field_and_value_from_hash(key) f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\ ... + fieldN + valueN) elif key.type == SortedSet: # 用 ZADD key score1 member1 score2 member2 ... scoreN memberN # 命令來保存有序集鍵 score1, member1, score2, member2, ..., scoreN, memberN = \ get_score_and_member_from_sorted_set(key) f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\ ... + scoreN + memberN) else: raise_type_error() # 如果鍵帶有過期時間,那麼用 EXPIREAT key time 命令來保存鍵的過期時間 if key.have_expire_time(): f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp()) # 關閉文件 f.close()
AOF重寫的一個問題:如何實現重寫?
是使用後台線程還是使用子進程(redis是單進程的),這個問題值得討論下。額,對進程線程只是概念級的,等看完之後得拿redis的進程、線程機制開刀好好學一下。
redis肯定是以效率為先,所以不希望AOF重寫造成客戶端無法請求,所以redis采用了AOF重寫子進程執行,這樣的好處有:
當然,有有點肯定有缺點:
為 了解決這個問題,redis增加了一個AOF重寫緩存(在內存中),這個緩存在fort出子進程之後開始啟用,redis主進程在接到新的寫命令之後,除 了會將這個寫命令的協議內容追加到AOF文件之外,還會同時追加到這個緩存中。這樣,當子進程完成AOF重寫之後,它會給主進程發送一個信號,主進程接收 信號後,會將AOF重寫緩存中的內容全部寫入新AOF文件中,然後對新AOF改名,覆蓋老的AOF文件。
在整個AOF重寫過程中,只有最後的寫入緩存和改名操作會造成主進程的阻塞(要是不阻塞,客戶端請求到達又會造成數據不一致),所以,整個過程將AOF重寫對性能的消耗降到了最低。
AOF觸發條件
最後說一下AOF是如何觸發的,當然,如果手動觸發,是通過BGREWRITEAOF執行的。如果要用redis的自動觸發,就要涉及下面3個變量(AOF的功能要開啟哦 appendonlyfile yes):
每當serverCron函數(redis的crontab)執行時,會檢查以下條件是否全部滿足,如果是的話,就會觸發自動的AOF重寫: