引子
前段時間我們的服務由於一台交換機網絡出現故障,導致數據庫連接不上,但是在數據庫的連接超時參數設置不合理,connect timeout設置的過長,導致接口耗時增加。DB連接超時后線程未正常結束,上游請求又持續進來,最終耗光了Java線程,JVM進入持續GC狀態,無法恢復,直到手工重啟才恢復服務。
於是在服務的保護方面新增了兩個措施,第一,調小服務端workThread的最大線程數。第二,在Server端設置Accept后Socket的readTimeout時間,當Socket調用read方法后在一定時間內讀不到數據的時候會自動關閉socket。
說了這么多背景,但是本篇描述的不是這兩個問題,而是我們上線了保護措施后遇到的奇怪的問題。
我們上線了這兩個保護措施后,上游調用方向我們報異常,說請求總是返回錯誤,表現的情形為Socket的InputStream的read方法返回-1。
對方一口咬定是我們的問題,因為發送已經成功了,發送時候沒有報任何異常,然而讀消息的時候就返回-1了。
猜測應該是復用了已超時的連接所導致的,詢問了一下,發現對方果然使用了連接池,而且這條業務線請求量比較小,所以極有可能是連接池中的socket其實已經在服務端被超時關閉了,所以調用服務的時候會發生異常,可是為什么發送的時候不會報錯呢?而是在讀結果的時候發現連接關閉了呢?
Flush?
首先很容易想到的是有一個時間差。對方正在發送的時候,socket並還沒有被關閉,但是這些發送的內容在網絡傳輸過程中的時候,服務端這邊把socket給關閉了,所以出現了上文所描述的問題。對於rpc調用來說,一旦涉及到網絡的,都可以認為時間間隔是完全隨機的值,這種情形也是能解釋得通。
但是,上游反饋這個異常非常的多,而且所有的都是同一個異常,讀操作時候報錯,連接已關閉。如果是上文中的情形才出現,那么首先這種錯誤應該比較少,而且絕大多數應該都是寫出錯,而不是讀出錯。
socket.getOutputStream.flush()返回成功到底是一個什么樣的行為?
於是寫的小程序進行測試,邏輯很簡單,Server端accept后立即關閉socket,而client端則是連上1s后發送消息。
在flush()成功返回后立即記錄下System.currentTimeMillis(),同時使用ngrep進行抓包,分析TCP層面數據發送和接受的時間。
當時顯示的時間的11:46:52.154 flush返回成功,建議測試的時候使用兩台間隔遠一點的機器來測試,我當時在本機上測試的,抓到一條這個跨ms的記錄還費了好大一番功夫。
其實java的socket的outputstream在進行flush的時候,是不需要block到直到ACK到達才返回成功,或者說RST到達返回失敗的。
只是簡單地把數據交給TCP/IP層后就直接返回success了,也有可能是TCP層返回PUSH成功后在返回success。
但是可以肯定的是,success的時間在PUSH之后,在收到ACK之前。目前還不知道怎么把nanoseconds轉化成DateTime的形式
,不然就可以更加明確地確認,這個返回success是在TCP層面PUSH之前還是TCP層面PUSH之后。
無論是將Socket的setTcpNoDelay設置成true或false,都是一樣的表現,outputStream.flush()收到ACP/RST之前就已經成功返回success了。
所以說,當一個Socket已經被remote方close后,執行write方法卻返回success是一個正常的行為,而且從Stack Overflow的問答如何判斷一個socket已經關閉。可以了解到其實在java層面,一個socket如何確認自己的連接狀態是好的,還是已經不可用了,沒有一個好的方法。唯一有用的方法就是執行read操作或者是一個write操作,在連接已經斷開的情況下,read會返回-1或者是Connection Reset,write會拋出Broken PipeLine。
但是對於一個尚且活着的socket調用read方法,會導致線程阻塞,這個驗證方法基本不可取。
做了一些測試,查看這些字段的值,isConnected、isClosed、isInputShutdown、isOutputShutdown,都是徒勞。
繼續測試
但是之前描述過,flush操作返回值是不靠譜的。所以在對一個已經被remote端close的socket,進行read or write操作返回結果的情形還不是像上文描述的這么簡單。其實此socket處於CLOSE_WAIT狀態。
方便說明的話,假設W是寫操作,R是讀操作,統計一下依次做如下操作的結果。
測試的過程,本機啟動一個server,accept之后立即關閉。本機client連接server后,先sleep 1 s,之后做接下來的工作。
不同操作順序的結果如下
操作 | 結果 |
---|---|
W | success |
R | connection reset |
W | broken pipeline |
操作 | 結果 |
---|---|
R | -1 |
W | success |
W | broken pipeline |
操作 | 結果 |
---|---|
R * N | -1 |
W | success |
W | broken pipeline |
所以說,其實讀其實也根本沒有對write操作產生影響,只有W的操作,才會真正地觸發這個socket去看看自身的狀態到底是什么。
而讀這個操作,讀一個關閉后的socket,會返回-1,但是不會對下一次的R或W操作的結果產生影響,也就是說,不會去更新本地是socket的狀態。
后來才發現,其實是因為這個Socket處於CLOSE_WAIT狀態,進行了FLUSH操作后,會收到一個RST的包,Socket在收到RST后會立即關閉此Socket,不會進行所謂的LAST_ACK和TIME_WAIT狀態。
直到這里,還是沒有復現之前說過的那個問題。
操作 | 結果 |
---|---|
W | success |
R | -1 |
仔細想一想,既然Write操作是不等待ACK之后就立即返回的,所以說明這個更新Socket狀態的動作是異步做的,異步的動作肯定是有時間差的。
所以說,沒有復現R返回-1的情形有可能是因為之前的操作都是在本機在測試,網絡的處理很快,PUSH后立馬就收到了RST,所以導致R的操作就拋出Connection Reset的異常。
於是,將Server放在另一台遠一點的機器,延遲1ms左右,於是就復現了剛才的問題。當然,有趣的是,上文圖中的那些情況,都不成立了,出現了這種情況:
操作 | 結果 |
---|---|
W | success |
R | -1 |
W | success |
是不是意味着之前的所有結論都是錯的?當然沒關系,因為第一次的情形都是出於網絡基本無延遲的情況下。
其實這些結果是程序嚴格按順序執行時的結果,編程理想情況下的結果。其實這就是我們真實想要的,所有步驟都順序執行的結果。
所以說,真實確認這個socket關閉不可用的結論,其實是在收到RST之后異步處理的。
現在我們回到圖1
去仔細看看,從網絡抓包情況來看,即使是在收到RST后,還是有W返回success的情況,因為從TCP層面收到RST后,到應用層對socket的內存內容進行一些修改,還是需要一定時間的。
四次揮手?
之所以會出現這么些問題,都是因為處於中間狀態所導致,Server端的socket處於FIN_WAIT2狀態,而Client端的Socket處於CLOST_WAIT狀態,並沒有走到一個完全關閉的終態。
在上面的測試用例中,我把client端的等待時間改成了120S,用netstat命名查詢,這兩個Socket還是處於兩個狀態,更長的時間並沒有進行測試。
在TCP/IP Vol1 18章中描述到“許多伯克利的實現會在空閑10分鍾75秒的情況下關閉處於FIN_WAIT2狀態的Socket”,Client端會如何表現,目前我沒測試,等哪天有心情了測試一下
TCP/IP協議的四次揮手流程圖在這就不贅述了,感覺被課本騙了,說好的四次揮手呢,為什么總是進行到一半就不動了呢?
什么情況下會正常地走完這個流程?
1、Client的socket也主動調用close方法。
2、Client程序在CLOSE_WAIT狀態下終止了,四次握手會立即就完成,操作系統層面做了這個FIN包的發送工作。
3、如果Client程序如果沒有結束,也不主動發FIN包,會停留在此狀態,直至被操作系統回收。
這從另外一方面也驗證了為什么一定得主動關閉連接,既是幫助別人也是幫助自己。
即使是Server已經把你的連接關閉了,你這邊也會一直停留在CLOSE_WAIT狀態,Server端會停留在FIN_WAIT_2狀態。
這兩個狀態是否會進入完全關閉的狀態,以及進入完全關閉的狀態所需的時間,由操作系統來決定,也就是說,和操作系統的實現有關。
小現象
1、在我的操作系統,OS X EI Capitan 10.11.5 (15F34)情況下,FIN_WAIT_2自動關閉的時間符合10分鍾75秒這個描述,精確時間我也沒統計到,差距1分鍾內。已經30分鍾過去了,Client端的Socket還傻傻停留在CLOST_WAIT狀態,我也不知道啥時候它會完全關閉。
2、OSX作為Server服務的時候,無論是TCP連接建立的時候,還是TCP關閉的時候,都會重發一個ACK,linux下不存在這樣的問題。
參考文檔
java-socket-api-how-to-tell-if-a-connection-has-been-closed
TCP/IP Illustrated Volume 1: The Protocols
后記
后續看到有可以通過sendUrgentData()來實現發送urgent data,既實現寫探活,同時數據還會被對端忽略,不進入業務層。但是也不能達到實時性的效果,也是會在第一次成功,收到RST后才失敗。
所以還是覺得NIO的項目好,比如netty的channel,可以直接收到INACTIVE和UNREGISTERED事件。