死磕生菜 -- lettuce 間歇性發生 RedisCommandTimeoutException 的深層原理及解決方案
阿新 • • 發佈: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 的日誌級別需要開啟 `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原始碼【四】](https://blog.csdn.net/weixin_45145848/article/details/103866456) 這篇文件或者自行閱讀 `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](https://pracucci.com/linux-tcp-rto-min-max-and-tcp-retries2.html)
根據重傳機制,發生`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`。
```java
@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`時增加心跳機制:
```java
@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原始碼【三】](https://blog.csdn.net/weixin_45145848/article/details/103710855)
4.2 [Redis客戶端Lettuce原始碼【四】](https://blog.csdn.net/weixin_45145848/article/details/103866456)
4.3 [Linux TCP_RTO_MIN, TCP_RTO_MAX and the tcp_retries2 sysctl](https://pracucci.com/linux-tcp-rto-min-max-and-tcp-retries2.html)
4.4 [https://github.com/lettuce-io/lettuce-core/issues/762](https://github.com/lettuce-io/lettuce-core/issues/762)