宕機(jī)了,緩存數(shù)據(jù)沒了。。。
本文轉(zhuǎn)載自微信公眾號「小林coding」,作者小林coding。轉(zhuǎn)載本文請聯(lián)系小林coding公眾號。
AOF 日志
試想一下,如果 Redis 每執(zhí)行一條寫操作命令,就把該命令以追加的方式寫入到一個(gè)文件里,然后重啟 Redis 的時(shí)候,先去讀取這個(gè)文件里的命令,并且執(zhí)行它,這不就相當(dāng)于恢復(fù)了緩存數(shù)據(jù)了嗎?
這種保存寫操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File) 持久化功能,注意只會記錄寫操作命令,讀操作命令是不會被記錄的,因?yàn)闆]意義。
在 Redis 中 AOF 持久化功能默認(rèn)是不開啟的,需要我們修改 redis.conf 配置文件中的以下參數(shù):
AOF 日志文件其實(shí)就是普通的文本,我們可以通過 cat 命令查看里面的內(nèi)容,不過里面的內(nèi)容如果不知道一定的規(guī)則的話,可能會看不懂。
我這里以「set name xiaolin」命令作為例子,Redis 執(zhí)行了這條命令后,記錄在 AOF 日志里的內(nèi)容如下圖:
我這里給大家解釋下。
「*3」表示當(dāng)前命令有三個(gè)部分,每部分都是以「$+數(shù)字」開頭,后面緊跟著具體的命令、鍵或值。然后,這里的「數(shù)字」表示這部分中的命令、鍵或值一共有多少字節(jié)。例如,「$3 set」表示這部分有 3 個(gè)字節(jié),也就是「set」命令這個(gè)字符串的長度。
不知道大家注意到?jīng)]有,Redis 是先執(zhí)行寫操作命令后,才將該命令記錄到 AOF 日志里的,這么做其實(shí)有兩個(gè)好處。
第一個(gè)好處,避免額外的檢查開銷。
因?yàn)槿绻葘懖僮髅钣涗浀?AOF 日志里,再執(zhí)行該命令的話,如果當(dāng)前的命令語法有問題,那么如果不進(jìn)行命令語法檢查,該錯(cuò)誤的命令記錄到 AOF 日志里后,Redis 在使用日志恢復(fù)數(shù)據(jù)時(shí),就可能會出錯(cuò)。
而如果先執(zhí)行寫操作命令再記錄日志的話,只有在該命令執(zhí)行成功后,才將命令記錄到 AOF 日志里,這樣就不用額外的檢查開銷,保證記錄在 AOF 日志里的命令都是可執(zhí)行并且正確的。
第二個(gè)好處,不會阻塞當(dāng)前寫操作命令的執(zhí)行,因?yàn)楫?dāng)寫操作命令執(zhí)行成功后,才會將命令記錄到 AOF 日志。
當(dāng)然,AOF 持久化功能也不是沒有潛在風(fēng)險(xiǎn)。
第一個(gè)風(fēng)險(xiǎn),執(zhí)行寫操作命令和記錄日志是兩個(gè)過程,那當(dāng) Redis 在還沒來得及將命令寫入到硬盤時(shí),服務(wù)器發(fā)生宕機(jī)了,這個(gè)數(shù)據(jù)就會有丟失的風(fēng)險(xiǎn)。
第二個(gè)風(fēng)險(xiǎn),前面說道,由于寫操作命令執(zhí)行成功后才記錄到 AOF 日志,所以不會阻塞當(dāng)前寫操作命令的執(zhí)行,但是可能會給「下一個(gè)」命令帶來阻塞風(fēng)險(xiǎn)。
因?yàn)閷⒚顚懭氲饺罩镜倪@個(gè)操作也是在主進(jìn)程完成的(執(zhí)行命令也是在主進(jìn)程),也就是說這兩個(gè)操作是同步的。
如果在將日志內(nèi)容寫入到硬盤時(shí),服務(wù)器的硬盤的 I/O 壓力太大,就會導(dǎo)致寫硬盤的速度很慢,進(jìn)而阻塞住了,也就會導(dǎo)致后續(xù)的命令無法執(zhí)行。
認(rèn)真分析一下,其實(shí)這兩個(gè)風(fēng)險(xiǎn)都有一個(gè)共性,都跟「 AOF 日志寫回硬盤的時(shí)機(jī)」有關(guān)。
三種寫回策略
Redis 寫入 AOF 日志的過程,如下圖:
我先來具體說說:
- Redis 執(zhí)行完寫操作命令后,會將命令追加到 server.aof_buf 緩沖區(qū);
- 然后通過 write() 系統(tǒng)調(diào)用,將 aof_buf 緩沖區(qū)的數(shù)據(jù)寫入到 AOF 文件,此時(shí)數(shù)據(jù)并沒有寫入到硬盤,而是拷貝到了內(nèi)核緩沖區(qū) page cache,等待內(nèi)核將數(shù)據(jù)寫入硬盤;
- 具體內(nèi)核緩沖區(qū)的數(shù)據(jù)什么時(shí)候?qū)懭氲接脖P,由內(nèi)核決定。
Redis 提供了 3 種寫回硬盤的策略,控制的就是上面說的第三步的過程。
在 redis.conf 配置文件中的 appendfsync 配置項(xiàng)可以有以下 3 種參數(shù)可填:
- Always,這個(gè)單詞的意思是「總是」,所以它的意思是每次寫操作命令執(zhí)行完后,同步將 AOF 日志數(shù)據(jù)寫回硬盤;
- Everysec,這個(gè)單詞的意思是「每秒」,所以它的意思是每次寫操作命令執(zhí)行完后,先將命令寫入到 AOF 文件的內(nèi)核緩沖區(qū),然后每隔一秒將緩沖區(qū)里的內(nèi)容寫回到硬盤;
- No,意味著不由 Redis 控制寫回硬盤的時(shí)機(jī),轉(zhuǎn)交給操作系統(tǒng)控制寫回的時(shí)機(jī),也就是每次寫操作命令執(zhí)行完后,先將命令寫入到 AOF 文件的內(nèi)核緩沖區(qū),再由操作系統(tǒng)決定何時(shí)將緩沖區(qū)內(nèi)容寫回硬盤。
這 3 種寫回策略都無法能完美解決「主進(jìn)程阻塞」和「減少數(shù)據(jù)丟失」的問題,因?yàn)閮蓚€(gè)問題是對立的,偏向于一邊的話,就會要犧牲另外一邊,原因如下:
- Always 策略的話,可以最大程度保證數(shù)據(jù)不丟失,但是由于它每執(zhí)行一條寫操作命令就同步將 AOF 內(nèi)容寫回硬盤,所以是不可避免會影響主進(jìn)程的性能;
- No 策略的話,是交由操作系統(tǒng)來決定何時(shí)將 AOF 日志內(nèi)容寫回硬盤,相比于 Always 策略性能較好,但是操作系統(tǒng)寫回硬盤的時(shí)機(jī)是不可預(yù)知的,如果 AOF 日志內(nèi)容沒有寫回硬盤,一旦服務(wù)器宕機(jī),就會丟失不定數(shù)量的數(shù)據(jù)。
- Everysec 策略的話,是折中的一種方式,避免了 Always 策略的性能開銷,也比 No 策略更能避免數(shù)據(jù)丟失,當(dāng)然如果上一秒的寫操作命令日志沒有寫回到硬盤,發(fā)生了宕機(jī),這一秒內(nèi)的數(shù)據(jù)自然也會丟失。
大家根據(jù)自己的業(yè)務(wù)場景進(jìn)行選擇:
- 如果要高性能,就選擇 No 策略;
- 如果要高可靠,就選擇 Always 策略;
- 如果允許數(shù)據(jù)丟失一點(diǎn),但又想性能高,就選擇 Everysec 策略。
我也把這 3 個(gè)寫回策略的優(yōu)缺點(diǎn)總結(jié)成了一張表格:
大家知道這三種策略是怎么實(shí)現(xiàn)的嗎?
深入到源碼后,你就會發(fā)現(xiàn)這三種策略只是在控制 fsync() 函數(shù)的調(diào)用時(shí)機(jī)。
當(dāng)應(yīng)用程序向文件寫入數(shù)據(jù)時(shí),內(nèi)核通常先將數(shù)據(jù)復(fù)制到內(nèi)核緩沖區(qū)中,然后排入隊(duì)列,然后由內(nèi)核決定何時(shí)寫入硬盤。
如果想要應(yīng)用程序向文件寫入數(shù)據(jù)后,能立馬將數(shù)據(jù)同步到硬盤,就可以調(diào)用 fsync() 函數(shù),這樣內(nèi)核就會將內(nèi)核緩沖區(qū)的數(shù)據(jù)直接寫入到硬盤,等到硬盤寫操作完成后,該函數(shù)才會返回。
- Always 策略就是每次寫入 AOF 文件數(shù)據(jù)后,就執(zhí)行 fsync() 函數(shù);
- Everysec 策略就會創(chuàng)建一個(gè)異步任務(wù)來執(zhí)行 fsync() 函數(shù);
- No 策略就是永不執(zhí)行 fsync() 函數(shù);
AOF 重寫機(jī)制
AOF 日志是一個(gè)文件,隨著執(zhí)行的寫操作命令越來越多,文件的大小會越來越大。
如果當(dāng) AOF 日志文件過大就會帶來性能問題,比如重啟 Redis 后,需要讀 AOF 文件的內(nèi)容以恢復(fù)數(shù)據(jù),如果文件過大,整個(gè)恢復(fù)的過程就會很慢。
所以,Redis 為了避免 AOF 文件越寫越大,提供了 AOF 重寫機(jī)制,當(dāng) AOF 文件的大小超過所設(shè)定的閾值后,Redis 就會啟用 AOF 重寫機(jī)制,來壓縮 AOF 文件。
AOF 重寫機(jī)制是在重寫時(shí),讀取當(dāng)前數(shù)據(jù)庫中的所有鍵值對,然后將每一個(gè)鍵值對用一條命令記錄到「新的 AOF 文件」,等到全部記錄完后,就將新的 AOF 文件替換掉現(xiàn)有的 AOF 文件。
舉個(gè)例子,在沒有使用重寫機(jī)制前,假設(shè)前后執(zhí)行了「set name xiaolin」和「set name xiaolincoding」這兩個(gè)命令的話,就會將這兩個(gè)命令記錄到 AOF 文件。
但是在使用重寫機(jī)制后,就會讀取 name 最新的 value(鍵值對) ,然后用一條 「set name xiaolincoding」命令記錄到新的 AOF 文件,之前的第一個(gè)命令就沒有必要記錄了,因?yàn)樗鼘儆凇笟v史」命令,沒有作用了。這樣一來,一個(gè)鍵值對在重寫日志中只用一條命令就行了。
重寫工作完成后,就會將新的 AOF 文件覆蓋現(xiàn)有的 AOF 文件,這就相當(dāng)于壓縮了 AOF 文件,使得 AOF 文件體積變小了。
然后,在通過 AOF 日志恢復(fù)數(shù)據(jù)時(shí),只用執(zhí)行這條命令,就可以直接完成這個(gè)鍵值對的寫入了。
所以,重寫機(jī)制的妙處在于,盡管某個(gè)鍵值對被多條寫命令反復(fù)修改,最終也只需要根據(jù)這個(gè)「鍵值對」當(dāng)前的最新狀態(tài),然后用一條命令去記錄鍵值對,代替之前記錄這個(gè)鍵值對的多條命令,這樣就減少了 AOF 文件中的命令數(shù)量。最后在重寫工作完成后,將新的 AOF 文件覆蓋現(xiàn)有的 AOF 文件。
這里說一下為什么重寫 AOF 的時(shí)候,不直接復(fù)用現(xiàn)有的 AOF 文件,而是先寫到新的 AOF 文件再覆蓋過去。
因?yàn)槿绻?AOF 重寫過程中失敗了,現(xiàn)有的 AOF 文件就會造成污染,可能無法用于恢復(fù)使用。
所以 AOF 重寫過程,先重寫到新的 AOF 文件,重寫失敗的話,就直接刪除這個(gè)文件就好,不會對現(xiàn)有的 AOF 文件造成影響。
AOF 后臺重寫
寫入 AOF 日志的操作雖然是在主進(jìn)程完成的,因?yàn)樗鼘懭氲膬?nèi)容不多,所以一般不太影響命令的操作。
但是在觸發(fā) AOF 重寫時(shí),比如當(dāng) AOF 文件大于 64M 時(shí),就會對 AOF 文件進(jìn)行重寫,這時(shí)是需要讀取所有緩存的鍵值對數(shù)據(jù),并為每個(gè)鍵值對生成一條命令,然后將其寫入到新的 AOF 文件,重寫完后,就把現(xiàn)在的 AOF 文件替換掉。
這個(gè)過程其實(shí)是很耗時(shí)的,所以重寫的操作不能放在主進(jìn)程里。
所以,Redis 的重寫 AOF 過程是由后臺子進(jìn)程 bgrewriteaof 來完成的,這么做可以達(dá)到兩個(gè)好處:
子進(jìn)程進(jìn)行 AOF 重寫期間,主進(jìn)程可以繼續(xù)處理命令請求,從而避免阻塞主進(jìn)程;
子進(jìn)程帶有主進(jìn)程的數(shù)據(jù)副本(數(shù)據(jù)副本怎么產(chǎn)生的后面會說),這里使用子進(jìn)程而不是線程,因?yàn)槿绻鞘褂镁€程,多線程之間會共享內(nèi)存,那么在修改共享內(nèi)存數(shù)據(jù)的時(shí)候,需要通過加鎖來保證數(shù)據(jù)的安全,而這樣就會降低性能。而使用子進(jìn)程,創(chuàng)建子進(jìn)程時(shí),父子進(jìn)程是共享內(nèi)存數(shù)據(jù)的,不過這個(gè)共享的內(nèi)存只能以只讀的方式,而當(dāng)父子進(jìn)程任意一方修改了該共享內(nèi)存,就會發(fā)生「寫時(shí)復(fù)制」,于是父子進(jìn)程就有了獨(dú)立的數(shù)據(jù)副本,就不用加鎖來保證數(shù)據(jù)安全。
子進(jìn)程是怎么擁有主進(jìn)程一樣的數(shù)據(jù)副本的呢?
主進(jìn)程在通過 fork 系統(tǒng)調(diào)用生成 bgrewriteaof 子進(jìn)程時(shí),操作系統(tǒng)會把主進(jìn)程的「頁表」復(fù)制一份給子進(jìn)程,這個(gè)頁表記錄著虛擬地址和物理地址映射關(guān)系,而不會復(fù)制物理內(nèi)存,也就是說,兩者的虛擬空間不同,但其對應(yīng)的物理空間是同一個(gè)。
這樣一來,子進(jìn)程就共享了父進(jìn)程的物理內(nèi)存數(shù)據(jù)了,這樣能夠節(jié)約物理內(nèi)存資源,頁表對應(yīng)的頁表項(xiàng)的屬性會標(biāo)記該物理內(nèi)存的權(quán)限為只讀。
不過,當(dāng)父進(jìn)程或者子進(jìn)程在向這個(gè)內(nèi)存發(fā)起寫操作時(shí),CPU 就會觸發(fā)缺頁中斷,這個(gè)缺頁中斷是由于違反權(quán)限導(dǎo)致的,然后操作系統(tǒng)會在「缺頁異常處理函數(shù)」里進(jìn)行物理內(nèi)存的復(fù)制,并重新設(shè)置其內(nèi)存映射關(guān)系,將父子進(jìn)程的內(nèi)存讀寫權(quán)限設(shè)置為可讀寫,最后才會對內(nèi)存進(jìn)行寫操作,這個(gè)過程被稱為「寫時(shí)復(fù)制(Copy On Write)」。
寫時(shí)復(fù)制顧名思義,在發(fā)生寫操作的時(shí)候,操作系統(tǒng)才會去復(fù)制物理內(nèi)存,這樣是為了防止 fork 創(chuàng)建子進(jìn)程時(shí),由于物理內(nèi)存數(shù)據(jù)的復(fù)制時(shí)間過長而導(dǎo)致父進(jìn)程長時(shí)間阻塞的問題。
當(dāng)然,操作系統(tǒng)復(fù)制父進(jìn)程頁表的時(shí)候,父進(jìn)程也是阻塞中的,不過頁表的大小相比實(shí)際的物理內(nèi)存小很多,所以通常復(fù)制頁表的過程是比較快的。
不過,如果父進(jìn)程的內(nèi)存數(shù)據(jù)非常大,那自然頁表也會很大,這時(shí)父進(jìn)程在通過 fork 創(chuàng)建子進(jìn)程的時(shí)候,阻塞的時(shí)間也越久。
所以,有兩個(gè)階段會導(dǎo)致阻塞父進(jìn)程:
- 創(chuàng)建子進(jìn)程的途中,由于要復(fù)制父進(jìn)程的頁表等數(shù)據(jù)結(jié)構(gòu),阻塞的時(shí)間跟頁表的大小有關(guān),頁表越大,阻塞的時(shí)間也越長;
- 創(chuàng)建完子進(jìn)程后,如果子進(jìn)程或者父進(jìn)程修改了共享數(shù)據(jù),就會發(fā)生寫時(shí)復(fù)制,這期間會拷貝物理內(nèi)存,如果內(nèi)存越大,自然阻塞的時(shí)間也越長;
觸發(fā)重寫機(jī)制后,主進(jìn)程就會創(chuàng)建重寫 AOF 的子進(jìn)程,此時(shí)父子進(jìn)程共享物理內(nèi)存,重寫子進(jìn)程只會對這個(gè)內(nèi)存進(jìn)行只讀,重寫 AOF 子進(jìn)程會讀取數(shù)據(jù)庫里的所有數(shù)據(jù),并逐一把內(nèi)存數(shù)據(jù)的鍵值對轉(zhuǎn)換成一條命令,再將命令記錄到重寫日志(新的 AOF 文件)。
但是子進(jìn)程重寫過程中,主進(jìn)程依然可以正常處理命令。
如果此時(shí)主進(jìn)程修改了已經(jīng)存在 key-value,就會發(fā)生寫時(shí)復(fù)制,注意這里只會復(fù)制主進(jìn)程修改的物理內(nèi)存數(shù)據(jù),沒修改物理內(nèi)存還是與子進(jìn)程共享的。
所以如果這個(gè)階段修改的是一個(gè) bigkey,也就是數(shù)據(jù)量比較大的 key-value 的時(shí)候,這時(shí)復(fù)制的物理內(nèi)存數(shù)據(jù)的過程就會比較耗時(shí),有阻塞主進(jìn)程的風(fēng)險(xiǎn)。
還有個(gè)問題,重寫 AOF 日志過程中,如果主進(jìn)程修改了已經(jīng)存在 key-value,此時(shí)這個(gè) key-value 數(shù)據(jù)在子進(jìn)程的內(nèi)存數(shù)據(jù)就跟主進(jìn)程的內(nèi)存數(shù)據(jù)不一致了,這時(shí)要怎么辦呢?
為了解決這種數(shù)據(jù)不一致問題,Redis 設(shè)置了一個(gè) AOF 重寫緩沖區(qū),這個(gè)緩沖區(qū)在創(chuàng)建 bgrewriteaof 子進(jìn)程之后開始使用。
在重寫 AOF 期間,當(dāng) Redis 執(zhí)行完一個(gè)寫命令之后,它會同時(shí)將這個(gè)寫命令寫入到 「AOF 緩沖區(qū)」和 「AOF 重寫緩沖區(qū)」。
也就是說,在 bgrewriteaof 子進(jìn)程執(zhí)行 AOF 重寫期間,主進(jìn)程需要執(zhí)行以下三個(gè)工作:
- 執(zhí)行客戶端發(fā)來的命令;
- 將執(zhí)行后的寫命令追加到 「AOF 緩沖區(qū)」;
- 將執(zhí)行后的寫命令追加到 「AOF 重寫緩沖區(qū)」;
當(dāng)子進(jìn)程完成 AOF 重寫工作(掃描數(shù)據(jù)庫中所有數(shù)據(jù),逐一把內(nèi)存數(shù)據(jù)的鍵值對轉(zhuǎn)換成一條命令,再將命令記錄到重寫日志)后,會向主進(jìn)程發(fā)送一條信號,信號是進(jìn)程間通訊的一種方式,且是異步的。
主進(jìn)程收到該信號后,會調(diào)用一個(gè)信號處理函數(shù),該函數(shù)主要做以下工作:
- 將 AOF 重寫緩沖區(qū)中的所有內(nèi)容追加到新的 AOF 的文件中,使得新舊兩個(gè) AOF 文件所保存的數(shù)據(jù)庫狀態(tài)一致;
- 新的 AOF 的文件進(jìn)行改名,覆蓋現(xiàn)有的 AOF 文件。
信號函數(shù)執(zhí)行完后,主進(jìn)程就可以繼續(xù)像往常一樣處理命令了。
在整個(gè) AOF 后臺重寫過程中,除了發(fā)生寫時(shí)復(fù)制會對主進(jìn)程造成阻塞,還有信號處理函數(shù)執(zhí)行時(shí)也會對主進(jìn)程造成阻塞,在其他時(shí)候,AOF 后臺重寫都不會阻塞主進(jìn)程。
總結(jié)
這次小林給大家介紹了 Redis 持久化技術(shù)中的 AOF 方法,這個(gè)方法是每執(zhí)行一條寫操作命令,就將該命令以追加的方式寫入到 AOF 文件,然后在恢復(fù)時(shí),以逐一執(zhí)行命令的方式來進(jìn)行數(shù)據(jù)恢復(fù)。
Redis 提供了三種將 AOF 日志寫回硬盤的策略,分別是 Always、Everysec 和 No,這三種策略在可靠性上是從高到低,而在性能上則是從低到高。
隨著執(zhí)行的命令越多,AOF 文件的體積自然也會越來越大,為了避免日志文件過大, Redis 提供了 AOF 重寫機(jī)制,它會直接掃描數(shù)據(jù)中所有的鍵值對數(shù)據(jù),然后為每一個(gè)鍵值對生成一條寫操作命令,接著將該命令寫入到新的 AOF 文件,重寫完成后,就替換掉現(xiàn)有的 AOF 日志。重寫的過程是由后臺子進(jìn)程完成的,這樣可以使得主進(jìn)程可以繼續(xù)正常處理命令。
用 AOF 日志的方式來恢復(fù)數(shù)據(jù)其實(shí)是很慢的,因?yàn)?Redis 執(zhí)行命令由單線程負(fù)責(zé)的,而 AOF 日志恢復(fù)數(shù)據(jù)的方式是順序執(zhí)行日志里的每一條命令,如果 AOF 日志很大,這個(gè)「重放」的過程就會很慢了。
參考資料
《Redis設(shè)計(jì)與實(shí)現(xiàn)》
《Redis核心技術(shù)與實(shí)戰(zhàn)-極客時(shí)間》
《Redis源碼分析》