圖解 Redis | 不就是 AOF 持久化嘛

AOF 日誌

試想一下,如果 Redis 每執行一條寫操作命令,就把該命令以追加的方式寫入到一個文件裏,然後重啓 Redis 的時候,先去讀取這個文件裏的命令,並且執行它,這不就相當於恢復了緩存數據了嗎?

這種保存寫操作命令到日誌的持久化方式,就是 Redis 裏的 AOF(Append Only File) 持久化功能,注意只會記錄寫操作命令,讀操作命令是不會被記錄的,因爲沒意義。

在 Redis 中 AOF 持久化功能默認是不開啓的,需要我們修改 redis.conf 配置文件中的以下參數:

AOF 日誌文件其實就是普通的文本,我們可以通過 cat 命令查看裏面的內容,不過裏面的內容如果不知道一定的規則的話,可能會看不懂。

我這裏以「set name xiaolin」命令作爲例子,Redis 執行了這條命令後,記錄在 AOF 日誌裏的內容如下圖:

我這裏給大家解釋下。

*3」表示當前命令有三個部分,每部分都是以「$+數字」開頭,後面緊跟着具體的命令、鍵或值。然後,這裏的「數字」表示這部分中的命令、鍵或值一共有多少字節。例如,「$3 set」表示這部分有 3 個字節,也就是「set」命令這個字符串的長度。

不知道大家注意到沒有,Redis 是先執行寫操作命令後,纔將該命令記錄到 AOF 日誌裏的,這麼做其實有兩個好處。

第一個好處,避免額外的檢查開銷。

因爲如果先將寫操作命令記錄到 AOF 日誌裏,再執行該命令的話,如果當前的命令語法有問題,那麼如果不進行命令語法檢查,該錯誤的命令記錄到 AOF 日誌裏後,Redis 在使用日誌恢復數據時,就可能會出錯。

而如果先執行寫操作命令再記錄日誌的話,只有在該命令執行成功後,纔將命令記錄到 AOF 日誌裏,這樣就不用額外的檢查開銷,保證記錄在 AOF 日誌裏的命令都是可執行並且正確的。

第二個好處,不會阻塞當前寫操作命令的執行,因爲當寫操作命令執行成功後,纔會將命令記錄到 AOF 日誌。

當然,AOF 持久化功能也不是沒有潛在風險。

第一個風險,執行寫操作命令和記錄日誌是兩個過程,那當 Redis 在還沒來得及將命令寫入到硬盤時,服務器發生宕機了,這個數據就會有丟失的風險

第二個風險,前面說道,由於寫操作命令執行成功後才記錄到 AOF 日誌,所以不會阻塞當前寫操作命令的執行,但是可能會給「下一個」命令帶來阻塞風險

因爲將命令寫入到日誌的這個操作也是在主進程完成的(執行命令也是在主進程),也就是說這兩個操作是同步的。

如果在將日誌內容寫入到硬盤時,服務器的硬盤的 I/O 壓力太大,就會導致寫硬盤的速度很慢,進而阻塞住了,也就會導致後續的命令無法執行。

認真分析一下,其實這兩個風險都有一個共性,都跟「 AOF 日誌寫回硬盤的時機」有關。

三種寫回策略

Redis 寫入 AOF 日誌的過程,如下圖:

我先來具體說說:

  1. Redis 執行完寫操作命令後,會將命令追加到 server.aof_buf 緩衝區;
  2. 然後通過 write() 系統調用,將 aof_buf 緩衝區的數據寫入到 AOF 文件,此時數據並沒有寫入到硬盤,而是拷貝到了內核緩衝區 page cache,等待內核將數據寫入硬盤;
  3. 具體內核緩衝區的數據什麼時候寫入到硬盤,由內核決定。

Redis 提供了 3 種寫回硬盤的策略,控制的就是上面說的第三步的過程。

redis.conf 配置文件中的 appendfsync 配置項可以有以下 3 種參數可填:

  • Always,這個單詞的意思是「總是」,所以它的意思是每次寫操作命令執行完後,同步將 AOF 日誌數據寫回硬盤;
  • Everysec,這個單詞的意思是「每秒」,所以它的意思是每次寫操作命令執行完後,先將命令寫入到 AOF 文件的內核緩衝區,然後每隔一秒將緩衝區裏的內容寫回到硬盤;
  • No,意味着不由 Redis 控制寫回硬盤的時機,轉交給操作系統控制寫回的時機,也就是每次寫操作命令執行完後,先將命令寫入到 AOF 文件的內核緩衝區,再由操作系統決定何時將緩衝區內容寫回硬盤。

這 3 種寫回策略都無法能完美解決「主進程阻塞」和「減少數據丟失」的問題,因爲兩個問題是對立的,偏向於一邊的話,就會要犧牲另外一邊,原因如下:

  • Always 策略的話,可以最大程度保證數據不丟失,但是由於它每執行一條寫操作命令就同步將 AOF 內容寫回硬盤,所以是不可避免會影響主進程的性能;
  • No 策略的話,是交由操作系統來決定何時將 AOF 日誌內容寫回硬盤,相比於 Always 策略性能較好,但是操作系統寫回硬盤的時機是不可預知的,如果 AOF 日誌內容沒有寫回硬盤,一旦服務器宕機,就會丟失不定數量的數據。
  • Everysec 策略的話,是折中的一種方式,避免了 Always 策略的性能開銷,也比 No 策略更能避免數據丟失,當然如果上一秒的寫操作命令日誌沒有寫回到硬盤,發生了宕機,這一秒內的數據自然也會丟失。

大家根據自己的業務場景進行選擇:

  • 如果要高性能,就選擇 No 策略;
  • 如果要高可靠,就選擇 Always 策略;
  • 如果允許數據丟失一點,但又想性能高,就選擇 Everysec 策略。

我也把這 3 個寫回策略的優缺點總結成了一張表格:

大家知道這三種策略是怎麼實現的嗎?

深入到源碼後,你就會發現這三種策略只是在控制 fsync() 函數的調用時機。

當應用程序向文件寫入數據時,內核通常先將數據複製到內核緩衝區中,然後排入隊列,然後由內核決定何時寫入硬盤。

如果想要應用程序向文件寫入數據後,能立馬將數據同步到硬盤,就可以調用 fsync() 函數,這樣內核就會將內核緩衝區的數據直接寫入到硬盤,等到硬盤寫操作完成後,該函數纔會返回。

  • Always 策略就是每次寫入 AOF 文件數據後,就執行 fsync() 函數;
  • Everysec 策略就會創建一個異步任務來執行 fsync() 函數;
  • No 策略就是永不執行 fsync() 函數;

AOF 重寫機制

AOF 日誌是一個文件,隨着執行的寫操作命令越來越多,文件的大小會越來越大。

如果當 AOF 日誌文件過大就會帶來性能問題,比如重啓 Redis 後,需要讀 AOF 文件的內容以恢復數據,如果文件過大,整個恢復的過程就會很慢。

所以,Redis 爲了避免 AOF 文件越寫越大,提供了 AOF 重寫機制,當 AOF 文件的大小超過所設定的閾值後,Redis 就會啓用 AOF 重寫機制,來壓縮 AOF 文件。

AOF 重寫機制是在重寫時,讀取當前數據庫中的所有鍵值對,然後將每一個鍵值對用一條命令記錄到「新的 AOF 文件」,等到全部記錄完後,就將新的 AOF 文件替換掉現有的 AOF 文件。

舉個例子,在沒有使用重寫機制前,假設前後執行了「set name xiaolin」和「set name xiaolincoding」這兩個命令的話,就會將這兩個命令記錄到 AOF 文件。

但是在使用重寫機制後,就會讀取 name 最新的 value(鍵值對) ,然後用一條 「set name xiaolincoding」命令記錄到新的 AOF 文件,之前的第一個命令就沒有必要記錄了,因爲它屬於「歷史」命令,沒有作用了。這樣一來,一個鍵值對在重寫日誌中只用一條命令就行了。

重寫工作完成後,就會將新的 AOF 文件覆蓋現有的 AOF 文件,這就相當於壓縮了 AOF 文件,使得 AOF 文件體積變小了。

然後,在通過 AOF 日誌恢復數據時,只用執行這條命令,就可以直接完成這個鍵值對的寫入了。

所以,重寫機制的妙處在於,儘管某個鍵值對被多條寫命令反覆修改,最終也只需要根據這個「鍵值對」當前的最新狀態,然後用一條命令去記錄鍵值對,代替之前記錄這個鍵值對的多條命令,這樣就減少了 AOF 文件中的命令數量。最後在重寫工作完成後,將新的 AOF 文件覆蓋現有的 AOF 文件。

這裏說一下爲什麼重寫 AOF 的時候,不直接複用現有的 AOF 文件,而是先寫到新的 AOF 文件再覆蓋過去。

因爲如果 AOF 重寫過程中失敗了,現有的 AOF 文件就會造成污染,可能無法用於恢復使用。

所以 AOF 重寫過程,先重寫到新的 AOF 文件,重寫失敗的話,就直接刪除這個文件就好,不會對現有的 AOF 文件造成影響。

AOF 後臺重寫

寫入 AOF 日誌的操作雖然是在主進程完成的,因爲它寫入的內容不多,所以一般不太影響命令的操作。

但是在觸發 AOF 重寫時,比如當 AOF 文件大於 64M 時,就會對 AOF 文件進行重寫,這時是需要讀取所有緩存的鍵值對數據,併爲每個鍵值對生成一條命令,然後將其寫入到新的 AOF 文件,重寫完後,就把現在的 AOF 文件替換掉。

這個過程其實是很耗時的,所以重寫的操作不能放在主進程裏。

所以,Redis 的重寫 AOF 過程是由後臺子進程 bgrewriteaof 來完成的,這麼做可以達到兩個好處:

  • 子進程進行 AOF 重寫期間,主進程可以繼續處理命令請求,從而避免阻塞主進程;
  • 子進程帶有主進程的數據副本(數據副本怎麼產生的後面會說),這裏使用子進程而不是線程,因爲如果是使用線程,多線程之間會共享內存,那麼在修改共享內存數據的時候,需要通過加鎖來保證數據的安全,而這樣就會降低性能。而使用子進程,創建子進程時,父子進程是共享內存數據的,不過這個共享的內存只能以只讀的方式,而當父子進程任意一方修改了該共享內存,就會發生「寫時複製」,於是父子進程就有了獨立的數據副本,就不用加鎖來保證數據安全。

子進程是怎麼擁有主進程一樣的數據副本的呢?

主進程在通過 fork 系統調用生成 bgrewriteaof 子進程時,操作系統會把主進程的「頁表」複製一份給子進程,這個頁表記錄着虛擬地址和物理地址映射關係,而不會複製物理內存,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。

這樣一來,子進程就共享了父進程的物理內存數據了,這樣能夠節約物理內存資源,頁表對應的頁表項的屬性會標記該物理內存的權限爲只讀

不過,當父進程或者子進程在向這個內存發起寫操作時,CPU 就會觸發缺頁中斷,這個缺頁中斷是由於違反權限導致的,然後操作系統會在「缺頁異常處理函數」裏進行物理內存的複製,並重新設置其內存映射關係,將父子進程的內存讀寫權限設置爲可讀寫,最後纔會對內存進行寫操作,這個過程被稱爲「寫時複製(Copy On Write)」。

寫時複製顧名思義,在發生寫操作的時候,操作系統纔會去複製物理內存,這樣是爲了防止 fork 創建子進程時,由於物理內存數據的複製時間過長而導致父進程長時間阻塞的問題。

當然,操作系統複製父進程頁表的時候,父進程也是阻塞中的,不過頁表的大小相比實際的物理內存小很多,所以通常複製頁表的過程是比較快的。

不過,如果父進程的內存數據非常大,那自然頁表也會很大,這時父進程在通過 fork 創建子進程的時候,阻塞的時間也越久。

所以,有兩個階段會導致阻塞父進程:

  • 創建子進程的途中,由於要複製父進程的頁表等數據結構,阻塞的時間跟頁表的大小有關,頁表越大,阻塞的時間也越長;
  • 創建完子進程後,如果子進程或者父進程修改了共享數據,就會發生寫時複製,這期間會拷貝物理內存,如果內存越大,自然阻塞的時間也越長;

觸發重寫機制後,主進程就會創建重寫 AOF 的子進程,此時父子進程共享物理內存,重寫子進程只會對這個內存進行只讀,重寫 AOF 子進程會讀取數據庫裏的所有數據,並逐一把內存數據的鍵值對轉換成一條命令,再將命令記錄到重寫日誌(新的 AOF 文件)。

但是子進程重寫過程中,主進程依然可以正常處理命令。

如果此時主進程修改了已經存在 key-value,就會發生寫時複製,注意這裏只會複製主進程修改的物理內存數據,沒修改物理內存還是與子進程共享的

所以如果這個階段修改的是一個 bigkey,也就是數據量比較大的 key-value 的時候,這時複製的物理內存數據的過程就會比較耗時,有阻塞主進程的風險。

還有個問題,重寫 AOF 日誌過程中,如果主進程修改了已經存在 key-value,此時這個 key-value 數據在子進程的內存數據就跟主進程的內存數據不一致了,這時要怎麼辦呢?

爲了解決這種數據不一致問題,Redis 設置了一個 AOF 重寫緩衝區,這個緩衝區在創建 bgrewriteaof 子進程之後開始使用。

在重寫 AOF 期間,當 Redis 執行完一個寫命令之後,它會同時將這個寫命令寫入到 「AOF 緩衝區」和 「AOF 重寫緩衝區」

在這裏插入圖片描述在這裏插入圖片描述

也就是說,在 bgrewriteaof 子進程執行 AOF 重寫期間,主進程需要執行以下三個工作:

  • 執行客戶端發來的命令;
  • 將執行後的寫命令追加到 「AOF 緩衝區」;
  • 將執行後的寫命令追加到 「AOF 重寫緩衝區」;

當子進程完成 AOF 重寫工作(掃描數據庫中所有數據,逐一把內存數據的鍵值對轉換成一條命令,再將命令記錄到重寫日誌)後,會向主進程發送一條信號,信號是進程間通訊的一種方式,且是異步的。

主進程收到該信號後,會調用一個信號處理函數,該函數主要做以下工作:

  • 將 AOF 重寫緩衝區中的所有內容追加到新的 AOF 的文件中,使得新舊兩個 AOF 文件所保存的數據庫狀態一致;
  • 新的 AOF 的文件進行改名,覆蓋現有的 AOF 文件。

信號函數執行完後,主進程就可以繼續像往常一樣處理命令了。

在整個 AOF 後臺重寫過程中,除了發生寫時複製會對主進程造成阻塞,還有信號處理函數執行時也會對主進程造成阻塞,在其他時候,AOF 後臺重寫都不會阻塞主進程。

總結

這次小林給大家介紹了 Redis 持久化技術中的 AOF 方法,這個方法是每執行一條寫操作命令,就將該命令以追加的方式寫入到 AOF 文件,然後在恢復時,以逐一執行命令的方式來進行數據恢復。

Redis 提供了三種將 AOF 日誌寫回硬盤的策略,分別是 Always、Everysec 和 No,這三種策略在可靠性上是從高到低,而在性能上則是從低到高。

隨着執行的命令越多,AOF 文件的體積自然也會越來越大,爲了避免日誌文件過大, Redis 提供了 AOF 重寫機制,它會直接掃描數據中所有的鍵值對數據,然後爲每一個鍵值對生成一條寫操作命令,接着將該命令寫入到新的 AOF 文件,重寫完成後,就替換掉現有的 AOF 日誌。重寫的過程是由後臺子進程完成的,這樣可以使得主進程可以繼續正常處理命令。

用 AOF 日誌的方式來恢復數據其實是很慢的,因爲 Redis 執行命令由單線程負責的,而 AOF 日誌恢復數據的方式是順序執行日誌裏的每一條命令,如果 AOF 日誌很大,這個「重放」的過程就會很慢了。


參考資料
  • 《Redis設計與實現》
  • 《Redis核心技術與實戰-極客時間》
  • 《Redis源碼分析》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章