第78篇 Redis常見延遲問題

似梦亦非梦發表於2024-12-09

使用複雜度高的命令

Redis提供了慢日誌命令的統計功能
首先設定Redis的慢日誌閾值,只有超過閾值的命令才會被記錄,這裡的單位是微妙,例如設定慢日誌的閾值為5毫秒,同時設定只保留最近1000條慢日誌記錄:

# 命令執行超過5毫秒記錄慢日誌
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近1000條慢日誌
CONFIG SET slowlog-max-len 1000

執行SLOWLOG get 5查詢最近5條慢日誌

127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 32693       # 慢日誌ID
   2) (integer) 1593763337  # 執行時間
   3) (integer) 5299        # 執行耗時(微秒)
   4) 1) "LRANGE"           # 具體執行的命令和引數
	  2) "user_list_2000"
	  3) "0"
	  4) "-1"
2) 1) (integer) 32692
   2) (integer) 1593763337
   3) (integer) 5044
   4) 1) "GET"
	  2) "book_price_1000"
...

透過檢視慢日誌記錄,就可以知道在什麼時間執行哪些命令比較耗時,如果服務請求量並不大,但Redis例項的CPU使用率很高,很有可能就是使用了複雜度高的命令導致的。

比如經常使用O(n)以上覆雜度的命令,由於Redis是單執行緒執行命令,因此這種情況Redis處理資料時就會很耗時。例如

  • sort:對列表(list)、集合(set)、有序集合(sorted set)中的元素進行排序。在最簡單的情況下(沒有權重、沒有模式、沒有 LIMIT),SORT 命令的時間複雜度近似於 O(n*log(n))

  • sunion:用於計算兩個或多個集合的並集。時間複雜度可以描述為 O(N),其中 **N **是所有參與運算集合的元素總數。如果有多個集合,每個集合有不同數量的元素參與運算,那麼複雜度會是所有這些集合元素數量的總和。

  • zunionstore:用於計算一個或多個有序集合的並集,並將結果儲存到一個新的有序集合中。在最簡單的情況下,ZUNIONSTORE 命令的時間複雜度是 O(N*log(N)),其中 N 是所有參與計算的集合中元素的總數。

  • keys * :獲取所有的 key 操作;複雜度O(n),資料量越大執行速度越慢;可以使用scan命令替代

  • Hgetall:返回雜湊表中所有的欄位和;

  • smembers:返回集合中的所有成員;

解決方案就是,不使用這些複雜度較高的命令,並且一次不要獲取太多的資料,每次儘量操作少量的資料,讓Redis可以及時處理返回

儲存大key

如果查詢慢日誌發現,並不是複雜度較高的命令導致的,例如都是SET、DELETE操作出現在慢日誌記錄中,那麼就要懷疑是否存在Redis寫入了大key的情況。

多大才算大

如果一個 key 對應的 value 所佔用的記憶體比較大,那這個 key 就可以看作是 bigkey。

  • String 型別的 value 超過 1MB
  • 複合型別(List、Hash、Set、Sorted Set 等)的 value 包含的元素超過 5000 個(不過,對於複合型別的 value 來說,不一定包含的元素越多,佔用的記憶體就越多)。

產生原因

  • 程式設計不當,比如直接使用 String 型別儲存較大的檔案對應的二進位制資料。
  • 對於業務的資料規模考慮不周到,比如使用集合型別的時候沒有考慮到資料量的快速增長。
  • 未及時清理垃圾資料,比如雜湊中冗餘了大量的無用鍵值對。

造成的問題

  • 客戶端超時阻塞:由於 Redis 執行命令是單執行緒處理,然後在操作大 key 時會比較耗時,那麼就會阻塞 Redis,從客戶端這一視角看,就是很久很久都沒有響應。
  • 網路阻塞:每次獲取大 key 產生的網路流量較大,如果一個 key 的大小是 1 MB,每秒訪問量為 1000,那麼每秒會產生 1000MB 的流量,這對於普通千兆網路卡的伺服器來說是災難性的。
  • 工作執行緒阻塞:如果使用 del 刪除大 key 時,會阻塞工作執行緒,這樣就沒辦法處理後續的命令。
  • 持久化阻塞(磁碟IO):對AOF 日誌的影響
      • 使用Always 策略的時候,主執行緒在執行完命令後,會把資料寫入到 AOF 日誌檔案,然後會呼叫 fsync() 函式,將核心緩衝區的資料直接寫入到硬碟,等到硬碟寫操作完成後,該函式才會返回。因此當使用 Always 策略的時候,如果寫入是一個大 Key,主執行緒在執行 fsync() 函式的時候,阻塞的時間會比較久,因為當寫入的資料量很大的時候,資料同步到硬碟這個過程是很耗時的。
      • 另外兩種策略都不影響主執行緒

大 key 造成的阻塞問題還會進一步影響到主從同步和叢集擴容。

如何發現 bigkey?

  1. 使用 Redis 自帶的 --bigkeys 引數來查詢:這個命令會掃描(Scan) Redis 中的所有 key ,會對 Redis 的效能有一點影響,最好選擇在從節點上執行該命令,因為主節點上執行時,會阻塞主節點。並且,這種方式只能找出每種資料結構 top 1 bigkey(佔用記憶體最大的 String 資料型別,包含元素最多的複合資料型別)。然而,一個 key 的元素多並不代表佔用記憶體也多,需要我們根據具體的業務情況來進一步判斷。

  2. Redis 自帶的 SCAN 命令:SCAN 命令可以按照一定的模式和數量返回匹配的 key。獲取了 key 之後,可以利用 STRLEN、HLEN、LLEN等命令返回其長度或成員數量。

image

  1. 藉助開源工具分析 RDB 檔案:這種方案的前提是Redis 採用的是 RDB 持久化。網上有現成的工具:

    • redis-rdb-tools:Python 語言寫的用來分析 Redis 的 RDB 快照檔案用的工具
    • rdb_bigkeys:Go 語言寫的用來分析 Redis 的 RDB 快照檔案用的工具,效能更好。

如何處理 bigkey?

  • 刪除大 key:刪除大 key 時建議採用分批次刪除和非同步刪除的方式進行;

    • 因為刪除大 key釋放記憶體只是第一步,為了更加高效地管理記憶體空間,在應用程式釋放記憶體時,作業系統需要把釋放掉的記憶體塊插入一個空閒記憶體塊的連結串列,以便後續進行管理和再分配。這個過程本身需要一定時間,而且會阻塞當前釋放記憶體的應用程式。
    • 所以,如果一下子釋放了大量記憶體,空閒記憶體塊連結串列操作時間就會增加,相應地就會造成 Redis 主執行緒的阻塞,如果主執行緒發生了阻塞,其他所有請求可能都會超時,超時越來越多,會造成 Redis 連線耗盡,產生各種異常。
  • 分割 bigkey:將一個 bigkey 分割為多個小 key。例如,將一個含有上萬欄位數量的 Hash 按照一定策略(比如二次雜湊)拆分為多個 Hash。

  • 手動清理:Redis 4.0+ 可以使用 UNLINK 命令來非同步刪除一個或多個指定的 key。Redis 4.0 以下可以考慮使用 SCAN 命令結合 DEL 命令來分批次刪除。

  • 採用合適的資料結構:例如,檔案二進位制資料不使用 String 儲存、使用 HyperLogLog 統計頁面 UV、Bitmap 儲存狀態資訊(0/1)。

  • 開啟 lazy-free(惰性刪除/延遲釋放) :lazy-free 特性是 Redis 4.0 開始引入的,指的是讓 Redis 採用非同步方式延遲釋放 key 使用的記憶體,將該操作交給單獨的子執行緒處理,避免阻塞主執行緒。

集中過期

Redis的過期策略採用 定期過期+懶惰過期兩種策略:

  • 定期過期:Redis內部維護一個定時任務,預設每秒進行10次(也就是每隔100毫秒一次)過期掃描,從過期字典中隨機取出20個key,刪除過期的key,如果過期key的比例還超過25%,則繼續獲取20個key,刪除過期的key,迴圈往復,直到過期key的比例下降到25%或者這次任務的執行耗時超過了25毫秒,才會退出迴圈
  • 懶惰過期:只有當訪問某個key時,才判斷這個key是否已過期,如果已經過期,則從例項中刪除

Redis的定期刪除策略是在Redis主執行緒中執行的,也就是說如果在執行定期刪除的過程中,出現了需要大量刪除過期key的情況,那麼在業務訪問時,必須等這個定期刪除任務執行結束,才可以處理業務請求。此時就會出現,業務訪問延時增大的問題,最大延遲為25毫秒。

為了儘量避免這個問題,在設定過期時間時,可以給過期時間設定一個隨機範圍,避免同一時刻過期。

虛擬碼可以這麼寫:

# 在過期時間點之後的5分鐘內隨機過期掉
redis.expireat(key, expire_time + random(300))

例項記憶體達到上限

生產中會給記憶體設定上限maxmemory,當資料記憶體達到 maxmemory 時,便會觸發redis的記憶體淘汰策略

那麼當例項的記憶體達到了maxmemory後,就會發現之後每次寫入新的資料,就好像變慢了。導致變慢的原因是,當Redis記憶體達到maxmemory後,每次寫入新的資料之前,會先根據記憶體淘汰策略先踢出一部分資料,讓記憶體維持在maxmemory之下。

而記憶體淘汰策略就決定這個踢出資料的時間長短:

  • 最常使用的一般是allkeys-lru或volatile-lru策略,Redis 記憶體淘汰時,會使用隨機取樣的方式來淘汰資料,它是隨機取 5 個值 (此值可配置) ,然後淘汰一個最少訪問的key,之後把剩下的key暫存到一個池子中,繼續隨機取出一批key,並與之前池子中的key比較,再淘汰一個最少訪問的key。以此迴圈,直到記憶體降到maxmemory之下。
  • 如果使用的是allkeys-random或volatile-random策略,那麼就會快很多,因為是隨機淘汰,那麼就少了比較key訪問頻率時間的消耗了,隨機拿出一批key後直接淘汰即可,因此這個策略要比上面的LRU策略執行快一些。

但以上這些淘汰策略的邏輯都是在訪問Redis時,真正命令執行之前執行的,也就是它會影響真正需要執行的命令。

另外,如果此時Redis例項中有儲存大key,那麼在淘汰大key釋放記憶體時,這個耗時會更加久,延遲更大

AOF持久化

同步持久化

當 Redis 直接記錄 AOF 日誌時,如果有大量的寫操作,並且配置為同步持久化

appendfsync always

即每次發生資料變更會被立即記錄到磁碟,並且Always寫回策略是由主程序執行的,而寫磁碟比較耗時,效能較差,所以有時會阻塞主執行緒。

AOF重寫

  1. fork 出一條子執行緒來將檔案重寫,在執行 BGREWRITEAOF 命令時,Redis 伺服器會維護一個 AOF 重寫緩衝區,該緩衝區會在子執行緒建立新 AOF 檔案期間,記錄伺服器執行的所有寫命令。
  2. 當子執行緒完成建立新 AOF 檔案的工作之後,伺服器會將重寫緩衝區中的所有內容追加到新 AOF 檔案的末尾,使得新的 AOF 檔案儲存的資料庫狀態與現有的資料庫狀態一致。
  3. 最後,伺服器用新的 AOF 檔案替換舊的 AOF 檔案,以此來完成 AOF 檔案重寫操作。

阻塞就是出現在第2步的過程中,將緩衝區中新資料寫到新檔案的過程中會產生阻塞

fork耗時

生成RDB和AOF重寫都需要父程序fork出一個子程序進行資料的持久化,在fork執行過程中,父程序需要複製記憶體頁表給子程序,如果整個例項記憶體佔用很大,那麼需要複製的記憶體頁表會比較耗時,此過程會消耗大量的CPU資源,在完成fork之前,整個例項會被阻塞住,無法處理任何請求,如果此時CPU資源緊張,那麼fork的時間會更長,甚至達到秒級。這會嚴重影響Redis的效能。

Redis 在進行 RDB 快照的時候,會呼叫系統函式 fork() ,建立一個子執行緒來完成臨時檔案的寫入,而觸發條件正是配置檔案中的 save 配置。當達到配置時,就會觸發 bgsave 命令建立快照,這種方式是不會阻塞主執行緒的,而手動執行 save 命令會在主執行緒中執行,阻塞主執行緒。

除了因為備份的原因生成RDB之外,在【主從複製】第一次建立連線全量複製時,主節點也會生成RDB檔案給從節點進行一次全量同步,這時也會對Redis產生效能影響。

要想避免這種情況,需要規劃好資料備份的週期,建議在從節點上執行備份,而且最好放在低峰期執行。如果對於丟失資料不敏感的業務,那麼不建議開啟AOF和AOF重寫功能。

叢集擴容

Redis 叢集可以進行節點的動態擴容縮容,這一過程目前還處於半自動狀態,需要人工介入。

在擴縮容的時候,需要進行資料遷移。而 Redis 為了保證遷移的一致性,遷移所有操作都是同步操作。

執行遷移時,兩端的 Redis 均會進入時長不等的阻塞狀態,對於小Key,該時間可以忽略不計,但如果一旦 Key 的記憶體使用過大,嚴重的時候會觸發叢集內的故障轉移,造成不必要的切換。

總結

  1. 使用複雜度高的命令,執行命令時就會耗時
  2. 儲存大key:如果一個key寫入的資料非常大,Redis在分配記憶體、刪除大key時都會耗時,並且持久化AOF的寫回策略是always時會影響Redis效能
  3. 集中過期:Redis的主動過期的定時任務,是在Redis主執行緒中執行的,最差的情況下會有25ms的阻塞
  4. 例項記憶體達到上限時,淘汰策略的邏輯都是在訪問Redis時,真正命令執行之前執行的,也就是它會影響真正需要執行的命令。
  5. fork耗時:生成RDB和AOF重寫都需要父程序fork出一個子程序進行資料的持久化,如果整個例項記憶體佔用很大,那麼需要複製的記憶體頁表會比較耗時

額外總結大key的影響:
如果一個key寫入的資料非常大,Redis在分配記憶體、刪除大key時都會耗時。
當例項記憶體達到上限時,在淘汰大key釋放記憶體時,記憶體淘汰策略的耗時會更加久,延遲更大
AOF持久化時,使用always機制,這個操作是在主執行緒中執行的,如果寫入是一個大 Key,主執行緒在執行 fsync() 函式的時候,阻塞的時間會更久。
生成RDB和AOF重寫時會fork出一個子程序進行資料的持久化,父程序需要複製記憶體頁表給子程序,如果整個例項記憶體佔用很大,那麼需要複製的記憶體頁表會比較耗時。

相關文章