1. 程式人生 > 程式設計 >Go http client 連線池不復用的問題

Go http client 連線池不復用的問題

當 http client 返回值為不為空,只讀取 response header,但不讀 body 內容就執行 response.Body.Close(),那麼連線會被主動關閉,得不到複用。

測試程式碼如下:

// xiaorui.cc
 
func HttpGet() {
 for {
 fmt.Println("new")
 resp,err := http.Get("http://www.baidu.com")
 if err != nil {
  fmt.Println(err)
  continue
 }
 
 if resp.StatusCode == http.StatusOK {
  continue
 }
 
 resp.Body.Close()
  
 fmt.Println("go num",runtime.NumGoroutine())
 }
}

正如大家所想,除了 HEAD Method 外,很少會有隻讀取 header 的需求吧。

話說,golang httpclient 需要注意的地方著實不少。

  • 如沒有 response.Body.Close(),有些小場景造成 persistConn 的 writeLoop 洩露。
  • 如 header 和 body 都不管,那麼會造成洩露的連線幹滿連線池,後面的請求只能是短連線。

上下文

由於某幾個業務系統會瘋狂呼叫各區域不同的 k8s 叢集,為減少跨機房帶來的時延、相容新老 k8s 叢集 api、減少k8s api-server 的負載,故而開發了 k8scache 服務。在部署執行後開始對該服務進行監控,發現 metrics 呈現的 QPS 跟連線數不成正比,qps 為 1500,連線數為 10 個。開始以為觸發 idle timeout 被回收,但通過歷史監控圖分析到連線依然很少。????

按照對 k8scache 呼叫方的理解,他們經常粗暴的開啟不少協程來對 k8scache 進行訪問。已知預設的 golang httpclient transport 對連線數是有預設限制的,連線池總大小為 100,每個 host 連線數為 2。當併發對某 url 進行請求時,無法歸還連線池,也就是超過連線池大小的連線會被主動clsoe()。所以,我司的 golang 腳手架中會對預設的 httpclient 建立高配的 transport,不太可能出現連線池爆滿被 close 的問題。

如果真的是連線池爆了? 誰主動挑起關閉,誰就有 tcp time-wait 狀態,但通過 netstat 命令只發現少量跟 k8scache 相關的 time-wait。

排查問題

已知問題,為隱藏敏感資訊,索性使用簡單的場景設立問題的 case

tcpdump抓包分析問題?

包資訊如下,通過最後一行可以確認是由客戶端主動觸發 RST連線重置 。觸發RST的場景有很多,但常見的有 tw_bucket 滿了、tcp 連線佇列爆滿且開啟 tcp_abort_on_overflow、配置 so_linger、讀緩衝區還有資料就給 close。

通過 linux 監控和核心日誌可以確認不是核心配置的問題,配置 so_linger 更不可能。???? 大概率就一個可能,關閉未清空讀緩衝區的連線。

22:11:01.790573 IP (tos 0x0,ttl 64,id 29826,offset 0,flags [DF],proto TCP (6),length 60)
  host-46.54550 > 110.242.68.3.http: Flags [S],cksum 0x5f62 (incorrect -> 0xb894),seq 1633933317,win 29200,options [mss 1460,sackOK,TS val 47230087 ecr 0,nop,wscale 7],length 0
22:11:01.801715 IP (tos 0x0,ttl 43,id 0,length 52)
  110.242.68.3.http > host-46.54550: Flags [S.],cksum 0x00a0 (correct),seq 1871454056,ack 1633933318,win 29040,options [mss 1452,length 0
22:11:01.801757 IP (tos 0x0,id 29827,length 40)
  host-46.54550 > 110.242.68.3.http: Flags [.],cksum 0x5f4e (incorrect -> 0xb1f5),seq 1,ack 1,win 229,length 0
22:11:01.801937 IP (tos 0x0,id 29828,length 134)
  host-46.54550 > 110.242.68.3.http: Flags [P.],cksum 0x5fac (incorrect -> 0xb4d6),seq 1:95,length 94: HTTP,length: 94
 GET / HTTP/1.1
 Host: www.baidu.com
 User-Agent: Go-http-client/1.1
 
22:11:01.814122 IP (tos 0x0,id 657,length 40)
  110.242.68.3.http > host-46.54550: Flags [.],cksum 0xb199 (correct),ack 95,win 227,length 0
22:11:01.815179 IP (tos 0x0,id 658,length 4136)
  110.242.68.3.http > host-46.54550: Flags [P.],cksum 0x6f4e (incorrect -> 0x0e70),seq 1:4097,length 4096: HTTP,length: 4096
 HTTP/1.1 200 OK
 Bdpagetype: 1
 Bdqid: 0x8b3b62c400142f77
 Cache-Control: private
 Connection: keep-alive
 Content-Encoding: gzip
 Content-Type: text/html;charset=utf-8
 Date: Wed,09 Dec 2020 14:11:01 GMT
 ...
22:11:01.815214 IP (tos 0x0,id 29829,cksum 0x5f4e (incorrect -> 0xa157),seq 95,ack 4097,win 293,length 0
22:11:01.815222 IP (tos 0x0,id 661,cksum 0x6f4e (incorrect -> 0x07fa),seq 4097:8193,length 4096: HTTP
22:11:01.815236 IP (tos 0x0,id 29830,cksum 0x5f4e (incorrect -> 0x9117),ack 8193,win 357,length 0
22:11:01.815243 IP (tos 0x0,id 664,length 5848)
  ...
  host-46.54550 > 110.242.68.3.http: Flags [.],cksum 0x5f4e (incorrect -> 0x51ba),ack 24165,win 606,length 0
22:11:01.815369 IP (tos 0x0,id 29834,length 40)
  host-46.54550 > 110.242.68.3.http: Flags [R.],cksum 0x5f4e (incorrect -> 0x51b6),length 0

通過 lsof 找到程序關聯的 TCP 連線,然後使用 ss 或 netstat 檢視讀寫緩衝區。資訊如下,recv-q 讀緩衝區確實是存在資料。這個緩衝區位元組一直未讀,直到連線關閉引發了 rst。

$ lsof -p 54330
COMMAND  PID USER  FD   TYPE  DEVICE SIZE/OFF    NODE NAME
...
aaa   54330 root  1u   CHR   136,0   0t0     3 /dev/pts/0
aaa   54330 root  2u   CHR   136,0   0t0     3 /dev/pts/0
aaa   54330 root  3u a_inode   0,10    0    8838 [eventpoll]
aaa   54330 root  4r   FIFO    0,9   0t0 223586913 pipe
aaa   54330 root  5w   FIFO    0,9   0t0 223586913 pipe
aaa   54330 root  6u   IPv4 223596521   0t0    TCP host-46:60626->110.242.68.3:http (ESTABLISHED)
 
$ ss -an|egrep "68.3:80"
State   Recv-Q   Send-Q    Local Address:Port    Peer Address:Port 
ESTAB   72480    0      172.16.0.46:60626     110.242.68.3:80 

strace 跟蹤系統呼叫

通過系統呼叫可分析出,貌似只是讀取了 header 部分了,還未讀到 body 的資料。

[pid 8311] connect(6,{sa_family=AF_INET,sin_port=htons(80),sin_addr=inet_addr("110.242.68.3")},16 <unfinished ...>
[pid 195519] epoll_pwait(3,<unfinished ...>
[pid 8311] <... connect resumed>)   = -1 EINPROGRESS (操作現在正在進行)
[pid 8311] epoll_ctl(3,EPOLL_CTL_ADD,6,{EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET,{u32=2350546712,u64=140370471714584}} <unfinished ...>
[pid 195519] getsockopt(6,SOL_SOCKET,SO_ERROR,<unfinished ...>
[pid 192592] nanosleep({tv_sec=0,tv_nsec=20000},<unfinished ...>
[pid 195519] getpeername(6,[112->16]) = 0
[pid 195519] getsockname(6,<unfinished ...>
[pid 195519] <... getsockname resumed>{sa_family=AF_INET,sin_port=htons(47746),sin_addr=inet_addr("172.16.0.46")},[112->16]) = 0
[pid 195519] setsockopt(6,SOL_TCP,TCP_KEEPINTVL,[15],4) = 0
[pid 195519] setsockopt(6,TCP_KEEPIDLE,4 <unfinished ...>
[pid 8311] write(6,"GET / HTTP/1.1\r\nHost: www.baidu.com\r\nUser-Agent: Go-http-client/1.1\r\nAccept-Encoding: gzip\r\n\r\n",94 <unfinished ...>
[pid 192595] read(6,<unfinished ...>
[pid 192595] <... read resumed>"HTTP/1.1 200 OK\r\nBdpagetype: 1\r\nBdqid: 0xc43c9f460008101b\r\nCache-Control: private\r\nConnection: keep-alive\r\nContent-Encoding: gzip\r\nContent-Type: text/html;charset=utf-8\r\nDate: Wed,09 Dec 2020 13:46:30 GMT\r\nExpires: Wed,09 Dec 2020 13:45:33 GMT\r\nP3p: CP=\" OTI DSP COR IVA OUR IND COM \"\r\nP3p: CP=\" OTI DSP COR IVA OUR IND COM \"\r\nServer: BWS/1.1\r\nSet-Cookie: BAIDUID=996EE645C83622DF7343923BF96EA1A1:FG=1; expires=Thu,31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com\r\nSet-Cookie: BIDUPSID=99"...,4096) = 4096
[pid 192595] close(6 <unfinished ...>

邏輯程式碼

那麼到這裡,可以大概猜測問題所在,找到業務方涉及到 httpclient 的邏輯程式碼。虛擬碼如下,跟上面的結論一樣,只是讀取了header,但並未讀取完response body資料。

還以為是特殊的場景,結果是使用不當,把請求投遞過去後只判斷 http code?真正的業務 code 是在 body 裡的。????

urls := []string{...}
for _,url := range urls {
 resp,err := http.Post(url,...)
 if err != nil {
  // ...
 }
 if resp.StatusCode == http.StatusOK {
  continue
 }
 
 // handle redis cache
 // handle mongodb
 // handle rocketmq
 // ...
 
 resp.Body.Close()
}

如何解決

不細說了,把 header length 長度的資料讀完就可以了。

分析問題

先不管別人使用不當,再分析下為何出現短連線,連線不能複用的問題。

為什麼不讀取 body 就出問題?其實 http.Response 欄位描述中已經有說明了。當 Body 未讀完時,連線可能不能複用。

 // The http Client and Transport guarantee that Body is always
 // non-nil,even on responses without a body or responses with
 // a zero-length body. It is the caller's responsibility to
 // close Body. The default HTTP client's Transport may not
 // reuse HTTP/1.x "keep-alive" TCP connections if the Body is
 // not read to completion and closed.
 //
 // The Body is automatically dechunked if the server replied
 // with a "chunked" Transfer-Encoding.
 //
 // As of Go 1.12,the Body will also implement io.Writer
 // on a successful "101 Switching Protocols" response,// as used by WebSockets and HTTP/2's "h2c" mode.
 Body io.ReadCloser

眾所周知,golang httpclient 要注意 response Body 關閉問題,但上面的 case 確實有關了 body,只是非常規地沒去讀取 reponse body 資料。這樣會造成連線異常關閉,繼而引起連線池不能複用。

一般 http 協議直譯器是要先解析 header,再解析 body,結合當前的問題開始是這麼推測的,連線的 readLoop 收到一個新請求,然後嘗試解析 header 後,返回給呼叫方等待讀取 body,但呼叫方沒去讀取,而選擇了直接關閉 body。那麼後面當一個新請求被 transport roundTrip 再排程請求時,readLoop 的 header 讀取和解析會失敗,因為他的讀緩衝區裡有前面未讀的資料,必然無法解析 header。按照常見的網路程式設計原則,協議解析失敗,直接關閉連線。

想是這麼想的,但還是看了 golang net/http 的程式碼,結果不是這樣的。????

分析原始碼

httpclient 每個連線會建立讀寫協程兩個協程,分別使用 reqch 和 writech 來跟 roundTrip 通訊。上層使用的response.Body 其實是經過多次封裝的,一次封裝的 body 是直接跟 net.conn 進行互動讀取,二次封裝的 body 則是加強了 close 和 eof 處理的 bodyEOFSignal。

當未讀取 body 就進行 close 時,會觸發 earlyCloseFn() 回撥,看 earlyCloseFn 的函式定義,在 close 未見 io.EOF 時才呼叫。自定義的 earlyCloseFn 方法會給 readLoop 監聽的 waitForBodyRead 傳入 false,這樣引發 alive 為 false 不能繼續迴圈的接收新請求,只能是退出呼叫註冊過的 defer 方法,關閉連線和清理連線池。

// xiaorui.cc
 
func (pc *persistConn) readLoop() {
 closeErr := errReadLoopExiting // default value,if not changed below
 defer func() {
 pc.close(closeErr)   // 關閉連線
 pc.t.removeIdleConn(pc) // 從連線池中刪除
 }()
 
 ...
 
 alive := true
 for alive {
   ...
 
 rc := <-pc.reqch // 從管道中拿到請求,roundTrip 對該管道進行輸入
 trace := httptrace.ContextClientTrace(rc.req.Context())
 
 var resp *Response
 if err == nil {
  resp,err = pc.readResponse(rc,trace) // 更多的是解析 header
 } else {
  err = transportReadFromServerError{err}
  closeErr = err
 }
  ...
 
 waitForBodyRead := make(chan bool,2)
 body := &bodyEOFSignal{
  body: resp.Body,// 提前關閉 !!! 輸出false
  earlyCloseFn: func() error {
  waitForBodyRead <- false
  ...
  },// 正常收尾 !!!
  fn: func(err error) error {
  isEOF := err == io.EOF
  waitForBodyRead <- isEOF
  ...
  },}
 
 resp.Body = body
 
 select {
 case rc.ch <- responseAndError{res: resp}:
 case <-rc.callerGone:
  return
 }
 
 select {
 case bodyEOF := <-waitForBodyRead:
  replaced := pc.t.replaceReqCanceler(rc.cancelKey,nil) // before pc might return to idle pool
  // alive 為 false,不能繼續 continue
  alive = alive &&
  bodyEOF &&
  !pc.sawEOF &&
  pc.wroteRequest() &&
  replaced && tryPutIdleConn(trace)
  ...
 case <-rc.req.Cancel:
  alive = false
  pc.t.CancelRequest(rc.req)
 case <-rc.req.Context().Done():
  alive = false
  pc.t.cancelRequest(rc.cancelKey,rc.req.Context().Err())
 case <-pc.closech:
  alive = false
 }
 }
}

bodyEOFSignal 的 Close():

// xiaorui.cc
 
func (es *bodyEOFSignal) Close() error {
 es.mu.Lock()
 defer es.mu.Unlock()
 if es.closed {
 return nil
 }
 es.closed = true
 if es.earlyCloseFn != nil && es.rerr != io.EOF {
 return es.earlyCloseFn()
 }
 err := es.body.Close()
 return es.condfn(err)
}

最終會呼叫 persistConn 的 close(),連線關閉並關閉closech:

// xiaorui.cc
 
func (pc *persistConn) close(err error) {
 pc.mu.Lock()
 defer pc.mu.Unlock()
 pc.closeLocked(err)
}
 
func (pc *persistConn) closeLocked(err error) {
 if err == nil {
 panic("nil error")
 }
 pc.broken = true
 if pc.closed == nil {
 pc.closed = err
 pc.t.decConnsPerHost(pc.cacheKey)
 if pc.alt == nil {
  if err != errCallerOwnsConn {
  pc.conn.Close() // 關閉連線
  }
  close(pc.closech) // 通知讀寫協程
 }
 }
}

總之

同事的 httpclient 使用方法有些奇怪,除了 head method 之外,還真想不到有不讀取 body 的請求。所以,大家知道 httpclient 有這麼一回事就行了。

另外,一直覺得 net/http 的程式碼太繞,沒看過一些介紹直接看程式碼很容易陷進去,有時間專門講講 http client 的實現。

到此這篇關於Go http client 連線池不復用的問題的文章就介紹到這了,更多相關Go http client 連線池內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!