客戶端禁用Keep-alive, 服務端開啟Keep-alive,誰是主動斷開方?


最近部署的web程序,在服務器上出現不少time_wait的連接狀態,會占用tcp端口,費了幾天時間排查。

之前我有結論:HTTP keep-alive 是在應用層對TCP連接的滑動續約復用,如果客戶端、服務器穩定續約,就成了名副其實的長連接。

目前所有的HTTP網絡庫(不論是客戶端、服務端)都默認開啟了HTTP Keep-Alive,通過Request/Response的Connection標頭來協商復用連接。

特定於連接的標頭字段(例如 Connection)不得與 HTTP/2 一起使用

非常規做法導致的短連接

我手上有個項目,由於歷史原因,客戶端禁用了Keep-Alive,服務端默認開啟了Keep-Alive,如此一來協商復用連接失敗, 客戶端每次請求會使用新的TCP連接, 也就是回退為短連接。

客戶端強制禁用Keep-Alive

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

func main() {
	tr := http.Transport{
		DisableKeepAlives: true,
	}
	client := &http.Client{
		Timeout:   10 * time.Second,
		Transport: &tr,
	}
	for {
		requestWithClose(client)
		time.Sleep(time.Second * 1)
	}
}
> DisableKeepAlives: true, 會強制禁用客戶端keep-alive
func requestWithClose(client *http.Client) {
	resp, err := client.Get("http://10.100.219.9:8081")
	if err != nil {
		fmt.Printf("error occurred while fetching page, error: %s", err.Error())
		return
	}
	defer resp.Body.Close()
	c, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("Couldn't parse response body. %+v", err)
	}

	fmt.Println(string(c))
}

web服務端默認開啟Keep-Alive

package main

import (
	"fmt"
	"log"
	"net/http"
)

// 根據RemoteAddr 知道客戶端使用的持久連接
func IndexHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Println("receive a request from:", r.RemoteAddr, r.Header)
	w.Write([]byte("ok"))
}

func main() {
	fmt.Printf("Starting server at port 8081\n")
	// net/http 默認開啟持久連接
	if err := http.ListenAndServe(":8081", http.HandlerFunc(IndexHandler)); err != nil {
		log.Fatal(err)
	}
}

從服務端的日志看,確實是短連接: remoteaddr的port不一樣,客戶端攜帶了 Connection:close header

receive a request from: 10.22.38.48:54722 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54724 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54726 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54728 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54731 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54733 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54734 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54738 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54740 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54741 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54743 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54744 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]
receive a request from: 10.22.38.48:54746 map[Accept-Encoding:[gzip] Connection:[close] User-Agent:[Go-http-client/1.1]]

誰是主動斷開方?

我想當然的以為客戶端是主動斷開方,被現實啪啪打臉。

某一天服務器上超過300的time_wait報警告訴我這tmd是服務器主動終斷連接。

常規的TCP4次揮手, 主動斷開方會進入time_wait狀態,等待2MSL后釋放占用的SOCKET

以下是從服務器上tcpdump抓取的tcp連接信息。

紅框2,3部分明確提示是從 Server端發起TCP的FIN消息, 之后Client回應ACK確認收到Server的關閉通知; 之后Client再發FIN消息,告知現在可以關閉了, Server端最終發ACK確認收到,並進入Time_WAIT狀態,等待2MSL的時間關閉Socket。

特意指出,紅框1表示TCP雙端同時關閉,此時會在Client,Server同時留下time_wait痕跡,發生概率較小。

沒有源碼說個串串

此種情況是服務端主動關閉,我們往回翻一翻golang httpServer的源碼

  • http.ListenAndServe(":8081")
  • server.ListenAndServe()
  • srv.Serve(ln)
  • go c.serve(connCtx) 使用go協程來處理每個請求

服務器連接處理請求的簡略源碼如下:

func (c *conn) serve(ctx context.Context) {
	c.remoteAddr = c.rwc.RemoteAddr().String()
	ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
	defer func() {
    if !c.hijacked() {
			c.close()
			c.setState(c.rwc, StateClosed, runHooks)
		}
	}()

  ......
	// HTTP/1.x from here on.

	ctx, cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

	c.r = &connReader{conn: c}
	c.bufr = newBufioReader(c.r)
	c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)

	for {
		w, err := c.readRequest(ctx)
		......
		serverHandler{c.server}.ServeHTTP(w, w.req)
		w.cancelCtx()
		if c.hijacked() {
			return
		}
		w.finishRequest()
		if !w.shouldReuseConnection() {
			if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
				c.closeWriteAndWait()
			}
			return
		}
		c.setState(c.rwc, StateIdle, runHooks)
		c.curReq.Store((*response)(nil))

		if !w.conn.server.doKeepAlives() {
			// We're in shutdown mode. We might've replied
			// to the user without "Connection: close" and
			// they might think they can send another
			// request, but such is life with HTTP/1.1.
			return
		}

		if d := c.server.idleTimeout(); d != 0 {
			c.rwc.SetReadDeadline(time.Now().Add(d))
			if _, err := c.bufr.Peek(4); err != nil {
				return
			}
		}
		c.rwc.SetReadDeadline(time.Time{})
	}
}

我們需要關注

① for循環,表示嘗試復用該conn,用於處理迎面而來的請求

② w.shouldReuseConnection() = false, 表明讀取到ClientConnection:Close標頭,設置closeAfterReply=true,跳出for循環,協程即將結束,結束之前執行defer函數,defer函數內close該連接

c.close()
......
// Close the connection.
func (c *conn) close() {
	c.finalFlush()
	c.rwc.Close()
}

③ 如果 w.shouldReuseConnection() = true,則將該連接狀態置為idle, 並繼續走for循環,處理后續請求。

自圓其說

最后我們來回顧一下: 為什么客戶端禁用長連接, 會是服務端 主動關閉連接。

Q: 上面只是現象印證了這個結論, 怎么自圓其說明,這個現象的設計初衷呢?

A: http是請求-響應模型,發起方一直是客戶端,connection:keep-alive的初衷是為客戶端后續的請求重用連接
如果我們在某次請求--響應模型中,請求定義了connection:close, 那不再重用這個連接的時機就只有在服務端了,不能等到下次請求再關閉連接,因為可能根本就沒下次請求,所以我們在請求-響應這個周期的末端關閉連接是合理的。

從這個思路看起來,我開篇想當然認為是 【客戶端是主動斷開方】很弱智啊。
按照這個請求-響應單向模型思路, 即使客戶端開啟了keep-alive, 如果與服務器協商失敗(服務器強制關閉),服務器還是會主動關閉, 故主動關閉連接的一方只能是 服務端。

從上文源碼看, 服務端有能力從客戶端標頭拿到Connection:Close, 也可以找到服務端自己的Keep-Alive策略,所以
如果客戶端開啟Keep-Alive, 服務端禁用Keep-Alive,在請求-響應單向模型,服務端依舊是主動斷開方.

我的收獲

  1. tcp 4次揮手的八股文
  2. 短連接在服務器上的效應,time_wait,占用可用的SOCKET, 根據實際業務看是否需要切換為長連接
  3. golang http keep-alive復用tcp連接的源碼級分析
  4. tcpdump抓包的姿勢
  5. 提出這個疑問的原因 還是自己對於請求-響應單向模型 認識不深刻,在這個單向模型下,連接不重用的時機只能是 服務端。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM