Redis 的大 Key 對持久化有什麼影響?

2022-09-19 12:00:57

作者:小林coding

圖解計算機基礎(作業系統、計算機網路、計算機組成、資料庫等)網站:https://xiaolincoding.com

大家好,我是小林。

上週有位讀者位元組一二面時,被問到:Redis 的大 Key 對持久化有什麼影響?

Redis 的持久化方式有兩種:AOF 紀錄檔和 RDB 快照。

所以接下來,針對這兩種持久化方式具體分析分析。

大 Key 對 AOF 紀錄檔的影響

先說說 AOF 紀錄檔三種寫回磁碟的策略

Redis 提供了 3 種 AOF 紀錄檔寫回硬碟的策略,分別是:

  • Always,這個單詞的意思是「總是」,所以它的意思是每次寫操作命令執行完後,同步將 AOF 紀錄檔資料寫回硬碟;
  • Everysec,這個單詞的意思是「每秒」,所以它的意思是每次寫操作命令執行完後,先將命令寫入到 AOF 檔案的核心緩衝區,然後每隔一秒將緩衝區裡的內容寫回到硬碟;
  • No,意味著不由 Redis 控制寫回硬碟的時機,轉交給作業系統控制寫回的時機,也就是每次寫操作命令執行完後,先將命令寫入到 AOF 檔案的核心緩衝區,再由作業系統決定何時將緩衝區內容寫回硬碟。

這三種策略只是在控制 fsync() 函數的呼叫時機。

當應用程式向檔案寫入資料時,核心通常先將資料複製到核心緩衝區中,然後排入佇列,然後由核心決定何時寫入硬碟。

如果想要應用程式向檔案寫入資料後,能立馬將資料同步到硬碟,就可以呼叫 fsync() 函數,這樣核心就會將核心緩衝區的資料直接寫入到硬碟,等到硬碟寫操作完成後,該函數才會返回。

  • Always 策略就是每次寫入 AOF 檔案資料後,就執行 fsync() 函數;
  • Everysec 策略就會建立一個非同步任務來執行 fsync() 函數;
  • No 策略就是永不執行 fsync() 函數;

分別說說這三種策略,在持久化大 Key 的時候,會影響什麼?

在使用 Always 策略的時候,主執行緒在執行完命令後,會把資料寫入到 AOF 紀錄檔檔案,然後會呼叫 fsync() 函數,將核心緩衝區的資料直接寫入到硬碟,等到硬碟寫操作完成後,該函數才會返回。

當使用 Always 策略的時候,如果寫入是一個大 Key,主執行緒在執行 fsync() 函數的時候,阻塞的時間會比較久,因為當寫入的資料量很大的時候,資料同步到硬碟這個過程是很耗時的

當使用 Everysec 策略的時候,由於是非同步執行 fsync() 函數,所以大 Key 持久化的過程(資料同步磁碟)不會影響主執行緒。

當使用 No 策略的時候,由於永不執行 fsync() 函數,所以大 Key 持久化的過程不會影響主執行緒。

大 Key 對 AOF 重寫和 RDB 的影響

當 AOF 紀錄檔寫入了很多的大 Key,AOF 紀錄檔檔案的大小會很大,那麼很快就會觸發 AOF 重寫機制

AOF 重寫機制和 RDB 快照(bgsave 命令)的過程,都會分別通過 fork() 函數建立一個子程序來處理任務。

在建立子程序的過程中,作業系統會把父程序的「頁表」複製一份給子程序,這個頁表記錄著虛擬地址和實體地址對映關係,而不會複製實體記憶體,也就是說,兩者的虛擬空間不同,但其對應的物理空間是同一個。

這樣一來,子程序就共用了父程序的實體記憶體資料了,這樣能夠節約實體記憶體資源,頁表對應的頁表項的屬性會標記該實體記憶體的許可權為唯讀

隨著 Redis 存在越來越多的大 Key,那麼 Redis 就會佔用很多記憶體,對應的頁表就會越大。

在通過 fork() 函數建立子程序的時候,雖然不會複製父程序的實體記憶體,但是核心會把父程序的頁表複製一份給子程序,如果頁表很大,那麼這個複製過程是會很耗時的,那麼在執行 fork 函數的時候就會發生阻塞現象

而且,fork 函數是由 Redis 主執行緒呼叫的,如果 fork 函數發生阻塞,那麼意味著就會阻塞 Redis 主執行緒。由於 Redis 執行命令是在主執行緒處理的,所以當 Redis 主執行緒發生阻塞,就無法處理後續使用者端發來的命令。

我們可以執行 info 命令獲取到 latest_fork_usec 指標,表示 Redis 最近一次 fork 操作耗時。

# 最近一次 fork 操作耗時
latest_fork_usec:315

如果 fork 耗時很大,比如超過1秒,則需要做出優化調整:

  • 單個範例的記憶體佔用控制在 10 GB 以下,這樣 fork 函數就能很快返回。
  • 如果 Redis 只是當作純快取使用,不關心 Redis 資料安全性問題,可以考慮關閉 AOF 和 AOF 重寫,這樣就不會呼叫 fork 函數了。
  • 在主從架構中,要適當調大 repl-backlog-size,避免因為 repl_backlog_buffer 不夠大,導致主節點頻繁地使用全量同步的方式,全量同步的時候,是會建立 RDB 檔案的,也就是會呼叫 fork 函數。

那什麼時候會發生實體記憶體的複製呢?

當父程序或者子程序在向共用記憶體發起寫操作時,CPU 就會觸發缺頁中斷,這個缺頁中斷是由於違反許可權導致的,然後作業系統會在「缺頁例外處理函數」裡進行實體記憶體的複製,並重新設定其記憶體對映關係,將父子程序的記憶體讀寫許可權設定為可讀寫,最後才會對記憶體進行寫操作,這個過程被稱為「**寫時複製(Copy On Write)**」。

寫時複製顧名思義,在發生寫操作的時候,作業系統才會去複製實體記憶體,這樣是為了防止 fork 建立子程序時,由於實體記憶體資料的複製時間過長而導致父程序長時間阻塞的問題。

如果建立完子程序後,父程序對共用記憶體中的大 Key 進行了修改,那麼核心就會發生寫時複製,會把實體記憶體複製一份,由於大 Key 佔用的實體記憶體是比較大的,那麼在複製實體記憶體這一過程中,也是比較耗時的,於是父程序(主執行緒)就會發生阻塞

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

  • 建立子程序的途中,由於要複製父程序的頁表等資料結構,阻塞的時間跟頁表的大小有關,頁表越大,阻塞的時間也越長;
  • 建立完子程序後,如果子程序或者父程序修改了共用資料,就會發生寫時複製,這期間會拷貝實體記憶體,如果記憶體越大,自然阻塞的時間也越長;

這裡額外提一下, 如果 Linux 開啟了記憶體大頁,會影響 Redis 的效能的

Linux 核心從 2.6.38 開始支援記憶體大頁機制,該機制支援 2MB 大小的記憶體頁分配,而常規的記憶體頁分配是按 4KB 的粒度來執行的。

如果採用了記憶體大頁,那麼即使使用者端請求只修改 100B 的資料,在發生寫時複製後,Redis 也需要拷貝 2MB 的大頁。相反,如果是常規記憶體頁機制,只用拷貝 4KB。

兩者相比,你可以看到,每次寫命令引起的複製記憶體頁單位放大了 512 倍,會拖慢寫操作的執行時間,最終導致 Redis 效能變慢

那該怎麼辦呢?很簡單,關閉記憶體大頁(預設是關閉的)。

禁用方法如下:

echo never >  /sys/kernel/mm/transparent_hugepage/enabled

總結

當 AOF 寫回策略設定了 Always 策略,如果寫入是一個大 Key,主執行緒在執行 fsync() 函數的時候,阻塞的時間會比較久,因為當寫入的資料量很大的時候,資料同步到硬碟這個過程是很耗時的。

AOF 重寫機制和 RDB 快照(bgsave 命令)的過程,都會分別通過 fork() 函數建立一個子程序來處理任務。會有兩個階段會導致阻塞父程序(主執行緒):

  • 建立子程序的途中,由於要複製父程序的頁表等資料結構,阻塞的時間跟頁表的大小有關,頁表越大,阻塞的時間也越長;
  • 建立完子程序後,如果父程序修改了共用資料中的大 Key,就會發生寫時複製,這期間會拷貝實體記憶體,由於大 Key 佔用的實體記憶體會很大,那麼在複製實體記憶體這一過程,就會比較耗時,所以有可能會阻塞父程序。

大 key 除了會影響持久化之外,還會有以下的影響。

  • 使用者端超時阻塞。由於 Redis 執行命令是單執行緒處理,然後在操作大 key 時會比較耗時,那麼就會阻塞 Redis,從使用者端這一視角看,就是很久很久都沒有響應。

  • 引發網路阻塞。每次獲取大 key 產生的網路流量較大,如果一個 key 的大小是 1 MB,每秒存取量為 1000,那麼每秒會產生 1000MB 的流量,這對於普通千兆網路卡的伺服器來說是災難性的。

  • 阻塞工作執行緒。如果使用 del 刪除大 key 時,會阻塞工作執行緒,這樣就沒辦法處理後續的命令。

  • 記憶體分佈不均。叢集模型在 slot 分片均勻情況下,會出現資料和查詢傾斜情況,部分有大 key 的 Redis 節點佔用記憶體多,QPS 也會比較大。

如何避免大 Key 呢?

最好在設計階段,就把大 key 拆分成一個一個小 key。或者,定時檢查 Redis 是否存在大 key ,如果該大 key 是可以刪除的,不要使用 DEL 命令刪除,因為該命令刪除過程會阻塞主執行緒,而是用 unlink 命令(Redis 4.0+)刪除大 key,因為該命令的刪除過程是非同步的,不會阻塞主執行緒。

完!