一次Redis訪問超時的“捉蟲”之旅

01

   引言

作爲後端開發人員,對Redis肯定不陌生,它是一款基於內存的數據庫,讀寫速度非常快。在愛奇藝海外後端的項目中,我們也廣泛使用Redis,主要用於緩存、消息隊列和分佈式鎖等場景。最近在對一個老項目使用的docker鏡像版本升級過程中碰到一個奇怪的問題,發現項目升級到高版本鏡像後,訪問Redis會出現很多超時錯誤,而降回之前的鏡像版本後問題也隨之消失。經過排查,最終定位問題元兇是一個涉及到Lettuce、Redis、Netty等多塊內容的代碼bug。在問題解決過程中也對相關組件的工作方式有了更深一步的理解。以下就對“捉蟲”過程中的問題分析和排查過程做一個詳細的介紹。

02

   背景

我們的技術棧是業界常見的Spring Cloud全家桶。有問題的項目是整個微服務架構中的一個子服務,主要負責爲客戶端提供包括節目詳情、劇集列表和播放鑑權等內容相關的web服務。由於節目、劇集、演員等大部分領域實體變更頻率不高,我們使用三主三從的Redis集羣進行緩存,將數據分片管理,以便保存更多內容。
項目中訪問Redis的方式主要有兩種,一種是直接使用Spring框架封裝的RedisTemplete對象進行訪問,使用場景是對redis中的數據進行手動操作。另外一種方式是通過自研的緩存框架間接訪問,框架內部會對緩存內容進行管理,主要包含二級緩存,熱key統計,緩存預熱等高級功能。

通過RedisTemplete訪問:

通過自研緩存框架訪問。如下圖,加上@CreateCache註解的對象被聲明爲緩存容器,在項目啓動時框架會利用Redis的發佈訂閱機制,自動將遠端Redis二級緩存中的熱點數據同步到本地。並支持配置數據緩存的有效期、本地緩存數量等屬性。另外框架本身也提供了讀寫接口供使用方訪問緩存數據。

03

   問題現象

升級了鏡像版本後,應用正常啓動後會出現大量訪問Redis超時錯誤。在觀察了CPU、內存和垃圾回收等方面的常規監控後,並沒有發現明顯異常。只是在項目啓動初期會有較多的網絡數據寫入。這實際上是之前提到的緩存預熱邏輯,因此也在預期之內。
由於項目本身存在兩種訪問方式,不同環境下Redis服務器架構也不同,爲了固定問題場景,我們進行了一番條件測試,發現了一些端倪:
  • 低版本鏡像上RedisTemplete和緩存框架訪問Redis集羣正常
  • 高版本鏡像上RedisTemplete訪問Redis集羣正常,緩存框架訪問Redis集羣超時。項目啓動一段時間後框架訪問恢復正常
  • 低版本和高版本鏡像中RedisTemplete和緩存框架訪問Redis單機正常
根據以上現象不難推斷出,問題應似乎出現在緩存框架訪問Redis集羣的機制上。結合項目啓動一段時間後會恢復正常的特點,猜測應該和緩存預熱流程有關。

04

   排查過程

復現case

查閱代碼後發現自研的緩存框架沒有通過Spring訪問Redis,而是直接使用了Sping底層的Redis客戶端—— Lettuce。剔除了無關的業務代碼後,我們得到了一個可以復現問題的最小case,代碼如下:

整個case模擬的就是緩存的預熱場景,主要運行流程如下:
  1. 服務(節點3)啓動後發送新節點上線的ONLINE消息
  2. 其他節點(節點1,2)收到ONLINE消息後,將本地緩存的熱key打包
  3. 其他節點(節點1,2)發送包含本機熱key的HOTKEY消息
  4. 新節點(節點3)收到包含熱key的HOTKEY消息
  5. 新節點(節點3)根據收到的熱key反查redis獲取value值,並緩存到本地
在根據熱key反查Redis的方法中也加了日誌,以顯示反查操作的執行時間(省略部分無關代碼)。
運行上述代碼後,我們看看控制檯實際的輸出結果:
可以看到,應用在啓動後正常收到了Redis的HOTKEY消息並執行反查操作。然而,大量的反查請求在1秒後仍未獲取到結果。而源碼中請求future的超時時間設置也是1秒,即大量的Redis get請求都超時了。

一般情況下請求超時的原因有兩個,要麼是請求沒有到達服務端,要麼是響應沒有回到客戶端。爲了定位原因,我們在應用宿主機上查看與Redis集羣連接的通信情況,如下:
結果發現,本機與redis集羣的3個分片共建立了6個連接,其中一個tcp連接的接收隊列內容一直不爲空,這說明該連接的響應數據已經到達本機socket緩衝區,只不過由於某種原因客戶端程序沒有消費。作爲對比我們在低版本鏡像上啓動後同樣觀察連接情況,發現不存在數據積壓的情況。

排查至此我們發現緩衝區的數據積壓很可能就是造成反查請求超時的原因,明白了這一點後,我們開始思考:
  • 連接緩衝區中的數據應該由誰來消費?
  • 每個連接的作用是什麼?
  • 爲什麼只有一個連接出現了數據積壓情況?
  • 爲什麼積壓情況只在高版本的鏡像中出現?
  • 爲什麼通過Spring訪問Redis就不會出現超時問題?


深度分析

要回答以上問題,首先要了解Lettuce的工作原理,重點是其底層是如何訪問Redis集羣的。
根據官網介紹,Lettuce 底層基於 Netty 的NIO模型實現,只用有限的線程支持更多的 Redis 連接,在高負載情況下能更有效地利用系統資源。

我們簡要回顧一下Netty的工作機制。Netty中所有I/O操作和事件是由其內部的核心組件EventLoop負責處理的。Netty啓動時會根據配置創建多個EventLoop對象,每個Netty連接會被註冊到一個EventLoop上,同一個EventLoop可以管理多個連接。而每個EventLoop內部都包含一個工作線程、一個Selector選擇器以及一個任務隊列。

當客戶端執行連接建立或註冊等操作時,這些動作都會以任務的形式提交到關聯EventLoop的任務隊列中。每當連接上發生I/O事件或者任務隊列不爲空時,其內部的工作線程(單線程)會輪詢地從隊列中取出事件執行,或者將事件分發給相應的事件監聽者執行。
在Lettuce框架中,與Redis集羣的交互由內部的RedisClusterClient對象處理。項目啓動時,RedisClusterClient會根據配置獲取所有主從節點信息,並嘗試連接每個節點以獲取節點metadata數據,然後釋放連接完成初始化。隨後,RedisClusterClient會按需連接各個節點。RedisClusterClient的連接分爲主連接和副連接兩種。由客戶端顯示創建的連接是主連接,用於執行無需路由的命令,如auth/select等。而由client內部根據路由規則隱式創建的連接是副連接,用於執行需要根據slot路由的命令,例如常見的get/set操作。對於Pub/Sub發佈訂閱機制,爲了確保訂閱者可以實時接收到發佈者發佈的消息,Lettuce會單獨維護一個專用於事件監聽的連接。

所以我們之前觀察到的6個TCP連接,實際上包含了1個集羣主連接、3個副連接、1個用於事件發佈的pub連接 (由TestService聲明的statefulRedisPubS ubConnection)以及1個用於訂閱的sub連接。 所有這些連接都會被註冊到Netty的EventLoop上進行管理。
EventLoop機制的核心功能是多路複用,這意味着一個線程可以處理多個連接的讀寫事件。但是要實現這一點的前提是EventLoop線程不能被阻塞,否則註冊在該線程上的各個連接的事件將得不到響應。由此我們可以推測,如果socket緩衝區出現積壓,可能是某些原因導致socket連接對應的 EventLoop 線程被阻塞,使其無法正常響應可讀事件並讀取緩衝區數據。

爲了驗證猜測,我們在日誌中打印線程信息做進一步觀察。
結果發現大部分超時都發生在同一個EventLoop線程上(Lettuce的epollEventLoop-9-3線程),那這個線程此刻的狀態是什麼呢?我們可以通過診斷工具查看線程堆棧,定位阻塞原因。

Arthas排障

這裏我們利用阿里arthas排障工具的thread命令查看線程狀態和堆棧信息。

從堆棧信息可以看出,Lettuce一共創建了3個Netty EventLoop線程,其中9-3處在TIMED_WAITTING狀態,該線程亦是Pub/Sub消息的的監聽線程,阻塞在了RedisLettucePubSubListener對象接收消息更新熱key的get方法上。


定位原因

通過Arthas排障我們瞭解到,原來Lettuce是在Netty的EventLoop線程中響應Pub/Sub事件的。由此我們也基本定位了緩衝區的積壓原因,即在RedisLettucePubSubListener中執行了阻塞的future get方法,導致其載體EventLoop線程被阻塞,無法響應與其Selector關聯連接的io事件。

爲什麼Pub/Sub事件會和其他連接的io事件由同一個EventLoop處理呢?通過查閱資料,發現Netty對連接進行多路複用時,只會啓動有限個EventLoop線程(默認是CPU數*2)進行連接管理,每個連接是輪詢註冊到 EventLoop上的,所以當EventLoop數量不多時,多個連接就可能會註冊到同一個io線程上。

  • Netty中EventLoop線程數量計算邏輯

  • Netty註冊EventLoop時的輪訓策略
結合出問題的場景進一步分析,一共有3個EventLoop線程,創建了6個連接,其中 Pub/Sub 連接的創建優先級高於負責數據路由的副連接,因此必然會出現一個副連接和 Pub/Sub 連接註冊到同一個 EventLoop 線程上的情況。而我們的程序會訪問大量的key,當key被路由到Pub/Sub的共享線程上時,由於此時線程被Pub/Sub的回調方法阻塞,即使緩衝區中有數據到達,也會導致與該 EventLoop 綁定的副連接上的讀寫事件無法被正常觸發。

  • 發佈訂閱回調方法阻塞導致EventLoop線程阻塞
針對這種應用場景Lettuce官網上也有專門提醒:https://lettuce.io/core/release/reference/index.html

  • 即不要在Pub/Sub的回調函數中執行阻塞操作。

另外還有一點需要額外說明,就是關於 EventLoop 的數量。由於我們並沒有主動配置,一般情況下Netty 會創建 CPU 數量的兩倍的 EventLoop。在我們的測試程序中,宿主環境是雙核,理論上應該創建4個 EventLoop。但觀察到實際的 EventLoop 數量卻只有3個。這是因爲 Lettuce 框架對 Netty 的邏輯進行了調整,要求創建的 EventLoop 數量等於 CPU 核數,且不少於3個。

  • Lettuce中的io線程數量計算邏輯。

  • 這點在官方文檔中也有說明。

解決方案
原因定位後,解決方案也呼之欲出。有兩種方法:

增加io線程
增加Lettuce io線程數量,使Pub/Sub連接和其他連接可以註冊到不同的EventLoop中。具體設置方式也有兩種:
  1. 在lettuce提供的ClientResources接口中指定io線程數量

由於Lettuce底層用的Netty,也可以通過配置io.netty.eventLoopThreads參數來指定Netty中EventLoop的數量。爲了快速驗證效果,我們在超時實例上配置該參數後重啓,發現問題果然消失,也進一步證明了的確是該原因導致了訪問超時。

異步化
比較優雅的方式是不要在nio線程中執行阻塞操作,即將處理Pub/Sub消息的過程異步化,最好放到獨立的線程中執行,以儘早釋放Netty的EventLoop資源。我們熟悉的Spring-data-redis框架就是這麼做的。

  • Spring-data-redis的做法是每次收到消息時都新啓動新線程處理。

思考

儘管問題已經解決,但之前還有幾個遺留的疑問沒有解答。經過一番研究,我們也找到了答案。

  1. 爲什麼低版本鏡像沒問題?
在之前的分析中,我們提到了因爲 EventLoop 線程數量過少導致線程阻塞。高版本的實例中 EventLoop 線程數量爲 3,那麼低版本的情況呢?通過Arthas 查看,發現低版本 Lettuce 的 EventLoop 數量是 13,遠遠超過了高版本的數量。這表示在低版本環境中,Pub/Sub 連接和其他連接會註冊到不同的 EventLoop 上,即使 Pub/Sub 處理線程被阻塞,也不會影響到其他連接讀寫事件的處理。
高版本鏡像最大線程編號9-3              

低版本鏡像最大線程編號9-13

爲什麼低版本的鏡像會創建更多的 EventLoop 呢?這其實是 JDK 的一個坑。早期的 JDK 8 版本(8u131 之前)存在docker環境下Java獲取cpu核心數不準確的問題,會導致程序拿到的是宿主機的核數。
(https://blogs.oracle.com/java/post/java-se-support-for-docker-cpu-and-memory-limits)

查看低版本鏡像的jdk版本是8u101,應用宿主機的核數是16,也就是說,低版本應用誤拿到了宿主機的核數16,因此會將每個連接註冊到一個獨立的EventLoop上,從而避免了阻塞的發生。換句話說,之所以低版本鏡像沒問題,其實是程序在錯誤的環境下獲取到錯誤的數值,卻得到了正確的結果,負負得正了。至於爲什麼最大線程號是 13 ,這是由於我們的 Redis 集羣配置了兩個域名,如下圖所示。

在 RedisClusterClient 初始化時,會分別對域名(2)、所有集羣節點(6)、Pub/Sub 通道(1)、集羣主連接(1)、副連接(3)進行連接創建,加起來一共正好是 13 個。

  1. 爲什麼高版本通過Spring訪問Redis爲什麼不會出現超時問題?
原始項目訪問Redis有Spring和緩存框架兩種方式。前文中提到的所有 EventLoop 都是由自研緩存框架維護的 RedisClusterClient 對象創建的。而Spring 容器會使用單獨的 RedisClusterClient 對象來創建Redis連接。在 Lettuce 中,每個 RedisClusterClient 對象底層都對應着不同的 EventLoopGroup。也就是說,Spring 創建的Redis連接一定不會和緩存框架的連接共用同一個 EventLoop。因此即使緩存框架所在的 EventLoop 線程被阻塞,也不會影響到 Spring 連接的事件響應。

  1. 爲什麼高版本鏡像訪問單機Redis沒問題?
與RedisClusterClient訪問Redis集羣時會創建多個主副連接不同,訪問單機Redis時Lettuce使用的RedisClient只會創建1個連接。再加上獨立的Pub/Sub連接,相當於是2個連接註冊到3個EventLoop上,避免了衝突。

05

   總結

本文從實際工作中遇到的一個Redis訪問超時問題出發,探究背後Spring、Lettuce和Netty的工作原理,並利用Arthas等調試工具,分析了EventLoop線程對連接處理的重要性,以及在處理Pub/Sub事件時避免阻塞操作的必要性。通過觀察不同版本環境下的行爲差異,加深了對JDK版本和程序環境適配的理解,爲今後排查類似問題積累了寶貴經驗。

06

   參考資料

[1]https://lettuce.io/core/5.3.7.RELEASE/reference/index.html
[2]https://docs.spring.io/spring-data/redis/reference/redis/pubsub.html
[3]https://github.com/TFdream/netty-learning/issues/22
[4]https://github.com/alibaba/jetcache/blob/master/docs/CN/RedisWithLettuce.md
[5]https://arthas.aliyun.com/doc/thread.html
[6]https://blogs.oracle.com/java/post/java-se-support-for-docker-cpu-and-memory-limits 


本文分享自微信公衆號 - 愛奇藝技術產品團隊(iQIYI-TP)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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