天天看點

Redis單機資料庫的實作原理

Redis單機資料庫的實作原理

本文主要介紹Redis的資料庫結構,Redis兩種持久化的原理:RDB持久化、AOF持久化,以及Redis事件分類及執行原理。最後,分别介紹了單機班Redid用戶端和Redis伺服器的使用和實作原理。本文篇幅較長,全文學習請提前做好心裡準備,當然也可直接跳到某一段學習某一特定部分。

Redis單機資料庫的實作原理

本文主要介紹Redis的資料庫結構,Redis兩種持久化的原理:RDB持久化、AOF持久化,以及Redis事件分類及執行原理。最後,分别介紹了單機版Redid用戶端和Redis伺服器的使用和實作原理。本文篇幅較長,全文學習請提前做好心理準備,當然也可直接跳到某一段學習某一特定部分。

Redis 伺服器的所有資料庫都儲存在 <code>redisServer.db</code> 數組中, 而資料庫的數量則由 <code>redisServer.dbnum</code> 屬性儲存。

用戶端通過修改目标資料庫指針, 讓它指向 <code>redisServer.db</code> 數組中的不同元素來切換不同的資料庫。

資料庫主要由 <code>dict</code> 和 <code>expires</code> 兩個字典構成, 其中 <code>dict</code> 字典負責儲存鍵值對, 而 <code>expires</code> 字典則負責儲存鍵的過期時間。

因為資料庫由字典構成, 是以對資料庫的操作都是建立在字典操作之上的。

資料庫的鍵總是一個字元串對象, 而值則可以是任意一種 Redis 對象類型, 包括字元串對象、哈希表對象、集合對象、清單對象和有序集合對象, 分别對應字元串鍵、哈希表鍵、集合鍵、清單鍵和有序集合鍵。

<code>expires</code> 字典的鍵指向資料庫中的某個鍵, 而值則記錄了資料庫鍵的過期時間, 過期時間是一個以毫秒為機關的 UNIX 時間戳。

Redis 使用惰性删除和定期删除兩種政策來删除過期的鍵: 惰性删除政策隻在碰到過期鍵時才進行删除操作, 定期删除政策則每隔一段時間, 主動查找并删除過期鍵。

執行 SAVE 指令或者 BGSAVE 指令所産生的新 RDB 檔案不會包含已經過期的鍵。

執行 BGREWRITEAOF 指令所産生的重寫 AOF 檔案不會包含已經過期的鍵。

當一個過期鍵被删除之後, 伺服器會追加一條 DEL 指令到現有 AOF 檔案的末尾, 顯式地删除過期鍵。

當主伺服器删除一個過期鍵之後, 它會向所有從伺服器發送一條 DEL 指令, 顯式地删除過期鍵。

從伺服器即使發現過期鍵, 也不會自作主張地删除它, 而是等待主節點發來 DEL 指令, 這種統一、中心化的過期鍵删除政策可以保證主從伺服器資料的一緻性。

當 Redis 指令對資料庫進行修改之後, 伺服器會根據配置, 向用戶端發送資料庫通知。

Redis伺服器将所有資料庫都儲存在伺服器狀态redis.h/redisServer結構都db數組中,db資料的每個項都是一個redis.h/redisDb結構,每個redisDb結構代表一個資料庫:

在初始化伺服器時,程式會根據伺服器的狀态的dbnum屬性來決定應該建立多少個資料庫;

dbnum屬性的值由伺服器配置的database選項決定,預設情況下,該選項的值為16,是以Redis伺服器預設會建立16個資料庫。

如圖

Redis單機資料庫的實作原理

Redis 是一個鍵值對(key-value pair)資料庫伺服器, 伺服器中的每個資料庫都由一個 <code>redis.h/redisDb</code> 結構表示, 其中, <code>redisDb</code> 結構的 <code>dict</code> 字典儲存了資料庫中的所有鍵值對, 我們将這個字典稱為鍵空間(key space):

鍵空間和使用者所見的資料庫是直接對應的:

鍵空間的鍵也就是資料庫的鍵, 每個鍵都是一個字元串對象。

鍵空間的值也就是資料庫的值, 每個值可以是字元串對象、清單對象、哈希表對象、集合對象和有序集合對象在内的任意一種 Redis 對象。

舉個例子, 如果我們在空白的資料庫中執行以下指令: 

那麼在這些指令執行之後, 資料庫的鍵空間将會是圖 IMAGE_DB_EXAMPLE 所展示的樣子:

<code>alphabet</code> 是一個清單鍵, 鍵的名字是一個包含字元串 <code>"alphabet"</code> 的字元串對象, 鍵的值則是一個包含三個元素的清單對象。

<code>book</code> 是一個哈希表鍵, 鍵的名字是一個包含字元串 <code>"book"</code> 的字元串對象, 鍵的值則是一個包含三個鍵值對的哈希表對象。

<code>message</code> 是一個字元串鍵, 鍵的名字是一個包含字元串 <code>"message"</code> 的字元串對象, 鍵的值則是一個包含字元串 <code>"hello world"</code> 的字元串對象。

Redis單機資料庫的實作原理

因為資料庫的鍵空間是一個字典, 是以所有針對資料庫的操作 —— 比如添加一個鍵值對到資料庫, 或者從資料庫中删除一個鍵值對, 又或者在資料庫中擷取某個鍵值對, 等等,實際上都是通過對鍵空間字典進行操作來實作的。

其他鍵空間操作。除了上面列出的添加、删除、更新、取值操作之外, 還有很多針對資料庫本身的 Redis 指令, 也是通過對鍵空間進行處理來完成的。

比如說, 用于清空整個資料庫的 FLUSHDB 指令, 就是通過删除鍵空間中的所有鍵值對來實作的。

又比如說, 用于随機傳回資料庫中某個鍵的 RANDOMKEY 指令, 就是通過在鍵空間中随機傳回一個鍵來實作的。

另外, 用于傳回資料庫鍵數量的 DBSIZE 指令, 就是通過傳回鍵空間中包含鍵值對的數量來實作的。

類似的指令還有 EXISTS 、 RENAME 、 KEYS , 等等, 這些指令都是通過對鍵空間進行操作來實作的。

讀寫鍵空間時的維護操作

當使用 Redis 指令對資料庫進行讀寫時, 伺服器不僅會對鍵空間執行指定的讀寫操作, 還會執行一些額外的維護操作, 其中包括:

在讀取一個鍵之後(讀操作和寫操作都要對鍵進行讀取), 伺服器會根據鍵是否存在, 以此來更新伺服器的鍵空間命中(hit)次數或鍵空間不命中(miss)次數, 這兩個值可以在 INFO stats 指令的 <code>keyspace_hits</code> 屬性和 <code>keyspace_misses</code> 屬性中檢視。

在讀取一個鍵之後, 伺服器會更新鍵的 LRU (最後一次使用)時間, 這個值可以用于計算鍵的閑置時間, 使用指令 OBJECT idletime &lt;key&gt; 指令可以檢視鍵 <code>key</code> 的閑置時間。

如果伺服器在讀取一個鍵時, 發現該鍵已經過期, 那麼伺服器會先删除這個過期鍵, 然後才執行餘下的其他操作, 本章稍後對過期鍵的讨論會詳細說明這一點。

如果有用戶端使用 WATCH 指令監視了某個鍵, 那麼伺服器在對被監視的鍵進行修改之後, 會将這個鍵标記為髒(dirty), 進而讓事務程式注意到這個鍵已經被修改過, 《事務》一章會詳細說明這一點。

伺服器每次修改一個鍵之後, 都會對髒(dirty)鍵計數器的值增一, 這個計數器會觸發伺服器的持久化以及複制操作執行, 《RDB 持久化》、《AOF 持久化》和《複制》這三章都會說到這一點。

如果伺服器開啟了資料庫通知功能, 那麼在對鍵進行修改之後, 伺服器将按配置發送相應的資料庫通知, 本章稍後讨論資料庫通知功能的實作時會詳細說明這一點。

Redis有四個不同的指令可以用于設定鍵的生存時間(鍵可以存在多久)或過期時間(鍵什麼時候會被删除):

EXPIRE<key><ttl>指令用于将鍵key的生存時間設定為ttl秒。

PEXPIRE<key><ttl>指令用于将鍵key的生存時間設定為ttl毫秒。

EXPIREAT<key><timestamp>指令用于将鍵key的過期時間設定為timestamp所指定的秒數時間戳。

PEXPIREAT<key><timestamp>指令用于将鍵key的過期時間設定為timestamp所指定的毫秒數時間戳。

雖然有多種不同機關和不同形式的設定指令,但實際上EXPIRE、PEXPIRE、EXPIREAT三個指令都是使用PEXPIREAT指令來實作的:無論用戶端執行的是以上四個指令中的哪一個,經過轉換之後,最終的執行效果都和執行PEXPIREAT指令一樣。

Redis單機資料庫的實作原理

redisDb結構的expires字典儲存了資料庫中所有鍵的過期時間,我們稱這個字典為過期字典

過期字典的鍵是一個指針,這個指針指向鍵空間中的某個鍵對象(也即是某個資料庫鍵)。

過期字典的值是一個long long類型的整數,這個整數儲存了鍵所指向的資料庫鍵的過期時間——一個毫秒精度的UNIX時間戳。

查詢剩餘生命? 移除過期時間?

删除政策有定時/惰性/定期三種。但Redis伺服器實際使用的是惰性删除和定期删除兩種政策:通過配合使用這兩種删除政策,伺服器可以很好地在合理使用CPU時間和避免浪費記憶體空間之間取得平衡。

惰性删除

惰性删除政策對CPU時間來說是最友好的:程式隻會在取出鍵時才對鍵進行過期檢查,這可以保證删除過期鍵的操作隻會在非做不可的情況下進行,并且删除的目标僅限于目前處理的鍵,這個政策不會在删除其他無關的過期鍵上花費任何CPU時間。

惰性删除政策的缺點是,它對記憶體是最不友好的:如果一個鍵已經過期,而這個鍵又仍然保留在資料庫中,那麼隻要這個過期鍵不被删除,它所占用的記憶體就不會釋放。

在使用惰性删除政策時,如果資料庫中有非常多的過期鍵,而這些過期鍵又恰好沒有被通路到的話,那麼它們也許永遠也不會被删除(除非使用者手動執行FLUSHDB),我們甚至可以将這種情況看作是一種記憶體洩漏——無用的垃圾資料占用了大量的記憶體,而伺服器卻不會自己去釋放它們,這對于運作狀态非常依賴于記憶體的Redis伺服器來說,肯定不是一個好消息。

舉個例子,對于一些和時間有關的資料,比如日志(log),在某個時間點之後,對它們的通路就會大大減少,甚至不再通路,如果這類過期資料大量地積壓在資料庫中,使用者以為伺服器已經自動将它們删除了,但實際上這些鍵仍然存在,而且鍵所占用的記憶體也沒有釋放,那麼造成的後果肯定是非常嚴重的。

定期删除

從上面對惰性删除的讨論來看,這删除方式在單一使用時有明顯的缺陷:

惰性删除浪費太多記憶體,有記憶體洩漏的危險。定期删除政策是前兩種政策的一種整合和折中:

定期删除政策每隔一段時間執行一次删除過期鍵操作,并通過限制删除操作執行的時長和頻率來減少删除操作對CPU時間的影響。除此之外,通過定期删除過期鍵,定期删除政策有效地減少了因為過期鍵而帶來的記憶體浪費。定期删除政策的難點是确定删除操作執行的時長和頻率:

如果删除操作執行得太頻繁,或者執行的時間太長,定期删除政策就會退化成定時删除政策,以至于将CPU時間過多地消耗在删除過期鍵上面。

如果删除操作執行得太少,或者執行的時間太短,定期删除政策又會和惰性删除政策一樣,出現浪費記憶體的情況。是以,如果采用定期删除政策的話,伺服器必須根據情況,合理地設定删除操作的執行時長和執行頻率。

生成RDB檔案

在執行SAVE指令或者BGSAVE指令建立一個新的RDB檔案時,程式會對資料庫中的鍵進行檢查,已過期的鍵不會被儲存到新建立的RDB檔案中。

舉個例子,如果資料庫中包含三個鍵k1、k2、k3,并且k2已經過期,那麼當執行SAVE指令或者BGSAVE指令時,程式隻會将k1和k3的資料儲存到RDB檔案中,而k2則會被忽略。

是以,資料庫中包含過期鍵不會對生成新的RDB檔案造成影響。

可參考rdb.c中函數rdbSave()函數源碼:

載入RDB檔案

在啟動Redis伺服器時,如果伺服器開啟了RDB功能,那麼伺服器将對RDB檔案進行載入:

如果伺服器以主伺服器模式運作,那麼在載入RDB檔案時,程式會對檔案中儲存的鍵進行檢查,未過期的鍵會被載入到資料庫中,而過期鍵則會被忽略,是以過期鍵對載入RDB檔案的主伺服器不會造成影響;

如果伺服器以從伺服器模式運作,那麼在載入RDB檔案時,檔案中儲存的所有鍵,不論是否過期,都會被載入到資料庫中。不過,因為主從伺服器在進行資料同步的時候,從伺服器的資料庫就會被清空,是以一般來講,過期鍵對載入RDB檔案的從伺服器也不會造成影響;

這部分代碼可以檢視rdb.c中rdbLoad()函數源碼:

AOF檔案寫入

當伺服器以AOF持久化模式運作時,如果資料庫中的某個鍵已經過期,但它還沒有被惰性删除或者定期删除,那麼AOF檔案不會因為這個過期鍵而産生任何影響。

當過期鍵被惰性删除或者定期删除之後,程式會向AOF檔案追加(append)一條DEL指令,來顯式地記錄該鍵已被删除。

舉個例子,如果用戶端使用GET message指令,試圖通路過期的message鍵,那麼伺服器将執行以下三個動作:

1)從資料庫中删除message鍵。

2)追加一條DEL message指令到AOF檔案。(根據AOF檔案增加的特點,AOF隻有在用戶端進行請求的時候才會有這個DEL操作)

3)向執行GET指令的用戶端傳回空回複。

這部分就是Redis中的惰性删除政策中expireIfNeeded函數的使用。關于惰性删除政策這一部分在Redis惰性删除政策一篇中有講。是以這裡就不贅述了。

需要提示一下的是:expireIfNeeded函數是在db.c/lookupKeyRead()函數中被調用,lookupKeyRead函數用于在執行讀取操作時取出鍵key在資料庫db中的值。

 AOF重寫

和生成RDB檔案時類似,在執行AOF重寫的過程中,程式會對資料庫中的鍵進行檢查,已過期的鍵不會被儲存到重寫後的AOF檔案中。

舉個例子,如果資料庫中包含三個鍵k1、k2、k3,并且k2已經過期,那麼在進行重寫工作時,程式隻會對k1和k3進行重寫,而k2則會被忽略。

這一部分如果掌握了AOF重寫的方法的話,那就自然了解了。

複制

當伺服器運作在複制模式下時,從伺服器的過期鍵删除動作由主伺服器控制:

主伺服器在删除一個過期鍵之後,會顯式地向所有從伺服器發送一個DEL指令,告知從伺服器删除這個過期鍵;

從伺服器在執行用戶端發送的讀指令時,即使碰到過期鍵也不會将過期鍵删除,而是繼續像處理未過期的鍵一樣來處理過期鍵;

從伺服器隻有在接到主伺服器發來的DEL指令之後,才會删除過期鍵。

舉個例子,有一對主從伺服器,它們的資料庫中都儲存着同樣的三個鍵message、xxx和yyy,其中message為過期鍵,如圖所示

Redis單機資料庫的實作原理

      如果這時有用戶端向從伺服器發送指令GET message,那麼從伺服器将發現message鍵已經過期,但從伺服器并不會删除message鍵,而是繼續将message鍵的值傳回給用戶端,就好像message鍵并沒有過期一樣,如圖所示:

Redis單機資料庫的實作原理

假設在此之後,有用戶端向主伺服器發送指令GET message,那麼主伺服器将發現鍵message已經過期:主伺服器會删除message鍵,向用戶端傳回空回複,并向從伺服器發送DEL message指令,如圖所示:

Redis單機資料庫的實作原理

 從伺服器在接收到主伺服器發來的DEL message指令之後,也會從資料庫中删除message鍵,在這之後,主從伺服器都不再儲存過期鍵message了,如圖所示:

Redis單機資料庫的實作原理

資料庫通知是Redis2.8版本新增加的功能,這個功能可以讓用戶端通過訂閱給定的頻道或者模式,來獲知資料庫中鍵的變化,以及資料庫中指令的執行情況。

舉個例子,以下代碼展示了用戶端如何擷取0号資料庫中針對message鍵執行的所有指令: 

根據發回的通知顯示,先後共有SET、EXPlRE 、DEL 三個指令對鍵message進行了操作。

這一類關注"某個鍵執行了什麼指令"的通知稱為鍵空間通知(key-space-notification),除此之外,還有另一類稱為鍵事件通知(key-event-notification)的通知,它們關注的是"某個指令被什麼鍵執行了" 。

以下是一個鍵事件通知的例子,代碼展示了用戶端如何擷取0 号資料庫中所有執行DEL 指令的鍵:

根據發回的通知顯示,key、number、message三個鍵先後執行了DEL 指令。伺服器配置的notify-keyspace-events選項決定了伺服器所發送通知的類型:

想讓伺服器發送所有類型的鍵空間通知和鍵事件通知,可以将選項的值設定為AKE。

想讓伺服器發送所有類型的鍵空間通知,可以将選項的值設定為AK。

想讓伺服器發送所有類型的鍵事件通知,可以将選項的值設定為AE。

想讓伺服器隻發送和字元串鍵有關的鍵空間通知,可以将選項的值設定為K$。

想讓伺服器隻發送和清單鍵有關的鍵事件通知,可以将選項的值設定為E1。

關于資料庫通知功能的詳細用法,以及notify-keyspace-events選項的更多設定,Redis 的官方文檔已經做了很詳細的介紹,這裡不再贅述。

Redis是一個鍵值對資料庫伺服器,伺服器中通常包含着任意個非空資料庫,而每個非空資料庫中又可以包含任意個鍵值對,為了友善起見,我們将伺服器中的非空資料庫以及他們的鍵值對統稱為資料庫狀态。

因為Redis是記憶體資料庫,它将自己的資料庫狀态儲存在記憶體裡面,是以如果不想辦法将儲存在記憶體中的資料庫狀态儲存到磁盤裡面,那麼一旦伺服器程序退出,伺服器中的資料庫狀态也會消失不見。

為了解決這個問題,Redis提供了RDB持久化功能,這個功能可以将Redis在記憶體中的資料庫狀态儲存到磁盤裡面,避免資料意外丢失。

RDB持久化既可以手動執行,也可以根據伺服器配置選項定期執行,該功能可以将某個時間點上的資料庫狀态儲存到一個RDB檔案中。

RDB持久化功能所生成的RDB檔案是一個經過壓縮的二進制檔案,通過該檔案可以還原生成RDB檔案時的資料庫狀态。如下圖所示

Redis單機資料庫的實作原理

RDB 檔案用于儲存和還原 Redis 伺服器所有資料庫中的所有鍵值對資料。

SAVE 指令由伺服器程序直接執行儲存操作,是以該指令會阻塞伺服器。

BGSAVE 指令由子程序執行儲存操作,是以該指令不會阻塞伺服器。

伺服器狀态中會儲存所有用 <code>save</code> 選項設定的儲存條件,當任意一個儲存條件被滿足時,伺服器會自動執行 BGSAVE 指令。

RDB 檔案是一個經過壓縮的二進制檔案,由多個部分組成。

對于不同類型的鍵值對, RDB 檔案會使用不同的方式來儲存它們。

有兩個Redis指令可以用于生成RDB檔案,一個是SAVE,另一個是BGSAVE。

SAVE指令會阻塞Redis伺服器程序,直到RDB檔案建立完畢為止,在伺服器程序阻塞期間,伺服器不能處理任何指令請求

和SAVE指令直接阻塞伺服器程序的做法不同,BGSAVE指令會派生出一個子程序,然後由子程序負責建立RDB檔案,伺服器程序(父程序)繼續處理指令請求

建立RDB檔案的實際工作由rdb.c/rdbSave函數完成,SAVE指令和BGSAVE指令會以不同的方式調用這個函數,通過以下僞代碼可以明顯地看出這兩個指令之間的差別:

Redis單機資料庫的實作原理

載入rdbLoad/aofLoad;

和使用SAVE指令或者BGSAVE指令建立RDB檔案不同,RDB檔案的載入工作是在伺服器啟動時自動執行的,是以Redis并沒有專門用于載入RDB檔案的指令,隻要Redis伺服器在啟動時檢測到RDB檔案存在,它就會自動載入RDB檔案。

另外值得一提的是,因為AOF檔案的更新頻率通常比RDB檔案的更新頻率高,是以:

如果伺服器開啟了AOF持久化功能,那麼伺服器會優先使用AOF檔案來還原資料庫狀态。

隻有在AOF持久化功能處于關閉狀态時,伺服器才會使用RDB檔案來還原資料庫狀态。

 伺服器判斷該使用哪個檔案來還原資料庫狀态的流程,如下圖所示:

Redis單機資料庫的實作原理

載入RDB檔案的實際工作由rdb.c/rdbLoad()完成,這個函數和rdbSave()之間的關系可以下圖表示。

Redis單機資料庫的實作原理

bgsave執行時的伺服器狀态

    待補充

伺服器save配置

因為BGSAVE指令可以在不阻塞伺服器程序的情況下執行,是以Redis允許使用者通過設定伺服器配置的save選項,讓伺服器每隔一段時間自動執行一次BGSAVE指令。

舉個例子,如果我們向伺服器提供以下配置:

那麼隻要滿足以下三個條件中的任意一個,BGSAVE指令就會被執行:

伺服器在900秒之内,對資料庫進行了至少1次修改。

伺服器在300秒之内,對資料庫進行了至少10次修改。

伺服器在60秒之内,對資料庫進行了至少10000次修改。

設定儲存條件/資料結構

當Redis伺服器啟動時,使用者可以通過指定配置檔案或者傳入啟動參數的方式設定save選項,如果使用者沒有主動設定save選項,那麼伺服器會為save選項設定預設條件:

接着,伺服器程式會根據save選項所設定的儲存條件,設定伺服器狀态redisServer結構的saveparams屬性.

dirty計數器記錄距離上一次成功執行SAVE指令或者BGSAVE指令之後,伺服器對資料庫狀态(伺服器中的所有資料庫)進行了多少次修改(包括寫入、删除、更新等操作)。

lastsave屬性是一個UNIX時間戳,記錄了伺服器上一次成功執行SAVE指令或者BGSAVE指令的時間。

saveparams屬性是一個數組,數組中的每個元素都是一個saveparam結構,每個saveparam結構都儲存了一個save選項設定的儲存條件:

那麼伺服器狀态中的saveparams數組将會是下圖所示的樣子

Redis單機資料庫的實作原理

檢查儲存條件是否滿足

Redis的伺服器周期性操作函數serverCron預設每隔100毫秒就會執行一次,該函數用于對正在運作的伺服器進行維護,它的其中一項工作就是檢查save選項所設定的儲存條件是否已經滿足,如果滿足的話,就執行BGSAVE指令。以下僞代碼展示了serverCron函數檢查儲存條件的過程:

Redis單機資料庫的實作原理

程式會周遊并檢查saveparams數組中的所有儲存條件,隻要有任意一個條件被滿足,那麼伺服器就會執行BGSAVE指令。

圖 IMAGE_RDB_STRUCT_OVERVIEW 展示了一個完整 RDB 檔案所包含的各個部分。

Redis單機資料庫的實作原理
注意 為了友善區分變量、資料、常量, 圖 IMAGE_RDB_STRUCT_OVERVIEW 中用全大寫單詞标示常量, 用全小寫單詞标示變量和資料。 因為 RDB 檔案儲存的是二進制資料, 而不是 C 字元串, 為了簡便起見, 我們用 <code>"REDIS"</code> 符号代表 <code>'R'</code> 、 <code>'E'</code> 、 <code>'D'</code> 、 <code>'I'</code> 、 <code>'S'</code> 五個字元, 而不是帶 <code>'\0'</code> 結尾符号的 C 字元串 <code>'R'</code> 、 <code>'E'</code> 、 <code>'D'</code> 、 <code>'I'</code> 、 <code>'S'</code> 、 <code>'\0'</code> 。 本章展示的所有 RDB 檔案結構圖都遵循此規則。

RDB 檔案的最開頭是 <code>REDIS</code> 部分, 這個部分的長度為 <code>5</code> 位元組, 儲存着 <code>"REDIS"</code> 五個字元。 通過這五個字元, 程式可以在載入檔案時, 快速檢查所載入的檔案是否 RDB 檔案。

<code>db_version</code> 長度為 <code>4</code> 位元組, 它的值是一個字元串表示的整數, 這個整數記錄了 RDB 檔案的版本号, 比如 <code>"0006"</code> 就代表 RDB 檔案的版本為第六版。 本章隻介紹第六版 RDB 檔案的結構。

<code>EOF</code> 常量的長度為 <code>1</code> 位元組, 這個常量标志着 RDB 檔案正文内容的結束, 當讀入程式遇到這個值的時候, 它知道所有資料庫的所有鍵值對都已經載入完畢了。

<code>check_sum</code> 是一個 <code>8</code> 位元組長的無符号整數, 儲存着一個校驗和, 這個校驗和是程式通過對 <code>REDIS</code> 、 <code>db_version</code> 、 <code>databases</code> 、 <code>EOF</code> 四個部分的内容進行計算得出的。 伺服器在載入 RDB 檔案時, 會将載入資料所計算出的校驗和與 <code>check_sum</code> 所記錄的校驗和進行對比, 以此來檢查 RDB 檔案是否有出錯或者損壞的情況出現。

作為例子, 圖 IMAGE_RDB_WITH_EMPTY_DATABASE 展示了一個 <code>databases</code> 部分為空的 RDB 檔案: 檔案開頭的 <code>"REDIS"</code> 表示這是一個 RDB 檔案, 之後的 <code>"0006"</code> 表示這是第六版的 RDB 檔案, 因為 <code>databases</code> 為空, 是以版本号之後直接跟着 <code>EOF</code> 常量, 最後的 <code>6265312314761917404</code> 是檔案的校驗和。

Redis單機資料庫的實作原理

databases 部分

<code>a. databases</code> 部分包含着零個或任意多個資料庫, 以及各個資料庫中的鍵值對資料:

如果伺服器的資料庫狀态為空(所有資料庫都是空的), 那麼這個部分也為空, 長度為 <code>0</code> 位元組。

如果伺服器的資料庫狀态為非空(有至少一個資料庫非空), 那麼這個部分也為非空, 根據資料庫所儲存鍵值對的數量、類型和内容不同, 這個部分的長度也會有所不同。

比如說, 如果伺服器的 <code>0</code> 号資料庫和 <code>3</code> 号資料庫非空, 那麼伺服器将建立一個如圖 所示的 RDB 檔案, 圖中的 <code>database 0</code> 代表 <code>0</code> 号資料庫中的所有鍵值對資料, 而 <code>database 3</code> 則代表 <code>3</code> 号資料庫中的所有鍵值對資料。

Redis單機資料庫的實作原理

b.每個非空資料庫在 RDB 檔案中都可以儲存為 <code>SELECTDB</code> 、 <code>db_number</code> 、 <code>key_value_pairs</code> 三個部分, 如圖所示。

Redis單機資料庫的實作原理

<code>SELECTDB</code> 常量的長度為 <code>1</code> 位元組, 當讀入程式遇到這個值的時候, 它知道接下來要讀入的将是一個資料庫号碼。

<code>db_number</code> 儲存着一個資料庫号碼, 根據号碼的大小不同, 這個部分的長度可以是 <code>1</code> 位元組、 <code>2</code> 位元組或者 <code>5</code> 位元組。 當程式讀入 <code>db_number</code> 部分之後, 伺服器會調用 SELECT 指令, 根據讀入的資料庫号碼進行資料庫切換, 使得之後讀入的鍵值對可以載入到正确的資料庫中。

<code>key_valu</code>

<code>e_pairs</code> 部分儲存了資料庫中的所有鍵值對資料, 如果鍵值對帶有過期時間, 那麼過期時間也會和鍵值對儲存在一起。 根據鍵值對的數量、類型、内容、以及是否有過期時間等條件的不同, <code>key_value_pairs</code> 部分的長度也會有所不同。

key_value_pairs 部分

RDB 檔案中的每個 <code>key_value_pairs</code> 部分都儲存了一個或以上數量的鍵值對, 如果鍵值對帶有過期時間的話, 那麼鍵值對的過期時間也會被儲存在内。

不帶過期時間的鍵值對在 RDB 檔案中對由 <code>TYPE</code> 、 <code>key</code> 、 <code>value</code> 三部分組成, 如圖 IMAGE_KEY_WITHOUT_EXPIRE_TIME 所示。

Redis單機資料庫的實作原理

帶有過期時間的鍵值對在 RDB 檔案中的結構如圖 <code>IMAGE_KEY_WITH_EXPIRE_TIME</code> 所示。

Redis單機資料庫的實作原理

<code>TYPE</code> 記錄了 <code>value</code> 的類型,<code>TYPE</code> 常量都代表了一種對象類型或者底層編碼, 當伺服器讀入 RDB 檔案中的鍵值對資料時, 程式會根據 <code>TYPE</code> 的值來決定如何讀入和解釋 <code>value</code> 的資料。 TYPE長度為 <code>1</code> 位元組, 值可以是以下常量的其中一個:

<code>REDIS_RDB_TYPE_STRING</code>

<code>REDIS_RDB_TYPE_LIST</code>

<code>REDIS_RDB_TYPE_SET</code>

<code>REDIS_RDB_TYPE_ZSET</code>

<code>REDIS_RDB_TYPE_HASH</code>

<code>REDIS_RDB_TYPE_LIST_ZIPLIST</code>

<code>REDIS_RDB_TYPE_SET_INTSET</code>

<code>REDIS_RDB_TYPE_ZSET_ZIPLIST</code>

<code>REDIS_RDB_TYPE_HASH_ZIPLIST</code>

<code>key</code> 和 <code>value</code> 分别儲存了鍵值對的鍵對象和值對象:

其中 <code>key</code> 總是一個字元串對象, 它的編碼方式和 <code>REDIS_RDB_TYPE_STRING</code> 類型的 <code>value</code> 一樣。 根據内容長度的不同, <code>key</code> 的長度也會有所不同。

根據 <code>TYPE</code> 類型的不同, 以及儲存内容長度的不同, 儲存 <code>value</code> 的結構和長度也會有所不同, 本節稍後會詳細說明每種 <code>TYPE</code> 類型的 <code>value</code> 結構儲存方式。

<code>EXPIRETIME_MS</code> 常量的長度為 <code>1</code> 位元組, 它告知讀入程式, 接下來要讀入的将是一個以毫秒為機關的過期時間。

<code>ms</code> 是一個 <code>8</code> 位元組長的帶符号整數, 記錄着一個以毫秒為機關的 UNIX 時間戳, 這個時間戳就是鍵值對的過期時間。

通過前面的學習,我們對RDB檔案對各種内容和結構有了一定對了解,是時候抛開單純的示例開始分析和觀察一下實際的RDB的檔案了。

我們使用od指令來分析伺服器産生的RDB檔案,該指令可以用給定的格式轉存(dump)并列印輸入檔案。比如說,給對給定 -c參數可以以ASCII編碼的方式列印輸入檔案,給定 -x參數可以以十六進制的方式列印輸入檔案,諸如此類,具體資訊可以參考 od指令的文檔。

無資料的RDB檔案

有資料的RDB檔案

有過期時間的RDB檔案

數字以8進制顯示

REDIS0007:RDB檔案标志和版本号

372 結束符

redis-bit:redis的位數64或32

resdis-ver999.999.999:redis服務版本為999.999.999

ctime :時間戳 8位元組

used-mem:redis使用記憶體的大小

374:RDB_OPCODE_EXPIRETIME_MS占8位元組

377 EOF常量

最後8位元組為校驗和

除了RDB持久化功能之外, Redis還提供了AOF(Append Only File)持久化功能。與RDB持久化通過儲存資料庫中的鍵值對來記錄資料庫狀态不同,AOF持久化是通過儲存Redis伺服器所執行的寫指令來記錄資料庫狀态的。

伺服器在啟動時,可以通過載入和運作AOF檔案中儲存的指令來還原伺服器關閉之前的資料庫狀态。

AOF 檔案通過儲存所有修改資料庫的寫指令請求來記錄伺服器的資料庫狀态。

AOF 檔案中的所有指令都以 Redis 指令請求協定的格式儲存。

指令請求會先儲存到 AOF 緩沖區裡面, 之後再定期寫入并同步到 AOF 檔案。

<code>appendfsync</code> 選項的不同值對 AOF 持久化功能的安全性、以及 Redis 伺服器的性能有很大的影響。

伺服器隻要載入并重新執行儲存在 AOF 檔案中的指令, 就可以還原資料庫本來的狀态。

AOF 重寫可以産生一個新的 AOF 檔案, 這個新的 AOF 檔案和原有的 AOF 檔案所儲存的資料庫狀态一樣, 但體積更小。

AOF 重寫是一個有歧義的名字, 該功能是通過讀取資料庫中的鍵值對來實作的, 程式無須對現有 AOF 檔案進行任何讀入、分析或者寫入操作。

在執行 BGREWRITEAOF 指令時, Redis 伺服器會維護一個 AOF 重寫緩沖區, 該緩沖區會在子程序建立新 AOF 檔案的期間, 記錄伺服器執行的所有寫指令。 當子程序完成建立新 AOF 檔案的工作之後, 伺服器會将重寫緩沖區中的所有内容追加到新 AOF 檔案的末尾, 使得新舊兩個 AOF 檔案所儲存的資料庫狀态一緻。 最後, 伺服器用新的 AOF 檔案替換舊的 AOF 檔案, 以此來完成 AOF 檔案重寫操作。

指令追加

當 AOF 持久化功能處于打開狀态時, 伺服器在執行完一個寫指令之後, 會以協定格式将被執行的寫指令追加到伺服器狀态的 <code>aof_buf</code> 緩沖區的末尾:

比如說, 如果用戶端向伺服器發送以下指令:

那麼伺服器在執行這個 RPUSH 指令之後, 會将以下協定内容追加到 <code>aof_buf</code> 緩沖區的末尾:

以上就是 AOF 持久化的指令追加步驟的實作原理。

AOF 檔案的寫入與同步

Redis 的伺服器程序就是一個事件循環(loop), 這個循環中的檔案事件負責接收用戶端的指令請求, 以及向用戶端發送指令回複, 而時間事件則負責執行像 <code>serverCron</code> 函數這樣需要定時運作的函數。

因為伺服器在處理檔案事件時可能會執行寫指令, 使得一些内容被追加到 <code>aof_buf</code> 緩沖區裡面, 是以在伺服器每次結束一個事件循環之前, 它都會調用 <code>flushAppendOnlyFile</code> 函數, 考慮是否需要将 <code>aof_buf</code> 緩沖區中的内容寫入和儲存到 AOF 檔案裡面, 這個過程可以用以下僞代碼表示:

<code>flushAppendOnlyFile</code> 函數的行為由伺服器配置的 <code>appendfsync</code> 選項的值來決定, 各個不同值産生的行為如表所示。

<col>

<code>appendfsync</code> 選項的值

<code>flushAppendOnlyFile</code> 函數的行為

<code>always</code>

将 <code>aof_buf</code> 緩沖區中的所有内容寫入并同步到 AOF 檔案。

<code>everysec</code>

将 <code>aof_buf</code> 緩沖區中的所有内容寫入到 AOF 檔案, 如果上次同步 AOF 檔案的時間距離現在超過一秒鐘, 那麼再次對 AOF 檔案進行同步, 并且這個同步操作是由一個線程專門負責執行的。

<code>no</code>

将 <code>aof_buf</code> 緩沖區中的所有内容寫入到 AOF 檔案, 但并不對 AOF 檔案進行同步, 何時同步由作業系統來決定。

 檔案的寫入和同步

為了提高檔案的寫入效率, 在現代作業系統中, 當使用者調用 <code>write</code> 函數, 将一些資料寫入到檔案的時候, 作業系統通常會将寫入資料暫時儲存在一個記憶體緩沖區裡面, 等到緩沖區的空間被填滿、或者超過了指定的時限之後, 才真正地将緩沖區中的資料寫入到磁盤裡面。

這種做法雖然提高了效率, 但也為寫入資料帶來了安全問題, 因為如果計算機發生停機, 那麼儲存在記憶體緩沖區裡面的寫入資料将會丢失。

為此, 系統提供了 <code>fsync</code> 和 <code>fdatasync</code> 兩個同步函數, 它們可以強制讓作業系統立即将緩沖區中的資料寫入到硬碟裡面, 進而確定寫入資料的安全性。

 AOF 持久化的效率和安全性 伺服器配置 <code>appendfsync</code> 選項的值直接決定 AOF 持久化功能的效率和安全性。 當 <code>appendfsync</code> 的值為 <code>always</code> 時, 伺服器在每個事件循環都要将 <code>aof_buf</code> 緩沖區中的所有内容寫入到 AOF 檔案, 并且同步 AOF 檔案, 是以 <code>always</code> 的效率是 <code>appendfsync</code> 選項三個值當中最慢的一個, 但從安全性來說, <code>always</code> 也是最安全的, 因為即使出現故障停機, AOF 持久化也隻會丢失一個事件循環中所産生的指令資料。 當 <code>appendfsync</code> 的值為 <code>everysec</code> 時, 伺服器在每個事件循環都要将 <code>aof_buf</code> 緩沖區中的所有内容寫入到 AOF 檔案, 并且每隔超過一秒就要在子線程中對 AOF 檔案進行一次同步: 從效率上來講, <code>everysec</code> 模式足夠快, 并且就算出現故障停機, 資料庫也隻丢失一秒鐘的指令資料。 當 <code>appendfsync</code> 的值為 <code>no</code> 時, 伺服器在每個事件循環都要将 <code>aof_buf</code> 緩沖區中的所有内容寫入到 AOF 檔案, 至于何時對 AOF 檔案進行同步, 則由作業系統控制。 因為處于 <code>no</code> 模式下的 <code>flushAppendOnlyFile</code> 調用無須執行同步操作, 是以該模式下的 AOF 檔案寫入速度總是最快的, 不過因為這種模式會在系統緩存中積累一段時間的寫入資料, 是以該模式的單次同步時長通常是三種模式中時間最長的: 從平攤操作的角度來看, <code>no</code> 模式和 <code>everysec</code> 模式的效率類似, 當出現故障停機時, 使用 <code>no</code> 模式的伺服器将丢失上次同步 AOF 檔案之後的所有寫指令資料。

AOF檔案包含來重建資料庫狀态的所有寫指令,是以伺服器隻要讀入并重新執行一遍AOF檔案裡面儲存的寫指令,就可以還原伺服器關閉之前的資料庫狀态。

Redis 讀取 AOF 檔案并還原資料庫的詳細步驟如下:

1).建立一個不帶網絡連接配接的僞用戶端(fake client)。

2).讀取 AOF 所儲存的文本,并根據内容還原出指令、指令的參數以及指令的個數。

3).根據指令、指令的參數和指令的個數,使用僞用戶端執行該指令。

4).執行 2 和 3 ,直到 AOF 檔案中的所有指令執行完畢。

完成第 4) 步之後, AOF 檔案所儲存的資料庫就會被完整地還原出來。

注意, 因為 Redis 的指令隻能在用戶端的上下文中被執行, 而 AOF 還原時所使用的指令來自于 AOF 檔案, 而不是網絡, 是以程式使用了一個沒有網絡連接配接的僞用戶端來執行指令。 僞用戶端執行指令的效果, 和帶網絡連接配接的用戶端執行指令的效果, 完全一樣。

因為AOF是通過儲存被執行的寫指令來記錄資料庫狀态的,是以随着伺服器的運作時間久,AOF的檔案會變得越來越大,不僅占用系統資源,而且當通過AOF檔案來進行資料還原時花費的額時間也會更久。

為了解決AOF檔案體積膨脹的問題,Redis提供了AOF檔案重寫(rewrite)功能。通過該功能,Redis伺服器可以建立一個新的AOF檔案來替代現有的AOF檔案,新舊兩個AOF檔案所儲存的額資料庫狀态相同,但新的AOF檔案不會包含任何浪費空間的備援指令,是以新AOF檔案的體積通常會比舊AOF檔案的體積要小的多。

雖然Redis将生成新AOF檔案替換舊AOF檔案的功能命名為“AOF檔案重寫”,但實際上,AOF檔案重寫并不需要對現有的AOF檔案進行任何讀取、分析或者寫入操作,這個過程是通過讀取伺服器目前的資料庫狀态來實作的。

很明顯,作為一種輔佐性的維護手段,Redis不希望AOF重寫造成伺服器無法處理請求,是以Redis決定将AOF重寫程式放到子程序裡進行,這樣做可以達到兩個目的:

子程序進行AOF重寫期間,伺服器程序(父程序)可以繼續處理指令請求。

子程序帶有伺服器程序的額資料副本,使用子程序而不是線程,可以避免在使用鎖的情況下保證資料的安全性。

不過,使用子程序也有一個問題需要解決,因為子程序在進行AOF重寫期間,伺服器程序還需要繼續處理指令請求,而新的指令可能會對現有的資料庫狀态進行修改,進而使得伺服器目前的資料庫狀态和重寫後的AOF檔案所儲存的資料庫狀态不一緻。

為了解決這個問題,Redis伺服器設定了一個AOF重寫緩沖區,這個緩沖區在伺服器建立子程序之後開始使用,當Redis伺服器執行完一個指令後,他會同時将這個寫指令發送給AOF緩沖區和AOF重寫緩沖區。

Redis單機資料庫的實作原理

 當子程序完成AOF重寫之後,他會向父程序發送一個信号,父程序在接收到該信号後會調用一個信号處理函數,并進行一下工作:

1)将AOF重寫緩沖區中的所有内容寫入到新的AOF檔案中,這時新AOF檔案所儲存的資料庫狀态将和伺服器的狀态一緻。

2)對新的AOF檔案進行該米ing,原子的(atomic)覆寫現有的AOF檔案,完成新舊兩個AOF檔案的替換。

在這個信号處理函數執行完畢之後,父程序就可以繼續向往常一樣接受指令請求了。

在整個AOF重寫背景執行過程中,隻有信号處理函數執行時會對伺服器程序(父程序)造成阻塞,在其他時候不會阻塞,這将AOF重寫對伺服器性能造成的影響降到了最低。

Redis伺服器是一個事件驅動程式,伺服器需要處理一下兩類事件:

1)檔案事件 File Event: Redis伺服器通過套接字與用戶端或其他Redis伺服器進行連接配接,而檔案事件就是伺服器對套接字操作的抽象。伺服器與用戶端或其他伺服器的通信會産生相應的檔案事件,而伺服器則通過監聽并處理這些事件來完成一系列網絡通信操作。

2)時間事件 Time Event: Redis伺服器中的一些操作(比如serverCron函數)需要在給定的時間點執行,而時間事件就是伺服器對這類定時操作的抽象。

Redis 伺服器是一個事件驅動程式, 伺服器處理的事件分為時間事件和檔案事件兩類。

檔案事件處理器是基于 Reactor 模式實作的網絡通訊程式。

檔案事件是對套接字操作的抽象: 每次套接字變得可應答(acceptable)、可寫(writable)或者可讀(readable)時, 相應的檔案事件就會産生。

檔案事件分為 <code>AE_READABLE</code> 事件(讀事件)和 <code>AE_WRITABLE</code> 事件(寫事件)兩類。

時間事件分為定時事件和周期性事件: 定時事件隻在指定的時間達到一次, 而周期性事件則每隔一段時間到達一次。

伺服器在一般情況下隻執行 <code>serverCron</code> 函數一個時間事件, 并且這個事件是周期性事件。

檔案事件和時間事件之間是合作關系, 伺服器會輪流處理這兩種事件, 并且處理事件的過程中也不會進行搶占。

時間事件的實際處理時間通常會比設定的到達時間晚一些。

Redis 基于 Reactor 模式開發了自己的網絡事件處理器: 這個處理器被稱為檔案事件處理器(file event handler):

檔案事件處理器使用 I/O 多路複用(multiplexing)程式來同時監聽多個套接字, 并根據套接字目前執行的任務來為套接字關聯不同的事件處理器。

當被監聽的套接字準備好執行連接配接應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時, 與操作相對應的檔案事件就會産生, 這時檔案事件處理器就會調用套接字之前關聯好的事件處理器來處理這些事件。

雖然檔案事件處理器以單線程方式運作, 但通過使用 I/O 多路複用程式來監聽多個套接字, 檔案事件處理器既實作了高性能的網絡通信模型, 又可以很好地與 Redis 伺服器中其他同樣以單線程方式運作的子產品進行對接, 這保持了 Redis 内部單線程設計的簡單性。

檔案事件處理器的構成

下圖展示了檔案事件處理器的四個組成部分, 它們分别是套接字、 I/O 多路複用程式、 檔案事件分派器(dispatcher)、 以及事件處理器。 

Redis單機資料庫的實作原理

I/O 多路複用程式負責監聽多個套接字, 并向檔案事件分派器傳送那些産生了事件的套接字。

盡管多個檔案事件可能會并發地出現, 但 I/O 多路複用程式總是會将所有産生事件的套接字都入隊到一個隊列裡面, 然後通過這個隊列, 以有序(sequentially)、同步(synchronously)、每次一個套接字的方式向檔案事件分派器傳送套接字: 當上一個套接字産生的事件被處理完畢之後(該套接字為事件所關聯的事件處理器執行完畢), I/O 多路複用程式才會繼續向檔案事件分派器傳送下一個套接字, 如圖 。

Redis單機資料庫的實作原理

檔案事件分派器接收 I/O 多路複用程式傳來的套接字, 并根據套接字産生的事件的類型, 調用相應的事件處理器。

伺服器會為執行不同任務的套接字關聯不同的事件處理器, 這些處理器是一個個函數, 它們定義了某個事件發生時, 伺服器應該執行的動作。

I/O 多路複用程式的實作

Redis 的 I/O 多路複用程式的所有功能都是通過包裝常見的 <code>select</code> 、 <code>epoll</code> 、 <code>evport</code> 和 <code>kqueue</code> 這些 I/O 多路複用函數庫來實作的, 每個 I/O 多路複用函數庫在 Redis 源碼中都對應一個單獨的檔案, 比如 <code>ae_select.c</code> 、 <code>ae_epoll.c</code> 、 <code>ae_kqueue.c</code> , 諸如此類。

因為 Redis 為每個 I/O 多路複用函數庫都實作了相同的 API , 是以 I/O 多路複用程式的底層實作是可以互換的, 如圖 IMAGE_MULTI_LIB 所示。

Redis單機資料庫的實作原理

事件的類型

I/O 多路複用程式可以監聽多個套接字的 <code>ae.h/AE_READABLE</code> 事件和 <code>ae.h/AE_WRITABLE</code> 事件, 這兩類事件和套接字操作之間的對應關系如下:

檔案事件的處理器

當套接字變得可讀時(用戶端對套接字執行 <code>write</code> 操作,或者執行 <code>close</code> 操作), 或者有新的可應答(acceptable)套接字出現時(用戶端對伺服器的監聽套接字執行 <code>connect</code> 操作), 套接字産生 <code>AE_READABLE</code> 事件。

當套接字變得可寫時(用戶端對套接字執行 <code>read</code> 操作), 套接字産生 <code>AE_WRITABLE</code> 事件。

I/O 多路複用程式允許伺服器同時監聽套接字的 <code>AE_READABLE</code> 事件和 <code>AE_WRITABLE</code> 事件, 如果一個套接字同時産生了這兩種事件, 那麼檔案事件分派器會優先處理 <code>AE_READABLE</code> 事件, 等到 <code>AE_READABLE</code> 事件處理完之後, 才處理 <code>AE_WRITABLE</code> 事件。

這也就是說, 如果一個套接字又可讀又可寫的話, 那麼伺服器将先讀套接字, 後寫套接字。

讓我們來追蹤一次 Redis 用戶端與伺服器進行連接配接并發送指令的整個過程, 看看在過程中會産生什麼事件, 而這些事件又是如何被處理的。

假設一個 Redis 伺服器正在運作, 那麼這個伺服器的監聽套接字的 <code>AE_READABLE</code> 事件應該正處于監聽狀态之下, 而該事件所對應的處理器為連接配接應答處理器。

如果這時有一個 Redis 用戶端向伺服器發起連接配接, 那麼監聽套接字将産生 <code>AE_READABLE</code> 事件, 觸發連接配接應答處理器執行: 處理器會對用戶端的連接配接請求進行應答, 然後建立用戶端套接字, 以及用戶端狀态, 并将用戶端套接字的 <code>AE_READABLE</code> 事件與指令請求處理器進行關聯, 使得用戶端可以向主伺服器發送指令請求。

之後, 假設用戶端向主伺服器發送一個指令請求, 那麼用戶端套接字将産生 <code>AE_READABLE</code> 事件, 引發指令請求處理器執行, 處理器讀取用戶端的指令内容, 然後傳給相關程式去執行。

執行指令将産生相應的指令回複, 為了将這些指令回複傳送回用戶端, 伺服器會将用戶端套接字的 <code>AE_WRITABLE</code> 事件與指令回複處理器進行關聯: 當用戶端嘗試讀取指令回複的時候, 用戶端套接字将産生 <code>AE_WRITABLE</code> 事件, 觸發指令回複處理器執行, 當指令回複處理器将指令回複全部寫入到套接字之後, 伺服器就會解除用戶端套接字的 <code>AE_WRITABLE</code> 事件與指令回複處理器之間的關聯。

圖 IMAGE_COMMAND_PROGRESS 總結了上面描述的整個通訊過程, 以及通訊時用到的事件處理器。

Redis單機資料庫的實作原理

Redis的事件事件分為以下兩類:

1)定時事件:讓一段程式在指定的時間之後執行一次。比如,讓程式X在目前時間的30毫秒之後執行一次。

2)周期性事件:讓一段程式每隔指定時間就執行一次。比如,讓程式X每隔30毫秒執行一次。

目前版本的Redis隻使用周期性事件,而沒有使用定時事件。

時間事件由三部分組成:

1)id:伺服器為時間時間建立全局唯一id,辨別事件。ID号按從小到大的順序遞增,新的事件比舊的事件id大;

2)when:unix毫秒級時間戳,記錄時間事件的到達時間;

3)timeProc:時間事件處理器,時間事件到達時調用相應的處理器進行處理。

一個時間事件是定時還是周期性,取決于其傳回值:

1)如果傳回的是AE_NOMORE,表示其是一次性的事件,即定時的,執行完畢後redis會将其删除;

2)如果傳回的不是該結果,則表示是周期性事件,伺服器會根據傳回值,更新unix毫秒級時間戳,這樣當下一次時間到達時又會執行該事件。

實作

redis将所有的時間事件放在一個無序連結清單中,當時間事件執行器執行時,會周遊整個連結清單,将所有已到達執行時間的事件調用相應的時間事件處理器進行處理。

雖然是無序連結清單,但是由于新的時間事件總是插入到表頭,是以表頭總是最新的時間事件。

無序連結清單結構如下圖:

Redis單機資料庫的實作原理

注意,我們這裡說得無序連結清單,不是指的連結清單不按ID排序,而是指連結清單的元素不按照when的先後進行排序。正是沒有按when屬性進行排序,是以當時間時間運作時,必須周遊整個連結清單所有事件,這樣才能確定所有到達執行時間的事件都能被執行。 

serverCron函數

redis目前僅使用serverCron函數作為時間事件,即目前僅有這一個時間事件。該時間事件主要進行以下操作:

1)更新redis伺服器各類統計資訊,包括時間、記憶體占用、資料庫占用等情況。

2)清理資料庫中的過期鍵值對。

3)關閉和清理連接配接失敗的用戶端。

4)嘗試進行aof和rdb持久化操作。

5)如果伺服器是主伺服器,會定期将資料向從伺服器做同步操作。

6)如果處于叢集模式,對叢集定期進行同步與連接配接測試操作。

redis伺服器開啟後,就會周期性執行此函數,直到redis伺服器關閉為止。預設每秒執行10次,平均100毫秒執行一次,可以在redis配置檔案的hz選項,調整該函數每秒執行的次數。

由于redis伺服器同時存在檔案事件和時間事件,是以必須對這兩個事件進行排程,決定何時處理檔案事件,何時處理時間事件,以及花費多少時間處理這兩類事件。

事件排程執行流程

事件的排程和執行由ae.c/aeProcessEvents函數負責,執行流程如下:

Redis單機資料庫的實作原理

1)啟動伺服器,初始化伺服器,一直處理事件,循環下面的2~6步驟,直到伺服器關閉。伺服器關閉會執行相關的清理操作。

2)擷取到達時間距離目前時間最近的時間事件,計算到達時間,機關是毫秒,假設結果是x毫秒。

3)如果時間已經到達,則x是負數,将x置成0。

4)如果x不是0,則程式阻塞,等待檔案事件的産生,再進入下一步,其中程式阻塞的最大等待時間是x毫秒,因為即使x毫秒内都沒有檔案事件的産生,但是x毫秒後必然有時間事件需要執行,是以不能繼續阻塞;如果x是0,即已經有時間事件到了需要執行的時候,則程式不阻塞,直接進入下一步。

5)處理所有已經産生的檔案事件。

6)處理所有已經産生的時間事件。

事件排程執行規則

1)程式等待檔案事件的最大阻塞時間,是由到達時間最接近目前時間的時間時間決定,即避免了程式對時間事件的不斷輪詢,又保證阻塞時間不會太長。

2)由于檔案事件的發生,是由用戶端決定,即完全随機的。是以程式處理完一個檔案事件後,如果沒有新的待處理的檔案事件,且還沒到達最近的時間事件的執行時間,則程式會繼續阻塞,直到達到最近的時間事件的時間,或期間有新的檔案事件。

3)檔案事件和時間事件都是同步、有序、原子的執行,執行一個事件的時候,其他事件會阻塞等待,不會發生事件的搶占。兩類事件處理器都會減少程式的阻塞時間,并在有需要的時候主動讓出執行權,避免事件的饑餓等待。

例如:檔案事件的指令回複處理器,如果内容太多,寫入的位元組數超出預設的常量,則處理器會自動break,留下剩餘的内容下一次再寫;時間事件中會将耗時的rdb持久化、aof重寫等操作,通過建立子程序,由子程序執行。

4)由于都是優先處理檔案事件,後處理時間事件,且處理過程不會發生事件搶占,是以時間事件的實際執行時間,有可能比設定的執行時間稍晚一些。

執行示例如下圖:

Redis單機資料庫的實作原理

 在等待下一個時間事件的過程中,程式處理了兩個檔案事件。其中第85毫秒,由于還沒到時間事件的執行時間,而有檔案事件,是以處理檔案事件。由于檔案事件處理完畢後是在130毫秒,則時間事件隻能在131毫秒執行,比預設的100毫秒晚了31毫秒。

redis伺服器是典型的一對多伺服器程式,一個伺服器可以與多個用戶端可以與建立網絡連接配接,每個用戶端可以向伺服器發送指令請求,而伺服器接收并處理用戶端發送的指令請求,并向用戶端傳回指令回複。

通過使用I/O多路複用技術實作的檔案事件處理器,redis伺服器使用單線程單程序的方式來處理請求,并與多個用戶端建立網絡通信。

Redis伺服器狀态結構的clients屬性是一個連結清單,這個連結清單儲存了所有與伺服器連接配接的用戶端的狀态結構,對用戶端執行批量操作,或者查找某個指定的用戶端,都可以通過周遊clients連結清單來完成:

伺服器狀态結構使用 <code>clients</code> 連結清單連接配接起多個用戶端狀态, 新添加的用戶端狀态會被放到連結清單的末尾。

用戶端狀态的 <code>flags</code> 屬性使用不同标志來表示用戶端的角色, 以及用戶端目前所處的狀态。

輸入緩沖區記錄了用戶端發送的指令請求, 這個緩沖區的大小不能超過 1 GB 。

指令的參數和參數個數會被記錄在用戶端狀态的 <code>argv</code> 和 <code>argc</code> 屬性裡面, 而 <code>cmd</code> 屬性則記錄了用戶端要執行指令的實作函數。

用戶端有固定大小緩沖區和可變大小緩沖區兩種緩沖區可用, 其中固定大小緩沖區的最大大小為 16 KB , 而可變大小緩沖區的最大大小不能超過伺服器設定的硬性限制值。

輸出緩沖區限制值有兩種, 如果輸出緩沖區的大小超過了伺服器設定的硬性限制, 那麼用戶端會被立即關閉; 除此之外, 如果用戶端在一定時間内, 一直超過伺服器設定的軟性限制, 那麼用戶端也會被關閉。

當一個用戶端通過網絡連接配接連上伺服器時, 伺服器會為這個用戶端建立相應的用戶端狀态。 網絡連接配接關閉、 發送了不合協定格式的指令請求、 成為 CLIENT_KILL 指令的目标、 空轉時間逾時、 輸出緩沖區的大小超出限制, 以上這些原因都會造成用戶端被關閉。

處理 Lua 腳本的僞用戶端在伺服器初始化時建立, 這個用戶端會一直存在, 直到伺服器關閉。

載入 AOF 檔案時使用的僞用戶端在載入工作開始時動态建立, 載入工作完畢之後關閉。

多個與伺服器建立連接配接的用戶端,伺服器都為這些用戶端建立相應的redis.h/redisClient結構,這個結構儲存用戶端目前的資訊,以及執行相關功能時候需要用到的資料結構,主要包括:

1)用戶端的套接字描述符 

2)用戶端名字

3)用戶端的标志值

4)用戶端用到的資料結構

用戶端複制狀态資訊,及複制所需的資料結構,

用戶端執行brpop、blpop等阻塞清單指令時用到的資料結構

用戶端事務及watch用到的資料結構

用戶端執行釋出訂閱功能用到的資料結構

用戶端狀态包含的屬性可以分為兩類:

1)一類是比較通用的屬性, 這些屬性很少與特定功能相關, 無論用戶端執行的是什麼工作, 它們都要用到這些屬性。

2)另外一類是和特定功能相關的屬性, 比如操作資料庫時需要用到的 <code>db</code> 屬性和 <code>dictid</code> 屬性, 執行事務時需要用到的 <code>mstate</code> 屬性, 以及執行 WATCH 指令時需要用到的 <code>watched_keys</code> 屬性, 等等。

套接字描述符

用戶端狀态的 <code>fd</code> 屬性記錄了用戶端正在使用的套接字描述符:

根據用戶端類型的不同, <code>fd</code> 屬性的值可以是 <code>-1</code> 或者是大于 <code>-1</code> 的整數:

僞用戶端(fake client)的 <code>fd</code> 屬性的值為 <code>-1</code> : 僞用戶端處理的指令請求來源于 AOF 檔案或者 Lua 腳本, 而不是網絡, 是以這種用戶端不需要套接字連接配接, 自然也不需要記錄套接字描述符。 目前 Redis 伺服器會在兩個地方用到僞用戶端, 一個用于載入 AOF 檔案并還原資料庫狀态, 而另一個則用于執行 Lua 腳本中包含的 Redis 指令。

普通用戶端的 <code>fd</code> 屬性的值為大于 <code>-1</code> 的整數: 普通用戶端使用套接字來與伺服器進行通訊, 是以伺服器會用 <code>fd</code> 屬性來記錄用戶端套接字的描述符。 因為合法的套接字描述符不能是 <code>-1</code> , 是以普通用戶端的套接字描述符的值必然是大于 <code>-1</code> 的整數。

 執行 CLIENT_LIST 指令可以列出目前所有連接配接到伺服器的普通用戶端, 指令輸出中的 <code>fd</code> 域顯示了伺服器連接配接用戶端所使用的套接字描述符:

名字

用戶端的名字記錄在用戶端狀态的 <code>name</code> 屬性裡面:

在預設情況下, 一個連接配接到伺服器的用戶端是沒有名字的。比如在下面展示的 CLIENT_LIST 指令示例中, 兩個用戶端的 <code>name</code> 域都是空白的:

使用 CLIENT_SETNAME 指令可以為用戶端設定一個名字, 讓用戶端的身份變得更清晰。以下展示的是用戶端執行 CLIENT_SETNAME 指令之後的用戶端清單:

标志

用戶端的标志屬性 <code>flags</code> 記錄了用戶端的角色(role), 以及用戶端目前所處的狀态:

<code>flags</code> 屬性的值可以是單個标志,也可以是多個标志的二進制或, 比如:

每個标志使用一個常量表示, 一部分标志記錄了用戶端的角色:

在主從伺服器進行複制操作時, 主伺服器會成為從伺服器的用戶端, 而從伺服器也會成為主伺服器的用戶端。 <code>REDIS_MASTER</code> 标志表示用戶端代表的是一個主伺服器, <code>REDIS_SLAVE</code> 标志表示用戶端代表的是一個從伺服器。

<code>REDIS_PRE_PSYNC</code> 标志表示用戶端代表的是一個版本低于 Redis 2.8 的從伺服器, 主伺服器不能使用 PSYNC 指令與這個從伺服器進行同步。 這個标志隻能在 <code>REDIS_SLAVE</code> 标志處于打開狀态時使用。

<code>REDIS_LUA_CLIENT</code> 辨別表示用戶端是專門用于處理 Lua 腳本裡面包含的 Redis 指令的僞用戶端。

而另外一部分标志則記錄了用戶端目前所處的狀态:

<code>REDIS_MONITOR</code> 标志表示用戶端正在執行 MONITOR 指令。

<code>REDIS_UNIX_SOCKET</code> 标志表示伺服器使用 UNIX 套接字來連接配接用戶端。

<code>REDIS_BLOCKED</code> 标志表示用戶端正在被 BRPOP 、 BLPOP 等指令阻塞。

<code>REDIS_UNBLOCKED</code> 标志表示用戶端已經從 <code>REDIS_BLOCKED</code> 标志所表示的阻塞狀态中脫離出來, 不再阻塞。 <code>REDIS_UNBLOCKED</code> 标志隻能在 <code>REDIS_BLOCKED</code> 标志已經打開的情況下使用。

<code>REDIS_MULTI</code> 标志表示用戶端正在執行事務。

<code>REDIS_DIRTY_CAS</code> 标志表示事務使用 WATCH 指令監視的資料庫鍵已經被修改, <code>REDIS_DIRTY_EXEC</code> 标志表示事務在指令入隊時出現了錯誤, 以上兩個标志都表示事務的安全性已經被破壞, 隻要這兩個标記中的任意一個被打開, EXEC 指令必然會執行失敗。 這兩個标志隻能在用戶端打開了 <code>REDIS_MULTI</code> 标志的情況下使用。

<code>REDIS_CLOSE_ASAP</code> 标志表示用戶端的輸出緩沖區大小超出了伺服器允許的範圍, 伺服器會在下一次執行 <code>serverCron</code> 函數時關閉這個用戶端, 以免伺服器的穩定性受到這個用戶端影響。 積存在輸出緩沖區中的所有内容會直接被釋放, 不會傳回給用戶端。

<code>REDIS_CLOSE_AFTER_REPLY</code> 标志表示有使用者對這個用戶端執行了 CLIENT_KILL 指令, 或者用戶端發送給伺服器的指令請求中包含了錯誤的協定内容。 伺服器會将用戶端積存在輸出緩沖區中的所有内容發送給用戶端, 然後關閉用戶端。

<code>REDIS_ASKING</code> 标志表示用戶端向叢集節點(運作在叢集模式下的伺服器)發送了 ASKING 指令。

<code>REDIS_FORCE_AOF</code> 标志強制伺服器将目前執行的指令寫入到 AOF 檔案裡面, <code>REDIS_FORCE_REPL</code> 标志強制主伺服器将目前執行的指令複制給所有從伺服器。 執行 PUBSUB 指令會使用戶端打開 <code>REDIS_FORCE_AOF</code> 标志, 執行 SCRIPT_LOAD 指令會使用戶端打開 <code>REDIS_FORCE_AOF</code> 标志和 <code>REDIS_FORCE_REPL</code> 标志。

在主從伺服器進行指令傳播期間, 從伺服器需要向主伺服器發送 REPLICATION ACK 指令, 在發送這個指令之前, 從伺服器必須打開主伺服器對應的用戶端的 <code>REDIS_MASTER_FORCE_REPLY</code> 标志, 否則發送操作會被拒絕執行。

以上提到的所有标志都定義在 <code>redis.h</code> 檔案裡面。

輸入緩沖區

輸出緩沖區

執行指令所得的指令回複會被儲存在用戶端狀态的輸出緩沖區裡面, 每個用戶端都有兩個輸出緩沖區可用, 一個緩沖區的大小是固定的, 另一個緩沖區的大小是可變的:

固定大小的緩沖區用于儲存那些長度比較小的回複, 比如 <code>OK</code> 、簡短的字元串值、整數值、錯誤回複,等等。

可變大小的緩沖區用于儲存那些長度比較大的回複, 比如一個非常長的字元串值, 一個由很多項組成的清單, 一個包含了很多元素的集合, 等等。

用戶端的固定大小緩沖區由 <code>buf</code> 和 <code>bufpos</code> 兩個屬性組成:

<code>buf</code> 是一個大小為 <code>REDIS_REPLY_CHUNK_BYTES</code> 位元組的位元組數組, 而 <code>bufpos</code> 屬性則記錄了 <code>buf</code> 數組目前已使用的位元組數量。

<code>REDIS_REPLY_CHUNK_BYTES</code> 常量目前的預設值為 <code>16*1024</code> , 也即是說, <code>buf</code> 數組的預設大小為 16 KB 。

可變大小緩沖區由 <code>reply</code> 連結清單和一個或多個字元串對象組成。通過使用連結清單來連接配接多個字元串對象, 伺服器可以為用戶端儲存一個非常長的指令回複, 而不必受到固定大小緩沖區 16 KB 大小的限制。

<code>buf</code> 數組的空間已經用完, 或者回複因為太大而沒辦法放進 <code>buf</code> 數組裡面時, 伺服器就會開始使用可變大小緩沖區。

指令與指令參數

在伺服器将用戶端發送的指令請求儲存到用戶端狀态的 <code>querybuf</code> 屬性之後, 伺服器将對指令請求的内容進行分析, 并将得出的指令參數以及指令參數的個數分别儲存到用戶端狀态的 <code>argv</code> 屬性和 <code>argc</code> 屬性:

<code>argv</code> 屬性是一個數組, 數組中的每個項都是一個字元串對象: 其中 <code>argv[0]</code> 是要執行的指令, 而之後的其他項則是傳給指令的參數。

<code>argc</code> 屬性則負責記錄 <code>argv</code> 數組的長度。

舉個例子, 對于圖 13-4 所示的 <code>querybuf</code> 屬性來說, 伺服器将分析并建立圖 13-5 所示的 <code>argv</code> 屬性和 <code>argc</code> 屬性。

Redis單機資料庫的實作原理

注意, 在圖 13-5 展示的用戶端狀态中, <code>argc</code> 屬性的值為 <code>3</code> , 而不是 <code>2</code> , 因為指令的名字 <code>"SET"</code> 本身也是一個參數。 

指令的實作函數

當伺服器從協定内容中分析并得出 <code>argv</code> 屬性和 <code>argc</code> 屬性的值之後, 伺服器将根據項 <code>argv[0]</code> 的值, 在指令表中查找指令所對應的指令實作函數。

Redis單機資料庫的實作原理

圖 13-6 展示了一個指令表示例, 該表是一個字典, 字典的鍵是一個 SDS 結構, 儲存了指令的名字, 字典的值是指令所對應的 <code>redisCommand</code> 結構, 這個結構儲存了指令的實作函數、 指令的标志、 指令應該給定的參數個數、 指令的總執行次數和總消耗時長等統計資訊。

當程式在指令表中成功找到 <code>argv[0]</code> 所對應的 <code>redisCommand</code> 結構時, 它會将用戶端狀态的 <code>cmd</code> 指針指向這個結構:

之後, 伺服器就可以使用 <code>cmd</code> 屬性所指向的 <code>redisCommand</code> 結構, 以及 <code>argv</code> 、 <code>argc</code> 屬性中儲存的指令參數資訊, 調用指令實作函數, 執行用戶端指定的指令。

圖 13-7 示範了伺服器在 <code>argv[0]</code> 為 <code>"SET"</code> 時, 查找指令表并将用戶端狀态的 <code>cmd</code> 指針指向目标 <code>redisCommand</code> 結構的整個過程。注意:針對指令表的查找操作不區分輸入字母的大小寫, 是以無論 <code>argv[0]</code> 是 <code>"SET"</code> 、 <code>"set"</code> 、或者 <code>"SeT</code> , 等等, 查找的結果都是相同的。 

身份驗證

用戶端狀态的 <code>authenticated</code> 屬性用于記錄用戶端是否通過了身份驗證:

如果 <code>authenticated</code> 的值為 <code>0</code> , 那麼表示用戶端未通過身份驗證; 如果 <code>authenticated</code> 的值為 <code>1</code> , 那麼表示用戶端已經通過了身份驗證。

當用戶端 <code>authenticated</code> 屬性的值為 <code>0</code> 時, 除了 AUTH 指令之外, 用戶端發送的所有其他指令都會被伺服器拒絕執行: 

<code>authenticated</code> 屬性僅在伺服器啟用了身份驗證功能時使用: 如果伺服器沒有啟用身份驗證功能的話, 那麼即使 <code>authenticated</code> 屬性的值為 <code>0</code> (這是預設值), 伺服器也不會拒絕執行用戶端發送的指令請求。

時間

最後, 用戶端還有幾個和時間有關的屬性:

<code>ctime</code> 屬性記錄了建立用戶端的時間, 這個時間可以用來計算用戶端與伺服器已經連接配接了多少秒 —— CLIENT_LIST 指令的 <code>age</code> 域記錄了這個秒數:

<code>lastinteraction</code> 屬性記錄了用戶端與伺服器最後一次進行互動(interaction)的時間, 這裡的互動可以是用戶端向伺服器發送指令請求, 也可以是伺服器向用戶端發送指令回複。

<code>lastinteraction</code> 屬性可以用來計算用戶端的空轉(idle)時間, 也即是, 距離用戶端與伺服器最後一次進行互動以來, 已經過去了多少秒 —— CLIENT_LIST 指令的 <code>idle</code> 域記錄了這個秒數:

<code>obuf_soft_limit_reached_time</code> 屬性記錄了輸出緩沖區第一次到達軟性限制(soft limit)的時間, 稍後介紹輸出緩沖區大小限制的時候會詳細說明這個屬性的作用。 

普通用戶端連接配接

普通的用戶端,通過connect指令連接配接上伺服器後,會被redis将用戶端的狀态結構體加入到redisServer的連結清單屬性clients的末尾。下圖表示c1比c2先連上的redis伺服器

Redis單機資料庫的實作原理

 普通用戶端關閉

 用戶端關閉有下述情況:

用戶端程序退出,或被kill。

用戶端發送不符合協定格式的請求,

或成為client kill的目标。

使用者設定空轉時限而用戶端達到此時限(該條件有例外,如用戶端是主伺服器、從伺服器、用戶端被阻塞、用戶端正在釋出訂閱,就算超過時限也不會被關閉)。

用戶端發送指令超出預設的1GB大小的請求。

伺服器回複給用戶端的内容超過輸出緩沖區規定大小的内容。

伺服器通過兩種方式來先知用戶端輸出緩沖區的大小:

硬性限制(hard limit)。規定一個值,輸出緩沖區超過這個值,立刻關閉該用戶端。

軟性限制(soft limit)。當超過這個值,但是還沒超過硬性限制,會寫入上述提及redis用戶端結構體中obuf_soft_limit_reached_time屬性,作為超過軟性限制開始的時間,之後伺服器會持續監控此用戶端,如果超出規定的軟性限制的時間,則關閉用戶端;如果軟性限制時間之前,用戶端輸出緩沖區内容減小到軟性限制之前,則不關閉用戶端,并且将obuf_soft_limit_reached_time的值清零

Lua腳本的僞用戶端

伺服器會在初始化時出建立負責運作Lua腳本中包含的Redis指令的僞用戶端,并将其存放在redisClient類型的lua_client屬性。該用戶端建立後的整個生命周期中一直會存在,直到伺服器關閉才會關閉。

AOF檔案的僞用戶端

 伺服器載入aof檔案時,會建立用于執行aof檔案包含的Redis指令的僞用戶端,并且載入完畢後關閉該用戶端。

redis伺服器負責和多個用戶端建立連接配接,處理用戶端發送的指令,在資料庫中儲存指令生成的資料,并且通過資源管理來實作自身的運轉。

一個指令請求從發送到獲得回複的過程中, 用戶端和伺服器需要完成一系列操作。

舉個例子, 如果我們使用用戶端執行以下指令:

那麼從用戶端發送 <code>SET KEY VALUE</code> 指令到獲得回複 <code>OK</code> 期間, 用戶端和伺服器共需要執行以下操作:

用戶端向伺服器發送指令請求 <code>SET KEY VALUE</code> 。

伺服器接收并處理用戶端發來的指令請求 <code>SET KEY VALUE</code> , 在資料庫中進行設定操作, 并産生指令回複 <code>OK</code> 。

伺服器将指令回複 <code>OK</code> 發送給用戶端。

用戶端接收伺服器傳回的指令回複 <code>OK</code> , 并将這個回複列印給使用者觀看。

本節接下來的内容将對這些操作的執行細節進行補充, 詳細地說明用戶端和伺服器在執行指令請求時所做的各種工作。

一個指令請求從發送到完成主要包括以下步驟: 1. 用戶端将指令請求發送給伺服器; 2. 伺服器讀取指令請求,并分析出指令參數; 3. 指令執行器根據參數查找指令的實作函數,然後執行實作函數并得出指令回複; 4. 伺服器将指令回複傳回給用戶端。

<code>serverCron</code> 函數預設每隔 <code>100</code> 毫秒執行一次, 它的工作主要包括更新伺服器狀态資訊, 處理伺服器接收的 <code>SIGTERM</code> 信号, 管理用戶端資源和資料庫狀态, 檢查并執行持久化操作, 等等。

伺服器從啟動到能夠處理用戶端的指令請求需要執行以下步驟: 1. 初始化伺服器狀态; 2. 載入伺服器配置; 3. 初始化伺服器資料結構; 4. 還原資料庫狀态; 5. 執行事件循環。

a.用戶端發送指令 

Redis 伺服器的指令請求來自 Redis 用戶端, 當使用者在用戶端中鍵入一個指令請求時, 用戶端會将這個指令請求轉換成協定格式, 然後通過連接配接到伺服器的套接字, 将協定格式的指令請求發送給伺服器, 如圖 14-1 所示。

Redis單機資料庫的實作原理

b.服務端讀取指令

當用戶端與伺服器之間的連接配接套接字因為用戶端的寫入而變得可讀時, 伺服器将調用指令請求處理器來執行以下操作:

讀取套接字中協定格式的指令請求, 并将其儲存到用戶端狀态的輸入緩沖區裡面。

對輸入緩沖區中的指令請求進行分析, 提取出指令請求中包含的指令參數, 以及指令參數的個數, 然後分别将參數和參數個數儲存到用戶端狀态的 <code>argv</code> 屬性和 <code>argc</code> 屬性裡面。

調用指令執行器, 執行用戶端指定的指令。

舉個例子, 假設用戶端執行指令:

伺服器先對指令進行分析, 并将得出的分析結果儲存到用戶端狀态的 <code>argv</code> 屬性和 <code>argc</code> 屬性裡面, 如圖 14-3 所示。

Redis單機資料庫的實作原理

c.服務端指令實作轉換

指令執行器要做的第一件事就是根據用戶端狀态的 <code>argv[0]</code> 參數, 在指令表(command table)中查找參數所指定的指令, 并将找到的指令儲存到用戶端狀态的 <code>cmd</code> 屬性裡面。

 指令表是一個字典, 字典的鍵是一個個指令名字,比如 <code>"set"</code> 、 <code>"get"</code> 、 <code>"del"</code> ,等等; 而字典的值則是一個個 <code>redisCommand</code> 結構, 每個 <code>redisCommand</code> 結構記錄了一個 Redis 指令的實作資訊, 表 14-1 記錄了這個結構的各個主要屬性的類型和作用。

屬性名

類型

作用

<code>name</code>

<code>char *</code>

指令的名字,比如 <code>"set"</code> 。

<code>proc</code>

<code>redisCommandProc *</code>

函數指針,指向指令的實作函數,比如 <code>setCommand</code> 。 <code>redisCommandProc</code> 類型的定義為 <code>typedef void redisCommandProc(redisClient *c);</code> 。

<code>arity</code>

<code>int</code>

指令參數的個數,用于檢查指令請求的格式是否正确。 如果這個值為負數 <code>-N</code> ,那麼表示參數的數量大于等于 <code>N</code> 。 注意指令的名字本身也是一個參數, 比如說 <code>SET msg "hello world"</code> 指令的參數是 <code>"SET"</code> 、 <code>"msg"</code> 、 <code>"hello world"</code> , 而不僅僅是 <code>"msg"</code> 和 <code>"hello world"</code> 。

<code>sflags</code>

字元串形式的辨別值, 這個值記錄了指令的屬性, 比如這個指令是寫指令還是讀指令, 這個指令是否允許在載入資料時使用, 這個指令是否允許在 Lua 腳本中使用, 等等。

<code>flags</code>

對 <code>sflags</code> 辨別進行分析得出的二進制辨別, 由程式自動生成。 伺服器對指令辨別進行檢查時使用的都是 <code>flags</code> 屬性而不是 <code>sflags</code> 屬性, 因為對二進制辨別的檢查可以友善地通過 <code>&amp;</code> 、 <code>^</code> 、 <code>~</code> 等操作來完成。

<code>calls</code>

<code>long long</code>

伺服器總共執行了多少次這個指令。

<code>milliseconds</code>

伺服器執行這個指令所耗費的總時長。

表 14-2 列出了 <code>sflags</code> 屬性可以使用的辨別值, 以及這些辨別的意義。

辨別

意義

帶有這個辨別的指令

<code>w</code>

這是一個寫入指令,可能會修改資料庫。

SET 、 RPUSH 、 DEL ,等等。

<code>r</code>

這是一個隻讀指令,不會修改資料庫。

GET 、 STRLEN 、 EXISTS ,等等。

<code>m</code>

這個指令可能會占用大量記憶體, 執行之前需要先檢查伺服器的記憶體使用情況, 如果記憶體緊缺的話就禁止執行這個指令。

SET 、 APPEND 、 RPUSH 、 LPUSH 、 SADD 、 SINTERSTORE ,等等。

<code>a</code>

這是一個管理指令。

SAVE 、 BGSAVE 、 SHUTDOWN ,等等。

<code>p</code>

這是一個釋出與訂閱功能方面的指令。

PUBLISH 、 SUBSCRIBE 、 PUBSUB ,等等。

<code>s</code>

這個指令不可以在 Lua 腳本中使用。

BRPOP 、 BLPOP 、 BRPOPLPUSH 、 SPOP ,等等。

<code>R</code>

這是一個随機指令, 對于相同的資料集和相同的參數, 指令傳回的結果可能不同。

SPOP 、 SRANDMEMBER 、 SSCAN 、 RANDOMKEY ,等等。

<code>S</code>

當在 Lua 腳本中使用這個指令時, 對這個指令的輸出結果進行一次排序, 使得指令的結果有序。

SINTER 、 SUNION 、 SDIFF 、 SMEMBERS 、 KEYS ,等等。

<code>l</code>

這個指令可以在伺服器載入資料的過程中使用。

INFO 、 SHUTDOWN 、 PUBLISH ,等等。

<code>t</code>

這是一個允許從伺服器在帶有過期資料時使用的指令。

SLAVEOF 、 PING 、 INFO ,等等。

<code>M</code>

這個指令在螢幕(monitor)模式下不會自動被傳播(propagate)。

EXEC

圖 14-4 展示了指令表的樣子, 并且以 SET 指令和 GET 指令作為例子, 展示了 <code>redisCommand</code> 結構:

SET 指令的名字為 <code>"set"</code> , 實作函數為 <code>setCommand</code> ; 指令的參數個數為 <code>-3</code> , 表示指令接受三個或以上數量的參數; 指令的辨別為 <code>"wm"</code> , 表示 SET 指令是一個寫入指令, 并且在執行這個指令之前, 伺服器應該對占用記憶體狀況進行檢查, 因為這個指令可能會占用大量記憶體。

GET 指令的名字為 <code>"get"</code> , 實作函數為 <code>getCommand</code> 函數; 指令的參數個數為 <code>2</code> , 表示指令隻接受兩個參數; 指令的辨別為 <code>"r"</code> , 表示這是一個隻讀指令。

Redis單機資料庫的實作原理
指令名字的大小寫不影響指令表的查找結果 因為指令表使用的是大小寫無關的查找算法, 無論輸入的指令名字是大寫、小寫或者混合大小寫, 隻要指令的名字是正确的, 就能找到相應的 <code>redisCommand</code> 結構。

d.預執行指令

到目前為止, 伺服器已經将執行指令所需的指令實作函數(儲存在用戶端狀态的 <code>cmd</code> 屬性)、參數(儲存在用戶端狀态的 <code>argv</code> 屬性)、參數個數(儲存在用戶端狀态的 <code>argc</code> 屬性)都收集齊了, 但是在真正執行指令之前, 程式還需要進行一些預備操作, 進而確定指令可以正确、順利地被執行, 這些操作包括:

檢查用戶端狀态的 <code>cmd</code> 指針是否指向 <code>NULL</code> , 如果是的話, 那麼說明使用者輸入的指令名字找不到相應的指令實作, 伺服器不再執行後續步驟, 并向用戶端傳回一個錯誤。

根據用戶端 <code>cmd</code> 屬性指向的 <code>redisCommand</code> 結構的 <code>arity</code> 屬性, 檢查指令請求所給定的參數個數是否正确, 當參數個數不正确時, 不再執行後續步驟, 直接向用戶端傳回一個錯誤。 比如說, 如果 <code>redisCommand</code> 結構的 <code>arity</code> 屬性的值為 <code>-3</code> , 那麼使用者輸入的指令參數個數必須大于等于 <code>3</code> 個才行。

檢查用戶端是否已經通過了身份驗證, 未通過身份驗證的用戶端隻能執行 AUTH 指令, 如果未通過身份驗證的用戶端試圖執行除 AUTH 指令之外的其他指令, 那麼伺服器将向用戶端傳回一個錯誤。

如果伺服器打開了 <code>maxmemory</code> 功能, 那麼在執行指令之前, 先檢查伺服器的記憶體占用情況, 并在有需要時進行記憶體回收, 進而使得接下來的指令可以順利執行。 如果記憶體回收失敗, 那麼不再執行後續步驟, 向用戶端傳回一個錯誤。

如果伺服器上一次執行 BGSAVE 指令時出錯, 并且伺服器打開了 <code>stop-writes-on-bgsave-error</code> 功能, 而且伺服器即将要執行的指令是一個寫指令, 那麼伺服器将拒絕執行這個指令, 并向用戶端傳回一個錯誤。

如果用戶端目前正在用 SUBSCRIBE 指令訂閱頻道, 或者正在用 PSUBSCRIBE 指令訂閱模式, 那麼伺服器隻會執行用戶端發來的 SUBSCRIBE 、 PSUBSCRIBE 、 UNSUBSCRIBE 、 PUNSUBSCRIBE 四個指令, 其他别的指令都會被伺服器拒絕。

如果伺服器正在進行資料載入, 那麼用戶端發送的指令必須帶有 <code>l</code> 辨別(比如 INFO 、 SHUTDOWN 、 PUBLISH ,等等)才會被伺服器執行, 其他别的指令都會被伺服器拒絕。

如果伺服器因為執行 Lua 腳本而逾時并進入阻塞狀态, 那麼伺服器隻會執行用戶端發來的 SHUTDOWN nosave 指令和 SCRIPT KILL 指令, 其他别的指令都會被伺服器拒絕。

如果用戶端正在執行事務, 那麼伺服器隻會執行用戶端發來的 EXEC 、 DISCARD 、 MULTI 、 WATCH 四個指令, 其他指令都會被放進事務隊列中。

如果伺服器打開了螢幕功能, 那麼伺服器會将要執行的指令和參數等資訊發送給螢幕。

當完成了以上預備操作之後, 伺服器就可以開始真正執行指令了。

以上隻列出了伺服器在單機模式下執行指令時的檢查操作, 當伺服器在複制或者叢集模式下執行指令時, 預備操作還會更多一些。

e.執行指令

在前面的操作中, 伺服器已經将要執行指令的實作儲存到了用戶端狀态的 <code>cmd</code> 屬性裡面, 并将指令的參數和參數個數分别儲存到了用戶端狀态的 <code>argv</code> 屬性和 <code>argc</code> 屬性裡面, 當伺服器決定要執行指令時, 它隻要執行以下語句就可以了:

該語句等于執行語句:

被調用的指令實作函數會執行指定的操作, 并産生相應的指令回複, 這些回複會被儲存在用戶端狀态的輸出緩沖區裡面(<code>buf</code> 屬性和 <code>reply</code> 屬性), 之後實作函數還會為用戶端的套接字關聯指令回複處理器, 這個處理器負責将指令回複傳回給用戶端。

繼續以之前的 SET 指令為例子, 圖 14-6 展示了用戶端包含了指令實作、參數和參數個數的樣子。

Redis單機資料庫的實作原理

函數調用 <code>setCommand(client);</code> 将産生一個 <code>"+OK\r\n"</code> 回複, 這個回複會被儲存到用戶端狀态的 <code>buf</code> 屬性裡面, 如圖 14-7 所示。

Redis單機資料庫的實作原理

f.後續處理

在執行完實作函數之後, 伺服器還需要執行一些後續工作,當以下操作都執行完了之後, 伺服器對于目前指令的執行到此就告一段落了, 之後伺服器就可以繼續從檔案事件處理器中取出并處理下一個指令請求了。

如果伺服器開啟了慢查詢日志功能, 那麼慢查詢日志子產品會檢查是否需要為剛剛執行完的指令請求添加一條新的慢查詢日志。

根據剛剛執行指令所耗費的時長, 更新被執行指令的 <code>redisCommand</code> 結構的 <code>milliseconds</code> 屬性, 并将指令的 <code>redisCommand</code> 結構的 <code>calls</code> 計數器的值增一。

如果伺服器開啟了 AOF 持久化功能, 那麼 AOF 持久化子產品會将剛剛執行的指令請求寫入到 AOF 緩沖區裡面。

如果有其他從伺服器正在複制目前這個伺服器, 那麼伺服器會将剛剛執行的指令傳播給所有從伺服器。

g.指令回複

前面說過, 指令實作函數會将指令回複儲存到用戶端的輸出緩沖區裡面, 并為用戶端的套接字關聯指令回複處理器, 當用戶端套接字變為可寫狀态時, 伺服器就會執行指令回複處理器, 将儲存在用戶端輸出緩沖區中的指令回複發送給用戶端。

當指令回複發送完畢之後, 回複處理器會清空用戶端狀态的輸出緩沖區, 為處理下一個指令請求做好準備。

h.用戶端接收指令

當用戶端接收到協定格式的指令回複之後, 它會将這些回複轉換成人類可讀的格式, 并列印給使用者觀看(假設我們使用的是 Redis 自帶的 <code>redis-cli</code> 用戶端), 如圖 14-8 所示。

Redis單機資料庫的實作原理

繼續以之前的 SET 指令為例子, 當用戶端接到伺服器發來的 <code>"+OK\r\n"</code> 協定回複時, 它會将這個回複轉換成 <code>"OK\n"</code> , 然後列印給使用者看:

以上就是 Redis 用戶端和伺服器執行指令請求的整個過程了。

redis定時函數——serverCron,該函數,預設情況下,redis每100毫秒執行一次,這個執行間隔可以在配置檔案進行設定。這個函數是用于管理伺服器的資源,保證伺服器更良好的運轉。redis将部分用于此函數的屬性,也存于結構體redisServer之中。

redis的serverCron函數,執行期間需要做11件事,如下:

a.更新伺服器時間緩存。

redis中有許多功能要擷取系統目前時間,則需要調用系統接口查詢時間,這樣比較耗時,是以redis在結構體中用unixtime、mstime屬性,儲存了目前時間,并且定時更新這個值。前者是秒級unix時間戳,後者是毫秒級unix時間戳。

但是,由于每100毫秒才更新是以,因而這兩個值隻會用在列印日志、更新伺服器LRU時鐘、決定是否進行持久化任務、計算伺服器上線時間等,精度要求不高的地方使用。

對于鍵過期時間、慢查詢日志等,伺服器會再次進行系統時間調用,擷取最精确的時間。

b.更新lru始終

lru記錄的是伺服器最後一次被通路的時間,是用于伺服器的計算空轉時長,用屬性lruclock進行存儲。預設情況下,每10秒更新一次。另外,每個redis對象也存了一個lru,儲存的是該對象最後一次被被通路的時間。

當要計算redis對象的空轉時間,則會用伺服器的lru減去redis對象的lru,獲得的結果即對象的空轉時長。

在redis用戶端,用指令objectidletime key,可以檢視該key的空轉時長,傳回結果是以秒為機關。由于redis每10秒更新一次伺服器的最後通路時間,是以不是很精确。

c.更新伺服器每秒執行指令次數

這個不是通過掃描全部的鍵,而是采用抽樣的方式确定的結果。每100毫秒1次,随機抽取一些鍵,檢視最近1秒是否有操作,來确定最近1秒的操作次數。

接着,會将這個值,與上一次的結果,取平均值,作為本次計算的每秒執行指令數。在存入結構體中,供下次取平均值使用。

d.更新伺服器記憶體峰值記錄

redis伺服器中,用stat_peak_memory記錄伺服器記憶體峰值。每次執行serverCron函數,會檢視目前記憶體使用量,并且與stat_peak_memory比較,如果超過這個值,就更新這個屬性。

e.處理sigterm終止信号

redis伺服器,用屬性shutdown_asap記錄目前的結果,0是不用進行操作,1的話是要求伺服器盡快關閉。

是以,伺服器關閉指令shutdown執行,并不會立即關閉伺服器,

而是将伺服器的shutdown_asap屬性置成1,當下一次serverCron讀取時,就會拒絕新的請求,完成目前正在執行的指令後,開始持久化相關的操作,結束持久化後才會關閉伺服器。

f.管理用戶端資源

主要是會檢查用戶端的兩個内容:

1)用戶端很長時間沒有和伺服器響應,伺服器認為該用戶端逾時,則會斷開和該用戶端的連接配接。

2)當用戶端在上一次執行指令請求後,輸入緩沖區超過規定的長度,程式會釋放輸入緩沖區,并建立一個預設大小的緩沖區,防止緩沖區過分消耗。

3)關閉輸出緩沖區超出大小限制的用戶端。

g.管理資料庫資源

主要是檢查鍵是否過期,并且按照配置的政策,删除過期的鍵。如懶惰删除、定期删除等。

h.執行被延遲的bgrewriteaofi.檢查持久化操作的運作狀态

redis用屬性aof_rewrite_scheduled記錄是否有延遲的bgrewriteaof指令。

當執行bgsave指令期間,如果接收到bgrewriteaof指令,不會立即執行該指令,而是會将屬性aof_rewrite_scheduled置成1。

每次執行serverCron函數執行時,發現屬性aof_rewrite_scheduled是1,會檢查目前是否在執行bgsave指令或bgrewriteaof指令,如果沒有在執行這兩個指令,則會執行bgrewriteaof指令。

redis伺服器分别用rdb_child_pid和aof_child_pid屬性,記錄rdb和aof的子程序号(即子程序pid),如果沒有在執行相應的持久化,則值是-1。

1)有一個值不是-1時。

每次伺服器檢查這兩個屬性,發現有一個不是-1,則會檢查子程序是否有信号發來伺服器程序。

如果有信号,表示rdb完成或aof重寫完畢,伺服器會進行後續的操作,比如用新的rdb、aof替換舊的相應檔案。

如果沒信号,表示持久化還沒完成,程式不做動作。

2)兩個值都是-1時。

兩個值都是-1,會進行三個檢查:

如果bgrewriteaof指令有存在延遲(即上述aof_rewrite_scheduled值是1),因為兩個屬性都是 -1,表示目前沒有在持久化,則redis伺服器會開始aof的重寫。

檢查伺服器是否滿足bgsave條件,如果滿足,因為兩個屬性都是 -1,則會開始執行bgsave。

檢查伺服器是否滿足bgrewriteaof條件,如果滿足,因為兩個屬性都是 -1,則會開始執行bgrewriteaof。

Redis單機資料庫的實作原理

j.AOF緩沖區k.關閉異步用戶端

如果開啟aof,redis會記錄每個寫指令,寫入aof緩沖區,但是為了減少磁盤I/O,不會立即寫入aof檔案。而是在執行serverCron函數時,才會開始将緩沖區内容寫入aof檔案。

l.cronloops計數器加一

redis用屬性cronloops儲存serverCron函數執行的次數。當執行一次serverCron,則會将屬性值加1。

這個值目前的作用,是在主從複制情況下,會有一個條件是,每執行n次serverCron,則執行一次指定代碼。

redis伺服器開啟時,會先進行初始化,主要有五個步驟,如下:

a.初始化狀态結構

首先,會建立一個structredisServer執行個體變量,存儲伺服器的狀态。

接着,redis初始化伺服器,會執行一次redis.c/initServerConfig函數,主要工作是設定伺服器運作ID、預設運作頻率、預設配置檔案路徑、運作架構、預設端口号、RDB條件、AOF條件、LRU時鐘、建立指令表。

初始化狀态結構,都是簡單的結構,後續的資料庫、共享對象、慢查詢日志、Lua環境等,都是後面才建立的。

b.加載使用者配置c.初始化資料結構

在啟動redis伺服器時,可以通過參數指定配置檔案、端口号等。redis會載入這些配置,并且和預設不同的時候,會覆寫預設的配置。

例如輸入redis-server –port5000,則會先建立端口基于6379的,再在這一步修改端口号為5000。

在加載使用者配置的檔案,如果有定義新的結果,則使用新結果,否則就使用預設值。

1)建立資料結構

在第一步,隻建立了一個指令表,在此步驟則會建立其他資料結構,包括:

伺服器會為上述結構配置設定記憶體空間。在此步驟才建立資料結構,是因為如果第一步建立,而第二步加載使用者自定義配置的時候,有可能會修改到某些内容,則還需要重寫。而指令表由于是固定的,是以可以放到第一步建立。

2)其他設定操作

除了建立資料結構,還會進行一些重要的設定,包括:

為伺服器設定程序信号處理器。

建立共享對象,包括整數1~10000的字元串對象,“OK”、“ERR”回複的字元串對象等,用于避免反複建立相同對象。

打開伺服器監聽端口,為監聽的套接字添加相應的應答事件,等待伺服器正式運作時接收用戶端的連接配接。

為serverCron函數建立時間事件,等待伺服器正式執行serverCron。

如果AOF持久化開啟,則打開aof檔案,如果不存在則建立aof檔案。

初始化伺服器背景I/O子產品(bio),為将來的I/O做好準備。

d.還原資料庫狀态

如果開啟aof,則載入aof檔案;如果沒有開啟aof,則載入rdb檔案。載入完成後,在日志中列印載入的耗時。

e.執行事件循環

初始化最後一步,伺服器将列印連接配接成功的日志。并且開始事件循環,初始化正式完成,可以開始處理用戶端的請求。

内容參考自《Redis設計與實作》 https://book.douban.com/subject/25900156/

 本文原創,碼字不易,轉載請注明出處!https://www.cnblogs.com/xuxh120/p/14466705.html

繼續閱讀