死磕生菜 -- lettuce 間歇性發生 RedisCommandTimeoutException 的深層原理及解決方案

wing_rg發表於2021-03-13

0x00 起源

專案的一些微服務整合了 Spring Data Redis,而底層的 Redis 客戶端是 lettuce,這也是預設的客戶端。微服務在某些環境中執行很正常,但在另一些環境中執行就會間歇性的發生 RedisCommandTimeoutException:有時長時間沒人使用(當然也不操作 Redis 了),例如一個晚上沒人作業系統,第二天早上使用時就會發生這個異常。而且發生該異常之後,訪問 Redis 就會一直拋這個異常,但過了一段時間後,又正常了。或者立即重啟微服務,也會正常了。

  • lettuce 版本:5.3.0
  • Redis 版本:官方 docker 映象, 5.0,預設配置
  • Spring boot 版本:2.1.x

經過日誌排查(lettuce 的日誌級別需要開啟 DEBUGTRACE),發生RedisCommandTimeoutException 的原因時lettuce的 Connection 已經斷了,發生異常後大約 15 分鐘,lettuceConnectionWatchdog會進行自動重連。

那麼為何 lettuce 的 Connection 為什麼會斷呢?而 ConnectionWatchdog為什麼沒有立即重連呢?又怎麼解決這些問題呢?這些問題如果不弄清楚不解決,會嚴重影響系統的可用性,總不能讓使用者等十幾分鍾再用吧,也不能總重啟應用吧。

網上也搜到了類似的問題,看來還是挺多人遇到相同的問題的。但大部分都沒說清楚這個現象的原因,也沒說真正的解決方法。網上幾乎全部的解決方法都是將lettuce換成了 jedis,迴避了這個問題。

0x01 本質

換成jedis固然可以解決問題,但既然 lettuce能成為Spring預設的客戶端,還是有先進的地方的。而且遇到問題不搞清楚,心裡也癢癢的。下面會闡述這些問題的來龍去脈。

1.1 為什麼 Redis 連線會斷

其實這個問題並不是很重要,因為Socket連線斷已經是事實,而且在分散式環境中,網路分割槽是必然的。在網路環境,Redis 伺服器主動斷掉連線是很正常的,lettuce 的作者也提及 lettuce 一天發生一兩次重連是很正常的。

那麼哪些情況會導致連線斷呢:

  • Linux 核心的 keepalive 功能可能會一直收不到客戶端的回應;
  • 收到與該連線相關的 ICMP 錯誤資訊;
  • 其他網路鏈路問題等等;

如果要需要真正查明原因,需要 tcp dump 進行抓包,但意義不大,除非斷線的概率大,像一天一兩次或者幾天才一次不必花這麼大力氣去查這個。而最主要的問題是 lettuce 客戶端能否及時檢測到連線已斷,並儘快重連。

1.2 為何 lettuce 沒有立刻重連

lettuce的重連機制這裡進行贅述,有興趣的同學可以參考 Redis客戶端Lettuce原始碼【四】 這篇文件或者自行閱讀 lettuceConnectionWatchdog的原始碼。

根據ConnectionWatchdog重連的機制(收到nettyChannelInactived事件後啟動重連的執行緒不斷進行連線)可以確定,連線是由 Redis 服務端斷開的,因為如果是客戶端主動斷開連線,那麼一定能收到ChannelInactived,因此,之所以lettuce要等 15 分鐘後才重連,是因為沒收到ChanelInactived事件。

那麼為什麼客戶端沒有到ChannelInactived事件呢?很多情況都會,例如:

  • 客戶端沒收到服務端 FIN 包;
  • 網路鏈路斷了,例如拔網線,斷電等等;

在我們這個情況,應該是沒收到服務端的 FIN 包。

好了,我們再來看另一個問題:日誌顯示發生RedisCommandTimeoutException後,15 分鐘後收到ChannelInactived事件。那麼,為什麼會大約是 15 分鐘而不是別的時間呢?

其實,這是與 Linux 底層Socket的實現有關--這就是超時重傳機制。也就是/proc/sys/net/ipv4/tcp_retries2引數,關於重傳機制,可以看這篇文章:

Linux TCP_RTO_MIN, TCP_RTO_MAX and the tcp_retries2 sysctl

根據重傳機制,發生RedisCommandTimeoutException的命令會重傳 tcp_retries2這麼多次,剛剛好是 15 分鐘左右。

小結:
問題的原因已經清楚了,這裡需要對 lettuce的重連機制、netty的工作原理、Linux socket實現原理有一定的瞭解。既然問題的原因找到了,如何解決呢?顯然無論是網上說的替換Jedis客戶端,還是重啟應用、還是等 15 分鐘,都不是好辦法。

0x02 解決方案

既然找到了問題原因所在,那麼可以根據這些原因來解決。主要有三種解決的方案:

2.1 設定 Linux 的 TCP_RETRIES2 引數

針對等待 15 分鐘,那麼就可以猜想是不是可以設定 Linux 的 TCP_RETRIES2 引數小點來縮短等待時間呢?答案是肯定的;這個引數 Linux 的預設值是 15,而有些應用(如 Oracle)要求設定為 3。

其實,一般情況下,tcp資料包超時了,重發 3 次都不成功,重發再多幾次也是枉然的。

但是這個方案有個缺點:

如果修改了這個引數,也會影響到其他應用,因為這個是全域性的引數。那麼能否單獨針對某個應用程式設定 Socket Option呢?很遺憾的是,筆者在 netty裡並沒找到該選項的設定,無論是EpollChannelOption 還是 JDK 的ExtendedSocketOptions

所幸的是:

netty提供另一個引數的設定:TCP_USER_TIMEOUT,這個引數就是為了針對單獨設定某個應用程式的超時重傳的設定。下面一小節講述如何使用。

2.2 設定 Socket Option 的 TCP_USER_TIMEOUT 引數

Spring Bootauto-configuration中,ClientResources的初始化是預設的 ClientResources,因此,我們可以自定義一個 ClientResources

@Bean
public ClientResources clientResources(){
    return ClientResources clientResources = ClientResources.builder()
	    .nettyCustomizer(new NettyCustomizer() {

    		@Override
    		public void afterBootstrapInitialized(Bootstrap bootstrap) {
    			bootstrap.option(EpollChannelOption.TCP_USER_TIMEOUT, 10);
    		}
    	    })
    	  .build();
}

2.3 定製 lettuce:增加心跳機制

上面兩個方案,縮短了等待的時長,都是依賴作業系統底層的通知。如果不想依賴底層作業系統的通知,唯一的辦法就是自己在應用層增加心跳機制。

如上述的方案,lettuce提供了NettyCustomizer進行擴充套件,熟悉netty的同學,應該聽說過netty所提供的心跳機制--IdleStateHandler,結合這兩者,就很容易在初始化netty時增加心跳機制:


@Bean
public ClientResources clientResources(){

    NettyCustomizer nettyCustomizer = new NettyCustomizer() {
        
        @Override
        public void afterChannelInitialized(Channel channel) {
            channel.pipeline().addLast(
                    new IdleStateHandler(readerIdleTimeSeconds, writerIdleTimeSeconds, allIdleTimeSeconds));
            channel.pipeline().addLast(new ChannelDuplexHandler() {
                @Override
                public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
                    if (evt instanceof IdleStateEvent) {
                        ctx.disconnect();
                    }
                }
            });
        }
        
        @Override
        public void afterBootstrapInitialized(Bootstrap bootstrap) {
        }
        
    };
    
    return ClientResources.builder().nettyCustomizer(nettyCustomizer ).build();
}

這裡由客戶端自己做心跳檢測,一旦發現Channel死了,主動關閉ctx.close(),那麼ChannelInactived事件一定會被觸發了。但是這個方案有個缺點,增加了客戶端的壓力。

0x03 總結

lettuce是一個優秀的開源軟體,設計和程式碼都很優美。通過這次的問題排查和解決問題,加深了自己對netty,Linux Socket機制、TCP/IP 協議的理解。

0x04 參考

4.1 Redis客戶端Lettuce原始碼【三】
4.2 Redis客戶端Lettuce原始碼【四】
4.3 Linux TCP_RTO_MIN, TCP_RTO_MAX and the tcp_retries2 sysctl
4.4 https://github.com/lettuce-io/lettuce-core/issues/762

相關文章