HttpClient偶爾報NoHttpResponseException: xxx failed to respond 問題分析

HttpClient偶爾報NoHttpResponseException: xxx failed to respond

背景描述

調用底層服務偶爾會報以下錯誤

org.apache.http.NoHttpResponseException: submit.10690221.com:9012 failed to respond

    at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:141)
    ....

第一次碰到,先google一下,發現不少相同的情況,講的也很不錯,但是呢,我想自己復現一下,并且自己去分析并解決,這樣能更好的去理解 網絡 這東西

復現方法

這個怎么復現呢,通過google得知,這個只會在服務器端keep-alive剛好過期的時間我們進行訪問才能大概率復現,方法如下:

wireshark進行抓包得出底層服務器的keep-alive時間

寫一段程序,用于探測底層服務器的keep-alive,代碼如下:

@Test
public void test121() throws Exception {
    String url = "http://xxxxxxx:9012/hy/json";
    CloseableHttpClient httpClient = HttpClients.createDefault();
    HttpPost request = new HttpPost(url);

    httpClient.execute(request, response -> {
        String content = EntityUtils.toString(response.getEntity());
        System.out.println(content);
        return content;
    });

    Thread.sleep(1000000);

}

開啟wireshark進行抓包,執行程序直到下圖出現即可停止


重點看左下角的紅色框,時間相差65秒左右,沒錯從而可以得知底層服務器的keep-alive 是 65秒,也就是當一個連接socket 65秒內沒有數據交互,底層服務器就會認為這個連接可以關閉了,因此才會在3分36秒進行揮手操作發送一個FIN包,這時我們稍微改造一下這個程序,如下:

@Test
public void test121() throws Exception {
    String url = "http://xxxxxxx:9012/hy/json";
    CloseableHttpClient httpClient = HttpClients.createDefault();
    HttpPost request = new HttpPost(url);
    while (true) {//加了一個死循環 ^_^
        httpClient.execute(request, response -> {
            String content = EntityUtils.toString(response.getEntity());
            System.out.println(content);
            return content;
        });

        Thread.sleep(65000); //關鍵是這里,設置和底層服務器keep-alive相同
    }
}

相比第一個,有兩個改動

  1. 加了一個循環
  2. 每次調用的間隔改成和底層服務器相同的65秒

我們清空wireshark,運行該程序抓包,結果如下:


問題分析

首先我們分析一下抓包結果


  1. 紅色框1:前3個請求是建立連接的過程,三次握手,接著4個請求就是client和server的數據交互,著重看最后四個請求
    1. 9012 -> 59233 [FIN, ACK]:服務器主動進行關閉,給client發送了FIN包
    2. 59233 -> 9012 [ACK]:client進行回應ACK包
    3. 69233 -> 9012 [FIN, ACK]:按照四次揮手原則,client發現目前數據已經發送完畢了,因此也發出FIN包
    4. 9012 -> 59233 [RST]:服務器直接返回一個RST
  2. 紅色框2:同2
  3. 紅色框3:前面的7個步驟都是相同的,建立連接,數據交互,區別唯獨在于綠色框
    1. 9012 -> 59233 POST /hy/json: client認為服務器端可用,因此給服務器發送數據
    2. 9012 -> 59233 [FIN, ACK]:服務器認為此連接已經失效,因為超過了65的keep-alive時間,主動進行關閉,給client發送了FIN包
    3. 59233 -> 9012 [ACK]:client進行回應ACK包
    4. 69233 -> 9012 [FIN, ACK]:按照四次揮手原則,client發現目前數據已經發送完畢了,因此也發出FIN包
    5. 9012 -> 59233 [RST]:服務器直接返回一個RST 通過Seq=188,可判斷這條是給【9012 -> 59233 POST /hy/json】這個請求回的
    6. 9012 -> 59233 [RST]:服務器直接返回一個RST 通過Seq=189,可判斷這條是給【69233 -> 9012 [FIN, ACK]】回的
    7. 9012 -> 59233 [RST]:服務器直接返回一個RST 通過Seq=189,同6

通過分析抓包數據,得出結果是,當client客戶端認為這條Socket連接有用,這時服務器端卻認為該Socket連接無用,并主動關閉,就會報錯,屬于臨界值沒有處理好的

這時有人就說了,為什么前兩次就沒有問題呢,原因是HttpClient會進行連接過期是否可用的檢查,那么也就能理解這是httpclient的一個bug,即使httpclient有做這么一件事情,但是由于網絡I/O原因,導致httpclient認為一個關閉了的連接是有效的,才報了這個錯誤

接下來我們看看HttpClient為什么會復用一個已經被關閉的連接

由于HttpClient代碼有點多,為了方便快速定位縮小范圍, 我這邊開啟了debug,并對兩者的日志進行了分析
左邊日志是正常交互、右邊是報錯了


我這邊簡化了一下日志,通過仔細分析HttpClient打印的debug日志,可發現左邊正常交互日志 打印了一串 "end of stream" 后進行了連接的重新建立, connection established ,而右邊錯誤日志打印了一串 "[read] I/O error: Read timed out" 后沒有進行連接的重新建立,因此就報錯了

那么可以通過打印 "[read] I/O error: Read timed out"日志的上下文日志縮小 排查代碼的范圍,上文日志 Connection request,下文日志 Connection leased,進行代碼定位


基本上定位到了PooingHttpClientConnectionManager.java這個類,那么進行代碼跟蹤吧


追蹤到了 AbstractConnPool.java類,那么這段代碼什么意思呢,這個就是進行連接是否能夠復用的檢查代碼

對validateAfterInactivity進行判斷,這個是服務器keep-alive的值

  1. leasedEntry.getUpdated() + validateAfterInactivity <= System.currentTimeMillis():如果連接的最后一次使用時間 + 服務器keep-alive的時間 小于等于當前時間,那么就認為該連接可能已經失效了
  2. !validate(leasedEntry): 因此會進行連接是否失效的檢查

跟進去看看


最終找到"end of stream" and "[read] I/O error: Read timed out" 打印的地方
然后回到如下圖代碼:


可以看到

  • 當bytesRead 值為 -1 時,返回true,那么HttpClient就會認為該連接失效了,不能夠復用,并進行清理操作,
  • 當拋出異常是ShockTimeoutException時會返回false, 那么HttpClient就會認為該連接可復用

分析到這,相信大部分人都已經知道為什么會保證錯了,不過還是強烈建議自己動手分析一下,另外大家可去了解一下,為什么會輸出"end of stream" and "[read] I/O error: Read timed out"兩種不同的結果,快去暢游底層Socket編程相關的原理吧,這有助于你更加理解

解決方案

其實當你知道原因后,也能想出對應的解決方案,不過我這邊還是收集列出來了一些

  1. 禁用HttpClient的連接復用(有點扯淡)
  2. 重試方案:http請求使用重發機制,捕獲NohttpResponseException的異常,重新發送請求,重發3次后還是失敗才停止
  3. 根據keep Alive時間,調整validateAfterInactivity小于keepAlive Time,但這種方法依舊不能避免同時關閉
  4. 系統主動檢查每個連接的空閑時間,并提前自動關閉連接,避免服務端主動斷開

推薦使用重試方案

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容