Connection reset原因分析和解決方案
現象描述
在使用 HttpClient 呼叫後臺 resetful 服務時,“Connection reset” 是一個比較常見的問題,有同學跟我私信說被這個問題困擾很久了,今天就來分析下,希望能幫到大家。例如我們線上的閘道器日誌就會拋該錯誤:
從日誌中可以看到是 Socket 套接字在 read 資料時丟擲了該錯誤。
原因分析
導致 “Connection reset” 的原因是伺服器端因為某種原因關閉了 Connection,而客戶端依然在讀寫資料,此時伺服器會返回復位標誌 “RST”,然後此時客戶端就會提示 “java.net.SocketException: Connection reset”。
TCP回顧
可能有同學對復位標誌 “RST” 還不太瞭解,這裡簡單解釋一下:
TCP 建立連線時需要三次握手,在釋放連線需要四次揮手;例如三次握手的過程如下:
- 第一次握手:客戶端傳送 syn 包(syn=j)到伺服器,並進入 SYN_SENT 狀態,等待伺服器確認;
- 第二次握手:伺服器收到 syn 包,並會確認客戶的 SYN(ack=j+1),同時自己也傳送一個 SYN 包(syn=k),即 SYN+ACK 包,此時伺服器進入 SYN_RECV 狀態;
- 第三次握手:客戶端收到伺服器的 SYN+ACK 包,向伺服器傳送確認包 ACK (ack=k+1),此包傳送完畢,客戶端和伺服器進入 ESTABLISHED(TCP 連線成功)狀態,完成三次握手。
可以看到握手時會在客戶端和伺服器之間傳遞一些 TCP 頭資訊,比如 ACK 標誌、SYN 標誌以及揮手時的 FIN 標誌等。
除了以上這些常見的標誌頭資訊,還有另外一些標誌頭資訊,比如推標誌 PSH、復位標誌 RST 等。其中復位標誌 RST 的作用就是 “復位相應的 TCP 連線”。
TCP 連線和釋放時還有許多細節,比如半連線狀態、半關閉狀態等。詳情請參考這方面的鉅著《TCP/IP 詳解》和《UNIX 網路程式設計》。
原因闡述
前面說到出現 “Connection reset” 的原因是伺服器關閉了 Connection [呼叫了 Socket.close () 方法]。大家可能有疑問了:伺服器關閉了 Connection 為什麼會返回 “RST” 而不是返回 “FIN” 標誌。原因在於 Socket.close () 方法的語義和 TCP 的 “FIN” 標誌語義不一樣:傳送 TCP 的 “FIN” 標誌表示我不再發送資料了,而 Socket.close () 表示我不在傳送也不接受資料了。問題就出在 “我不接受資料” 上,如果此時客戶端還往伺服器傳送資料,伺服器核心接收到資料,但是發現此時 Socket 已經 close 了,則會返回 “RST” 標誌給客戶端。當然,此時客戶端就會提示:“Connection reset”。詳細說明可以參考 oracle 的有關文件:
另一個可能導致的 “Connection reset” 的原因是伺服器設定了 Socket.setLinger (true, 0)。但我檢查過線上的 tomcat 配置,是沒有使用該設定的,而且線上的伺服器都使用了 nginx 進行反向代理,所以並不是該原因導致的。關於該原因上面的 oracle 文件也談到了並給出瞭解釋。
此外囉嗦一下,另外還有一種比較常見的錯誤 “Connection reset by peer”,該錯誤和 “Connection reset” 是有區別的:
-
伺服器返回了 “RST” 時,如果此時客戶端正在從 Socket 套接字的輸出流中讀資料則會提示 Connection reset”;
-
伺服器返回了 “RST” 時,如果此時客戶端正在往 Socket 套接字的輸入流中寫資料則會提示 “Connection reset by peer”。
“Connection reset by peer” 如下圖所示:
問題解決
前面談到了導致 “Connection reset” 的原因,而具體的解決方案有如下幾種:
-
出錯了重試;
-
客戶端和伺服器統一使用 TCP 長連線;
-
客戶端和伺服器統一使用 TCP 短連線。
出錯重試
首先是出錯了重試:這種方案可以簡單防止 “Connection reset” 錯誤,然後如果服務不是 “冪等” 的則不能使用該方法;比如提交訂單操作就不是冪等的,如果使用重試則可能造成重複提單。
統一建立長連線
然後是客戶端和伺服器統一使用 TCP 長連線:客戶端使用 TCP 長連線很容易配置(直接設定 HttpClient 就好),而伺服器配置長連線就比較麻煩了,就拿 tomcat 來說,需要設定 tomcat 的 maxKeepAliveRequests、connectionTimeout 等引數。另外如果使用了 nginx 進行反向代理或負載均衡,此時也需要配置 nginx 以支援長連線(nginx 預設是對客戶端使用長連線,對伺服器使用短連線)。
使用長連線可以避免每次建立 TCP 連線的三次握手而節約一定的時間,但是我這邊由於是內網,客戶端和伺服器的 3 次握手很快,大約只需 1ms。ping 一下大約 0.93ms(一次往返);三次握手也是一次往返(第三次握手不用返回)。根據 80/20 原理,1ms 可以忽略不計;又考慮到長連線的擴充套件性不如短連線好、修改 nginx 和 tomcat 的配置代價很大(所有後臺服務都需要修改);所以這裡並沒有使用長連線。ping 伺服器的時間如下圖:
統一建立短連線
最後的解決方案是客戶端和伺服器統一使用 TCP 短連線:我這邊正是這麼幹的,而使用短連線既不用改 nginx 配置,也不用改 tomcat 配置,只需在使用 HttpClient 時使用 http1.0 協議並增加 http 請求的 header 資訊(Connection: Close),原始碼如下:
1 httpGet.setProtocolVersion(HttpVersion.HTTP_1_0); 2 httpGet.addHeader(HTTP.CONN_DIRECTIVE, HTTP.CONN_CLOSE);
最後再補充幾句,雖然對於每次請求 TCP 長連線只能節約大約 1ms 的時間,但是具體是使用長連線還是短連線還是要衡量下,比如你的服務每天的 pv 是 1 億,那麼使用長連線節約的總時間為:
1億*1ms=10^8*1ms=10^5*1s=10^5*1h/3600≈27.78h
神奇的是,億萬級 pv 的服務使用長連線一天內節約的總時間為 27.78 小時(竟然大於一天)。
所以使用長連線還是短連線大家需要根據自己的服務訪問量、擴充套件性等因素衡量下。但是一定要注意:伺服器和客戶端的連線一定要保持一致,要麼都是長連線,要麼都是短連線。
知識拓展
稍微補充下,有時改成短連結不一定能完全解決該問題,因為在http請求傳送和返回響應肯定是需要時間的,在伺服器高併發環境下很容易觸發安全策略或者其他策略導致連結強制斷開(比如伺服器限制了單個ip連線數),在不用考慮冪等問題時,可以採用重試機制。
這裡使用okhttp4使用攔截器重試,這樣能大概率解決所有問題
1 private static OkHttpClient okHttpClient = new OkHttpClient.Builder() 2 .connectTimeout(0, TimeUnit.SECONDS) 3 .readTimeout(0, TimeUnit.SECONDS) 4 .retryOnConnectionFailure(true) 5 .addInterceptor(myOkHttpRetryInterceptor) 6 .build();
其中myOkHttpRetryInterceptor可以仿照這個部落格編寫
1 package com.gomefinance.esign.httpretry; 2 3 import lombok.extern.slf4j.Slf4j; 4 import okhttp3.Interceptor; 5 import okhttp3.Request; 6 import okhttp3.Response; 7 8 import java.io.IOException; 9 import java.io.InterruptedIOException; 10 import java.util.List; 11 12 /** 13 * User: Administrator 14 * Date: 2017/9/19 15 * Description: 16 */ 17 18 @Slf4j 19 public class MyOkHttpRetryInterceptor implements Interceptor { 20 public int executionCount;//最大重試次數 21 private long retryInterval;//重試的間隔 22 MyOkHttpRetryInterceptor(Builder builder) { 23 this.executionCount = builder.executionCount; 24 this.retryInterval = builder.retryInterval; 25 } 26 27 28 29 @Override 30 public Response intercept(Chain chain) throws IOException { 31 Request request = chain.request(); 32 Response response = doRequest(chain, request); 33 int retryNum = 0; 34 while ((response == null || !response.isSuccessful()) && retryNum <= executionCount) { 35 log.info("intercept Request is not successful - {}",retryNum); 36 final long nextInterval = getRetryInterval(); 37 try { 38 log.info("Wait for {}",nextInterval); 39 Thread.sleep(nextInterval); 40 } catch (final InterruptedException e) { 41 Thread.currentThread().interrupt(); 42 throw new InterruptedIOException(); 43 } 44 retryNum++; 45 // retry the request 46 response = doRequest(chain, request); 47 } 48 return response; 49 } 50 51 private Response doRequest(Chain chain, Request request) { 52 Response response = null; 53 try { 54 response = chain.proceed(request); 55 } catch (Exception e) { 56 } 57 return response; 58 } 59 60 /** 61 * retry間隔時間 62 */ 63 public long getRetryInterval() { 64 return this.retryInterval; 65 } 66 67 public static final class Builder { 68 private int executionCount; 69 private long retryInterval; 70 public Builder() { 71 executionCount = 3; 72 retryInterval = 1000; 73 } 74 75 public MyOkHttpRetryInterceptor.Builder executionCount(int executionCount){ 76 this.executionCount =executionCount; 77 return this; 78 } 79 80 public MyOkHttpRetryInterceptor.Builder retryInterval(long retryInterval){ 81 this.retryInterval =retryInterval; 82 return this; 83 } 84 public MyOkHttpRetryInterceptor build() { 85 return new MyOkHttpRetryInterceptor(this); 86 } 87 } 88 89 }