死磕生菜 — lettuce 間歇性發生 RedisCommandTimeoutException 的深層原理及解決方案
0x00 起源
項目的一些微服務集成了 Spring Data Redis
,而底層的 Redis 客戶端是 lettuce
,這也是默認的客戶端。微服務在某些環境中運行很正常,但在另一些環境中運行就會間歇性的發生 RedisCommandTimeoutException
:有時長時間沒人使用(當然也不操作 Redis 了),例如一個晚上沒人操作系統,第二天早上使用時就會發生這個異常。而且發生該異常之後,訪問 Redis 就會一直拋這個異常,但過了一段時間後,又正常了。或者立即重啟微服務,也會正常了。
- lettuce 版本:5.3.0
- Redis 版本:官方 docker 鏡像, 5.0,默認配置
- Spring boot 版本:2.1.x
經過日誌排查(lettuce 的日誌級別需要開啟 DEBUG
或 TRACE
),發生RedisCommandTimeoutException
的原因時lettuce
的 Connection 已經斷了,發生異常後大約 15 分鐘,lettuce
的 ConnectionWatchdog
會進行自動重連。
那麼為何 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源碼【四】 這篇文檔或者自行閱讀 lettuce
中ConnectionWatchdog
的源碼。
根據ConnectionWatchdog
重連的機制(收到netty
的ChannelInactived
事件後啟動重連的線程不斷進行連接)可以確定,連接是由 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 Boot
的auto-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 //github.com/lettuce-io/lettuce-core/issues/762