當(dāng) http client 返回值為不為空,只讀取 response header,但不讀 body 內(nèi)容就執(zhí)行 response.Body.Close(),那么連接會(huì)被主動(dòng)關(guān)閉,得不到復(fù)用。
測(cè)試代碼如下:
// 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 外,很少會(huì)有只讀取 header 的需求吧。
話說(shuō),golang httpclient 需要注意的地方著實(shí)不少。
- 如沒(méi)有 response.Body.Close(),有些小場(chǎng)景造成 persistConn 的 writeLoop 泄露。
- 如 header 和 body 都不管,那么會(huì)造成泄露的連接干滿連接池,后面的請(qǐng)求只能是短連接。
上下文
由于某幾個(gè)業(yè)務(wù)系統(tǒng)會(huì)瘋狂調(diào)用各區(qū)域不同的 k8s 集群,為減少跨機(jī)房帶來(lái)的時(shí)延、兼容新老 k8s 集群 api、減少k8s api-server 的負(fù)載,故而開(kāi)發(fā)了 k8scache 服務(wù)。在部署運(yùn)行后開(kāi)始對(duì)該服務(wù)進(jìn)行監(jiān)控,發(fā)現(xiàn) metrics 呈現(xiàn)的 QPS 跟連接數(shù)不成正比,qps 為 1500,連接數(shù)為 10 個(gè)。開(kāi)始以為觸發(fā) idle timeout 被回收,但通過(guò)歷史監(jiān)控圖分析到連接依然很少。????
按照對(duì) k8scache 調(diào)用方的理解,他們經(jīng)常粗暴的開(kāi)啟不少協(xié)程來(lái)對(duì) k8scache 進(jìn)行訪問(wèn)。已知默認(rèn)的 golang httpclient transport 對(duì)連接數(shù)是有默認(rèn)限制的,連接池總大小為 100,每個(gè) host 連接數(shù)為 2。當(dāng)并發(fā)對(duì)某 url 進(jìn)行請(qǐng)求時(shí),無(wú)法歸還連接池,也就是超過(guò)連接池大小的連接會(huì)被主動(dòng)clsoe()。所以,我司的 golang 腳手架中會(huì)對(duì)默認(rèn)的 httpclient 創(chuàng)建高配的 transport,不太可能出現(xiàn)連接池爆滿被 close 的問(wèn)題。
如果真的是連接池爆了? 誰(shuí)主動(dòng)挑起關(guān)閉,誰(shuí)就有 tcp time-wait 狀態(tài),但通過(guò) netstat 命令只發(fā)現(xiàn)少量跟 k8scache 相關(guān)的 time-wait。
排查問(wèn)題
已知問(wèn)題, 為隱藏敏感信息,索性使用簡(jiǎn)單的場(chǎng)景設(shè)立問(wèn)題的 case
tcpdump抓包分析問(wèn)題?
包信息如下,通過(guò)最后一行可以確認(rèn)是由客戶端主動(dòng)觸發(fā) RST連接重置 。觸發(fā)RST的場(chǎng)景有很多,但常見(jiàn)的有 tw_bucket 滿了、tcp 連接隊(duì)列爆滿且開(kāi)啟 tcp_abort_on_overflow、配置 so_linger、讀緩沖區(qū)還有數(shù)據(jù)就給 close。
通過(guò) linux 監(jiān)控和內(nèi)核日志可以確認(rèn)不是內(nèi)核配置的問(wèn)題,配置 so_linger 更不可能。???? 大概率就一個(gè)可能,關(guān)閉未清空讀緩沖區(qū)的連接。
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, offset 0, flags [DF], proto TCP (6), length 52)
110.242.68.3.http > host-46.54550: Flags [S.], cksum 0x00a0 (correct), seq 1871454056, ack 1633933318, win 29040, options [mss 1452,nop,nop,sackOK,nop,wscale 7], length 0
22:11:01.801757 IP (tos 0x0, ttl 64, id 29827, offset 0, flags [DF], proto TCP (6), 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, ttl 64, id 29828, offset 0, flags [DF], proto TCP (6), length 134)
host-46.54550 > 110.242.68.3.http: Flags [P.], cksum 0x5fac (incorrect -> 0xb4d6), seq 1:95, ack 1, win 229, 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, ttl 43, id 657, offset 0, flags [DF], proto TCP (6), length 40)
110.242.68.3.http > host-46.54550: Flags [.], cksum 0xb199 (correct), seq 1, ack 95, win 227, length 0
22:11:01.815179 IP (tos 0x0, ttl 43, id 658, offset 0, flags [DF], proto TCP (6), length 4136)
110.242.68.3.http > host-46.54550: Flags [P.], cksum 0x6f4e (incorrect -> 0x0e70), seq 1:4097, ack 95, win 227, 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, ttl 64, id 29829, offset 0, flags [DF], proto TCP (6), length 40)
host-46.54550 > 110.242.68.3.http: Flags [.], cksum 0x5f4e (incorrect -> 0xa157), seq 95, ack 4097, win 293, length 0
22:11:01.815222 IP (tos 0x0, ttl 43, id 661, offset 0, flags [DF], proto TCP (6), length 4136)
110.242.68.3.http > host-46.54550: Flags [P.], cksum 0x6f4e (incorrect -> 0x07fa), seq 4097:8193, ack 95, win 227, length 4096: HTTP
22:11:01.815236 IP (tos 0x0, ttl 64, id 29830, offset 0, flags [DF], proto TCP (6), length 40)
host-46.54550 > 110.242.68.3.http: Flags [.], cksum 0x5f4e (incorrect -> 0x9117), seq 95, ack 8193, win 357, length 0
22:11:01.815243 IP (tos 0x0, ttl 43, id 664, offset 0, flags [DF], proto TCP (6), length 5848)
...
host-46.54550 > 110.242.68.3.http: Flags [.], cksum 0x5f4e (incorrect -> 0x51ba), seq 95, ack 24165, win 606, length 0
22:11:01.815369 IP (tos 0x0, ttl 64, id 29834, offset 0, flags [DF], proto TCP (6), length 40)
host-46.54550 > 110.242.68.3.http: Flags [R.], cksum 0x5f4e (incorrect -> 0x51b6), seq 95, ack 24165, win 606, length 0
通過(guò) lsof 找到進(jìn)程關(guān)聯(lián)的 TCP 連接,然后使用 ss 或 netstat 查看讀寫(xiě)緩沖區(qū)。信息如下,recv-q 讀緩沖區(qū)確實(shí)是存在數(shù)據(jù)。這個(gè)緩沖區(qū)字節(jié)一直未讀,直到連接關(guān)閉引發(fā)了 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 跟蹤系統(tǒng)調(diào)用
通過(guò)系統(tǒng)調(diào)用可分析出,貌似只是讀取了 header 部分了,還未讀到 body 的數(shù)據(jù)。
[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 (操作現(xiàn)在正在進(jìn)行)
[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, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("110.242.68.3")}, [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, SOL_TCP, TCP_KEEPIDLE, [15], 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 ...>
邏輯代碼
那么到這里,可以大概猜測(cè)問(wèn)題所在,找到業(yè)務(wù)方涉及到 httpclient 的邏輯代碼。偽代碼如下,跟上面的結(jié)論一樣,只是讀取了header,但并未讀取完response body數(shù)據(jù)。
還以為是特殊的場(chǎng)景,結(jié)果是使用不當(dāng),把請(qǐng)求投遞過(guò)去后只判斷 http code?真正的業(yè)務(wù) 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()
}
如何解決
不細(xì)說(shuō)了,把 header length 長(zhǎng)度的數(shù)據(jù)讀完就可以了。
分析問(wèn)題
先不管別人使用不當(dāng),再分析下為何出現(xiàn)短連接,連接不能復(fù)用的問(wèn)題。
為什么不讀取 body 就出問(wèn)題?其實(shí) http.Response 字段描述中已經(jīng)有說(shuō)明了。當(dāng) Body 未讀完時(shí),連接可能不能復(fù)用。
// 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 關(guān)閉問(wèn)題,但上面的 case 確實(shí)有關(guān)了 body,只是非常規(guī)地沒(méi)去讀取 reponse body 數(shù)據(jù)。這樣會(huì)造成連接異常關(guān)閉,繼而引起連接池不能復(fù)用。
一般 http 協(xié)議解釋器是要先解析 header,再解析 body,結(jié)合當(dāng)前的問(wèn)題開(kāi)始是這么推測(cè)的,連接的 readLoop 收到一個(gè)新請(qǐng)求,然后嘗試解析 header 后,返回給調(diào)用方等待讀取 body,但調(diào)用方?jīng)]去讀取,而選擇了直接關(guān)閉 body。那么后面當(dāng)一個(gè)新請(qǐng)求被 transport roundTrip 再調(diào)度請(qǐng)求時(shí),readLoop 的 header 讀取和解析會(huì)失敗,因?yàn)樗淖x緩沖區(qū)里有前面未讀的數(shù)據(jù),必然無(wú)法解析 header。按照常見(jiàn)的網(wǎng)絡(luò)編程原則,協(xié)議解析失敗,直接關(guān)閉連接。
想是這么想的,但還是看了 golang net/http 的代碼,結(jié)果不是這樣的。????
分析源碼
httpclient 每個(gè)連接會(huì)創(chuàng)建讀寫(xiě)協(xié)程兩個(gè)協(xié)程,分別使用 reqch 和 writech 來(lái)跟 roundTrip 通信。上層使用的response.Body 其實(shí)是經(jīng)過(guò)多次封裝的,一次封裝的 body 是直接跟 net.conn 進(jìn)行交互讀取,二次封裝的 body 則是加強(qiáng)了 close 和 eof 處理的 bodyEOFSignal。
當(dāng)未讀取 body 就進(jìn)行 close 時(shí),會(huì)觸發(fā) earlyCloseFn() 回調(diào),看 earlyCloseFn 的函數(shù)定義,在 close 未見(jiàn) io.EOF 時(shí)才調(diào)用。自定義的 earlyCloseFn 方法會(huì)給 readLoop 監(jiān)聽(tīng)的 waitForBodyRead 傳入 false, 這樣引發(fā) alive 為 false 不能繼續(xù)循環(huán)的接收新請(qǐng)求,只能是退出調(diào)用注冊(cè)過(guò)的 defer 方法,關(guān)閉連接和清理連接池。
// xiaorui.cc
func (pc *persistConn) readLoop() {
closeErr := errReadLoopExiting // default value, if not changed below
defer func() {
pc.close(closeErr) // 關(guān)閉連接
pc.t.removeIdleConn(pc) // 從連接池中刪除
}()
...
alive := true
for alive {
...
rc := -pc.reqch // 從管道中拿到請(qǐng)求,roundTrip 對(duì)該管道進(jìn)行輸入
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,
// 提前關(guān)閉 !!! 輸出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, 不能繼續(xù) 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)
}
最終會(huì)調(diào)用 persistConn 的 close(), 連接關(guān)閉并關(guān)閉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() // 關(guān)閉連接
}
close(pc.closech) // 通知讀寫(xiě)協(xié)程
}
}
}
總之
同事的 httpclient 使用方法有些奇怪,除了 head method 之外,還真想不到有不讀取 body 的請(qǐng)求。所以,大家知道 httpclient 有這么一回事就行了。
另外,一直覺(jué)得 net/http 的代碼太繞,沒(méi)看過(guò)一些介紹直接看代碼很容易陷進(jìn)去,有時(shí)間專門講講 http client 的實(shí)現(xiàn)。
到此這篇關(guān)于Go http client 連接池不復(fù)用的問(wèn)題的文章就介紹到這了,更多相關(guān)Go http client 連接池內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
您可能感興趣的文章:- go 原生http web 服務(wù)跨域restful api的寫(xiě)法介紹
- golang http使用踩過(guò)的坑與填坑指南
- Golang實(shí)現(xiàn)http server提供壓縮文件下載功能
- golang語(yǔ)言http協(xié)議get拼接參數(shù)操作
- 在go文件服務(wù)器加入http.StripPrefix的用途介紹
- Golang 實(shí)現(xiàn)分片讀取http超大文件流和并發(fā)控制
- Go 實(shí)現(xiàn)HTTP中間人代理的操作