Redis(7)——持久化【一文了解】

  • 2020 年 3 月 13 日
  • 筆記

一、持久化簡介

Redis 的數據 全部存儲記憶體 中,如果 突然宕機,數據就會全部丟失,因此必須有一套機制來保證 Redis 的數據不會因為故障而丟失,這種機制就是 Redis 的 持久化機制,它會將記憶體中的資料庫狀態 保存到磁碟 中。

持久化發生了什麼 | 從記憶體到磁碟

我們來稍微考慮一下 Redis 作為一個 "記憶體資料庫" 要做的關於持久化的事情。通常來說,從客戶端發起請求開始,到伺服器真實地寫入磁碟,需要發生如下幾件事情:

詳細版 的文字描述大概就是下面這樣:

  1. 客戶端向資料庫 發送寫命令 (數據在客戶端的記憶體中)
  2. 資料庫 接收 到客戶端的 寫請求 (數據在伺服器的記憶體中)
  3. 資料庫 調用系統 API 將數據寫入磁碟 (數據在內核緩衝區中)
  4. 作業系統將 寫緩衝區 傳輸到 磁碟控控制器 (數據在磁碟快取中)
  5. 作業系統的磁碟控制器將數據 寫入實際的物理媒介(數據在磁碟中)

注意: 上面的過程其實是 極度精簡 的,在實際的作業系統中,快取緩衝區 會比這 多得多

如何儘可能保證持久化的安全

如果我們故障僅僅涉及到 軟體層面 (該進程被管理員終止或程式崩潰) 並且沒有接觸到內核,那麼在 上述步驟 3 成功返回之後,我們就認為成功了。即使進程崩潰,作業系統仍然會幫助我們把數據正確地寫入磁碟。

如果我們考慮 停電/ 火災更具災難性 的事情,那麼只有在完成了第 5 步之後,才是安全的。

機房」火了「

所以我們可以總結得出數據安全最重要的階段是:步驟三、四、五,即:

  • 資料庫軟體調用寫操作將用戶空間的緩衝區轉移到內核緩衝區的頻率是多少?
  • 內核多久從緩衝區取數據刷新到磁碟控制器?
  • 磁碟控制器多久把數據寫入物理媒介一次?
  • 注意: 如果真的發生災難性的事件,我們可以從上圖的過程中看到,任何一步都可能被意外打斷丟失,所以只能 儘可能地保證 數據的安全,這對於所有資料庫來說都是一樣的。

我們從 第三步 開始。Linux 系統提供了清晰、易用的用於操作文件的 POSIX file API20 多年過去,仍然還有很多人對於這一套 API 的設計津津樂道,我想其中一個原因就是因為你光從 API 的命名就能夠很清晰地知道這一套 API 的用途:

int open(const char *path, int oflag, .../*,mode_t mode */);  int close (int filedes);int remove( const char *fname );  ssize_t write(int fildes, const void *buf, size_t nbyte);  ssize_t read(int fildes, void *buf, size_t nbyte);

所以,我們有很好的可用的 API 來完成 第三步,但是對於成功返回之前,我們對系統調用花費的時間沒有太多的控制權。

然後我們來說說 第四步。我們知道,除了早期對電腦特別了解那幫人 (作業系統就這幫人搞的),實際的物理硬體都不是我們能夠 直接操作 的,都是通過 作業系統調用 來達到目的的。為了防止過慢的 I/O 操作拖慢整個系統的運行,作業系統層面做了很多的努力,譬如說 上述第四步 提到的 寫緩衝區,並不是所有的寫操作都會被立即寫入磁碟,而是要先經過一個緩衝區,默認情況下,Linux 將在 30 秒 後實際提交寫入。

image

但是很明顯,30 秒 並不是 Redis 能夠承受的,這意味著,如果發生故障,那麼最近 30 秒內寫入的所有數據都可能會丟失。幸好 PROSIX API 提供了另一個解決方案:fsync,該命令會 強制 內核將 緩衝區 寫入 磁碟,但這是一個非常消耗性能的操作,每次調用都會 阻塞等待 直到設備報告 IO 完成,所以一般在生產環境的伺服器中,Redis 通常是每隔 1s 左右執行一次 fsync 操作。

到目前為止,我們了解到了如何控制 第三步第四步,但是對於 第五步,我們 完全無法控制。也許一些內核實現將試圖告訴驅動實際提交物理介質上的數據,或者控制器可能會為了提高速度而重新排序寫操作,不會儘快將數據真正寫到磁碟上,而是會等待幾個多毫秒。這完全是我們無法控制的。

二、Redis 中的兩種持久化方式

方式一:快照

image

Redis 快照 是最簡單的 Redis 持久性模式。當滿足特定條件時,它將生成數據集的時間點快照,例如,如果先前的快照是在2分鐘前創建的,並且現在已經至少有 100 次新寫入,則將創建一個新的快照。此條件可以由用戶配置 Redis 實例來控制,也可以在運行時修改而無需重新啟動伺服器。快照作為包含整個數據集的單個 .rdb 文件生成。

但我們知道,Redis 是一個 單執行緒 的程式,這意味著,我們不僅僅要響應用戶的請求,還需要進行記憶體快照。而後者要求 Redis 必須進行 IO 操作,這會嚴重拖累伺服器的性能。

還有一個重要的問題是,我們在 持久化的同時記憶體數據結構 還可能在 變化,比如一個大型的 hash 字典正在持久化,結果一個請求過來把它刪除了,可是這才剛持久化結束,咋辦?

image

使用系統多進程 COW(Copy On Write) 機制 | fork 函數

作業系統多進程 COW(Copy On Write) 機制 拯救了我們。Redis 在持久化時會調用 glibc 的函數 fork 產生一個子進程,簡單理解也就是基於當前進程 複製 了一個進程,主進程和子進程會共享記憶體裡面的程式碼塊和數據段:

這裡多說一點,為什麼 fork 成功調用後會有兩個返回值呢? 因為子進程在複製時複製了父進程的堆棧段,所以兩個進程都停留在了 fork 函數中 (都在同一個地方往下繼續"同時"執行),等待返回,所以 一次在父進程中返回子進程的 pid,另一次在子進程中返回零,系統資源不夠時返回負數(偽程式碼如下)

pid = os.fork()  if pid > 0:    handle_client_request()  # 父進程繼續處理客戶端請求  if pid == 0:    handle_snapshot_write()  # 子進程處理快照寫磁碟  if pid < 0:    # fork error

所以 快照持久化 可以完全交給 子進程 來處理,父進程 則繼續 處理客戶端請求子進程 做數據持久化,它 不會修改現有的記憶體數據結構,它只是對數據結構進行遍歷讀取,然後序列化寫到磁碟中。但是 父進程 不一樣,它必須持續服務客戶端請求,然後對 記憶體數據結構進行不間斷的修改

這個時候就會使用作業系統的 COW 機制來進行 數據段頁面 的分離。數據段是由很多作業系統的頁面組合而成,當父進程對其中一個頁面的數據進行修改時,會將被共享的頁面復
制一份分離出來,然後 對這個複製的頁面進行修改。這時 子進程 相應的頁面是 沒有變化的,還是進程產生時那一瞬間的數據。

子進程因為數據沒有變化,它能看到的記憶體里的數據在進程產生的一瞬間就凝固了,再也不會改變,這也是為什麼 Redis 的持久化 叫「快照」的原因。接下來子進程就可以非常安心的遍曆數據了進行序列化寫磁碟了。

方式二:AOF

image

快照不是很持久。如果運行 Redis 的電腦停止運行,電源線出現故障或者您 kill -9 的實例意外發生,則寫入 Redis 的最新數據將丟失。儘管這對於某些應用程式可能不是什麼大問題,但有些使用案例具有充分的耐用性,在這些情況下,快照並不是可行的選擇。

AOF(Append Only File – 僅追加文件) 它的工作方式非常簡單:每次執行 修改記憶體 中數據集的寫操作時,都會 記錄 該操作。假設 AOF 日誌記錄了自 Redis 實例創建以來 所有的修改性指令序列,那麼就可以通過對一個空的 Redis 實例 順序執行所有的指令,也就是 「重放」,來恢復 Redis 當前實例的記憶體數據結構的狀態。

為了展示 AOF 在實際中的工作方式,我們來做一個簡單的實驗:

./redis-server --appendonly yes  # 設置一個新實例為 AOF 模式

然後我們執行一些寫操作:

redis 127.0.0.1:6379> set key1 Hello  OK  redis 127.0.0.1:6379> append key1 " World!"  (integer) 12  redis 127.0.0.1:6379> del key1  (integer) 1  redis 127.0.0.1:6379> del non_existing_key  (integer) 0

前三個操作實際上修改了數據集,第四個操作沒有修改,因為沒有指定名稱的鍵。這是 AOF 日誌保存的文本:

$ cat appendonly.aof  *2  $6  SELECT  $1  0  *3  $3  set  $4  key1  $5  Hello  *3  $6  append  $4  key1  $7   World!  *2  $3  del  $4  key1

如您所見,最後的那一條 DEL 指令不見了,因為它沒有對數據集進行任何修改。

就是這麼簡單。當 Redis 收到客戶端修改指令後,會先進行參數校驗、邏輯處理,如果沒問題,就 立即 將該指令文本 存儲 到 AOF 日誌中,也就是說,先執行指令再將日誌存檔。這一點不同於 MySQLLevelDBHBase 等存儲引擎,如果我們先存儲日誌再做邏輯處理,這樣就可以保證即使宕機了,我們仍然可以通過之前保存的日誌恢復到之前的數據狀態,但是 Redis 為什麼沒有這麼做呢?

Emmm… 沒找到特別滿意的答案,引用一條來自知乎上的回答吧:

AOF 重寫

image

Redis 在長期運行的過程中,AOF 的日誌會越變越長。如果實例宕機重啟,重放整個 AOF 日誌會非常耗時,導致長時間 Redis 無法對外提供服務。所以需要對 AOF 日誌 "瘦身"

Redis 提供了 bgrewriteaof 指令用於對 AOF 日誌進行瘦身。其 原理 就是 開闢一個子進程 對記憶體進行 遍歷 轉換成一系列 Redis 的操作指令,序列化到一個新的 AOF 日誌文件 中。序列化完畢後再將操作期間發生的 增量 AOF 日誌 追加到這個新的 AOF 日誌文件中,追加完畢後就立即替代舊的 AOF 日誌文件了,瘦身工作就完成了。

fsync

image

AOF 日誌是以文件的形式存在的,當程式對 AOF 日誌文件進行寫操作時,實際上是將內容寫到了內核為文件描述符分配的一個記憶體快取中,然後內核會非同步將臟數據刷回到磁碟的。

就像我們 上方第四步 描述的那樣,我們需要藉助 glibc 提供的 fsync(int fd) 函數來講指定的文件內容 強制從內核快取刷到磁碟。但 "強制開車" 仍然是一個很消耗資源的一個過程,需要 "節制"!通常來說,生產環境的伺服器,Redis 每隔 1s 左右執行一次 fsync 操作就可以了。

Redis 同樣也提供了另外兩種策略,一個是 永不 fsync,來讓作業系統來決定合適同步磁碟,很不安全,另一個是 來一個指令就 fsync 一次,非常慢。但是在生產環境基本不會使用,了解一下即可。

Redis 4.0 混合持久化

image

重啟 Redis 時,我們很少使用 rdb 來恢復記憶體狀態,因為會丟失大量數據。我們通常使用 AOF 日誌重放,但是重放 AOF 日誌性能相對 rdb 來說要慢很多,這樣在 Redis 實例很大的情況下,啟動需要花費很長的時間。

Redis 4.0 為了解決這個問題,帶來了一個新的持久化選項——混合持久化。將 rdb 文件的內容和增量的 AOF 日誌文件存在一起。這裡的 AOF 日誌不再是全量的日誌,而是 自持久化開始到持久化結束 的這段時間發生的增量 AOF 日誌,通常這部分 AOF 日誌很小:

於是在 Redis 重啟的時候,可以先載入 rdb 的內容,然後再重放增量 AOF 日誌就可以完全替代之前的 AOF 全量文件重放,重啟效率因此大幅得到提升。

相關閱讀

  1. Redis(1)——5種基本數據結構 – https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/
  2. Redis(2)——跳躍表 – https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/
  3. Redis(3)——分散式鎖深入探究 – https://www.wmyskxz.com/2020/03/01/redis-3/
  4. Reids(4)——神奇的HyperLoglog解決統計問題 – https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/
  5. Redis(5)——億級數據過濾和布隆過濾器 – https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/
  6. Redis(6)——GeoHash查找附近的人https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/

擴展閱讀

  1. Redis 數據備份與恢復 | 菜鳥教程 – https://www.runoob.com/redis/redis-backup.html
  2. Java Fork/Join 框架 – https://www.cnblogs.com/cjsblog/p/9078341.html

參考資料

  1. Redis persistence demystified | antirez weblog (作者部落格) – http://oldblog.antirez.com/post/redis-persistence-demystified.html
  2. 作業系統 — fork()函數的使用與底層原理 – https://blog.csdn.net/Dawn_sf/article/details/78709839
  3. 磁碟和記憶體讀寫簡單原理 – https://blog.csdn.net/zhanghongzheng3213/article/details/54141202
  • 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!