開啟 Keep-Alive 可能會導致http 請求偶發失敗

蓝胖子的编程梦發表於2024-04-03

大家好,我是藍胖子,說起提高http的傳輸效率,很多人會開啟http的Keep-Alive選項,這會http請求能夠複用tcp連線,節省了握手的開銷。但開啟Keep-Alive真的沒有問題嗎?我們來細細分析下。

最大空閒時間造成請求失敗

通常我們開啟Keep-Alive後 ,服務端還會設定連線的最大空閒時間,這樣能保證在沒有請求發生時,及時釋放連線,不會讓過多的tcp連線白白佔用機器資源。

問題就出現在服務端主動關閉空閒連線這個地方,試想一下這個場景,客戶端複用了一個空閒連線傳送http請求,但此時服務端正好檢測到這個連線超過了配置的連線最大空閒時間,在請求到達前,提前關閉了空閒連線,這樣就會導致客戶端此次的請求失敗。

過程如下圖所示,

image.png

如何避免此類問題

上述問題在理論上的確是一直存在的,但是我們可以針對傳送http請求的程式碼做一些加強,來儘量避免此類問題。來看看在Golang中,http client客戶端是如何儘量做到安全的http重試的。

go http client 是如何做到安全重試請求的?

在golang中,在傳送一次http請求後,如果發現請求失敗,會透過shouldRetryRequest 函式判斷此次請求是否應該被重試,程式碼如下,

func (pc *persistConn) shouldRetryRequest(req *Request, err error) bool {  
    if http2isNoCachedConnError(err) {  
       // Issue 16582: if the user started a bunch of  
       // requests at once, they can all pick the same conn       // and violate the server's max concurrent streams.       // Instead, match the HTTP/1 behavior for now and dial       // again to get a new TCP connection, rather than failing       // this request.      
        return true  
    }  
    if err == errMissingHost {  
       // User error.  
       return false  
    }  
    if !pc.isReused() {  
       // This was a fresh connection. There's no reason the server  
       // should've hung up on us.       //       // Also, if we retried now, we could loop forever       // creating new connections and retrying if the server       // is just hanging up on us because it doesn't like       // our request (as opposed to sending an error).       
       return false  
    }  
    if _, ok := err.(nothingWrittenError); ok {  
       // We never wrote anything, so it's safe to retry, if there's no body or we  
       // can "rewind" the body with GetBody.      
        return req.outgoingLength() == 0 || req.GetBody != nil  
    }  
    if !req.isReplayable() {  
       // Don't retry non-idempotent requests.  
       return false  
    }  
    if _, ok := err.(transportReadFromServerError); ok {  
       // We got some non-EOF net.Conn.Read failure reading  
       // the 1st response byte from the server.       
       return true  
    }  
    if err == errServerClosedIdle {  
       // The server replied with io.EOF while we were trying to  
       // read the response. Probably an unfortunately keep-alive       // timeout, just as the client was writing a request.       
       return true  
    }  
    return false // conservatively  
}

我們來挨個看看每個判斷邏輯,

http2isNoCachedConnError 是關於http2的判斷邏輯,這部分邏輯我們先不管。

err == errMissingHost 這是由於請求路徑中缺少請求的域名或ip資訊,這種情況不需要重試。

pc.isReused() 這個是在判斷此次請求的連線是不是屬於連線複用情況,因為如果是新建立的連線,伺服器正常情況下是沒有理由拒絕我們的請求,此時如果請求失敗了,則新建連線就好,不需要重試。

if _, ok := err.(nothingWrittenError); ok 這是在判斷此次的請求失敗的時候是不是還沒有向對端伺服器寫入任何位元組,如果沒有寫入任何位元組,並且請求的body是空的,或者有body但是能透過req.GetBody 恢復body就能進行重試。

📢📢注意,因為在真正向連線寫入請求頭和body時,golang其實是構建了一個bufio.Writer 去封裝了連線物件,資料是先寫到了bufio.Writer 緩衝區中,所以有可能出現請求體Request已經讀取了部分body,寫入到緩衝區中,但實際真正向連線寫入資料時失敗的場景,這種情況重試就需要恢復原先的body,重試請求時,從頭讀取body資料。

req.isReplayable() 則是從請求體中判斷這個請求是否能夠被重試,如果不滿足重試要求,則直接不重試,滿足重試要求則會繼續進行下面的重試判斷。 其程式碼如下,如果http的請求body為空,或者有GetBody 方法能為其恢復body,並且是"GET", "HEAD", "OPTIONS", "TRACE" 方法之一則認為該請求重試是安全的。

還有種情況是如果http請求頭中有Idempotency-Key 或者X-Idempotency-Key 也認為重試是安全的。

X-Idempotency-KeyIdempotency-Key 其實是為了給post請求的重試給了一個後門,對應的key是由業務方自己定義的具有冪等性質的key,服務端可以拿到它做冪等性校驗,所以重試是安全的。

func (r *Request) isReplayable() bool {  
    if r.Body == nil || r.Body == NoBody || r.GetBody != nil {  
       switch valueOrDefault(r.Method, "GET") {  
       case "GET", "HEAD", "OPTIONS", "TRACE":  
          return true  
       }  
       // The Idempotency-Key, while non-standard, is widely used to  
       // mean a POST or other request is idempotent. See       // https://golang.org/issue/19943#issuecomment-421092421       
       if r.Header.has("Idempotency-Key") || r.Header.has("X-Idempotency-Key") {  
          return true  
       }  
    }  
    return false  
}

只有認為請求重試是安全後,才會進一步判斷請求失敗 是不是由於服務端關閉空閒連線造成的 _, ok := err.(transportReadFromServerError)errServerClosedIdle都是由於服務端關閉空閒連線造成的錯誤碼,如果產生的錯誤碼是其中之一,則都是允許被重試的。

🍉🍉🍉所以,綜上你可以看出,如果你發的請求是一個不帶有Idempotency-Key或者X-Idempotency-Keypost請求頭的post請求,那麼即使是由於伺服器關閉空閒連線造成請求失敗,該post請求是不會被重試的。不過在其他請求方法比如GET方法下,由伺服器關閉空閒連線造成的請求錯誤,Golang 能自動重試。

最佳實踐

針對上述場景,我們應該如何設計我們的請求傳送來保證安全可靠的傳送http請求呢?針對於Golang開發環境,我總結幾點經驗,

1,GET請求可以自動重試,如果你的介面沒有完全準尋restful 風格,GET請求的處理方法仍然有修改資料的操作,那麼你應該保證你的介面是冪等的。

2,POST請求不會自動重試,但是如果你需要讓你的操作百分百的成功,請新增失敗重試邏輯,同樣,服務端最好做好冪等操作。

3,如果對效能要求不是那麼高,那麼直接關閉掉http的長連結,將請求頭的Connection 欄位設定為close 這樣每次傳送傳送http請求時都是用的新的連線,不會存在潛在的服務端關閉空閒連線造成請求失敗的問題。

4,第四點,其實你可以發現,網路請求,不管你的網路情況是否好壞,都是存在失敗的可能,即使將http長連線關掉,在網路壞的情況下,請求還是會失敗,失敗了要想保證成功,就得重試,重試就一定得保證服務端介面冪等了,所以,你的介面如果是冪等的,你的請求如果具有重試邏輯,那麼恭喜你,你的系統十分可靠。

5,最後一點,千萬不要抱著僥倖心理去看待網路請求,正如第四點說的那樣,不管你的網路情況是否好壞,都是存在失敗的可能。嗯,面對異常程式設計。

相關文章