Go http client 连接池不复用的问题
当httpclient返回值为不为空,只读取responseheader,但不读body内容就执行response.Body.Close(),那么连接会被主动关闭,得不到复用。
测试代码如下:
//xiaorui.cc funcHttpGet(){ for{ fmt.Println("new") resp,err:=http.Get("http://www.baidu.com") iferr!=nil{ fmt.Println(err) continue } ifresp.StatusCode==http.StatusOK{ continue } resp.Body.Close() fmt.Println("gonum",runtime.NumGoroutine()) } }
正如大家所想,除了HEADMethod外,很少会有只读取header的需求吧。
话说,golanghttpclient需要注意的地方着实不少。
- 如没有response.Body.Close(),有些小场景造成persistConn的writeLoop泄露。
- 如header和body都不管,那么会造成泄露的连接干满连接池,后面的请求只能是短连接。
上下文
由于某几个业务系统会疯狂调用各区域不同的k8s集群,为减少跨机房带来的时延、兼容新老k8s集群api、减少k8sapi-server的负载,故而开发了k8scache服务。在部署运行后开始对该服务进行监控,发现metrics呈现的QPS跟连接数不成正比,qps为1500,连接数为10个。开始以为触发idletimeout被回收,但通过历史监控图分析到连接依然很少。????
按照对k8scache调用方的理解,他们经常粗暴的开启不少协程来对k8scache进行访问。已知默认的golanghttpclienttransport对连接数是有默认限制的,连接池总大小为100,每个host连接数为2。当并发对某url进行请求时,无法归还连接池,也就是超过连接池大小的连接会被主动clsoe()。所以,我司的golang脚手架中会对默认的httpclient创建高配的transport,不太可能出现连接池爆满被close的问题。
如果真的是连接池爆了? 谁主动挑起关闭,谁就有tcptime-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.790573IP(tos0x0,ttl64,id29826,offset0,flags[DF],protoTCP(6),length60) host-46.54550>110.242.68.3.http:Flags[S],cksum0x5f62(incorrect->0xb894),seq1633933317,win29200,options[mss1460,sackOK,TSval47230087ecr0,nop,wscale7],length0 22:11:01.801715IP(tos0x0,ttl43,id0,offset0,flags[DF],protoTCP(6),length52) 110.242.68.3.http>host-46.54550:Flags[S.],cksum0x00a0(correct),seq1871454056,ack1633933318,win29040,options[mss1452,nop,nop,sackOK,nop,wscale7],length0 22:11:01.801757IP(tos0x0,ttl64,id29827,offset0,flags[DF],protoTCP(6),length40) host-46.54550>110.242.68.3.http:Flags[.],cksum0x5f4e(incorrect->0xb1f5),seq1,ack1,win229,length0 22:11:01.801937IP(tos0x0,ttl64,id29828,offset0,flags[DF],protoTCP(6),length134) host-46.54550>110.242.68.3.http:Flags[P.],cksum0x5fac(incorrect->0xb4d6),seq1:95,ack1,win229,length94:HTTP,length:94 GET/HTTP/1.1 Host:www.baidu.com User-Agent:Go-http-client/1.1 22:11:01.814122IP(tos0x0,ttl43,id657,offset0,flags[DF],protoTCP(6),length40) 110.242.68.3.http>host-46.54550:Flags[.],cksum0xb199(correct),seq1,ack95,win227,length0 22:11:01.815179IP(tos0x0,ttl43,id658,offset0,flags[DF],protoTCP(6),length4136) 110.242.68.3.http>host-46.54550:Flags[P.],cksum0x6f4e(incorrect->0x0e70),seq1:4097,ack95,win227,length4096:HTTP,length:4096 HTTP/1.1200OK Bdpagetype:1 Bdqid:0x8b3b62c400142f77 Cache-Control:private Connection:keep-alive Content-Encoding:gzip Content-Type:text/html;charset=utf-8 Date:Wed,09Dec202014:11:01GMT ... 22:11:01.815214IP(tos0x0,ttl64,id29829,offset0,flags[DF],protoTCP(6),length40) host-46.54550>110.242.68.3.http:Flags[.],cksum0x5f4e(incorrect->0xa157),seq95,ack4097,win293,length0 22:11:01.815222IP(tos0x0,ttl43,id661,offset0,flags[DF],protoTCP(6),length4136) 110.242.68.3.http>host-46.54550:Flags[P.],cksum0x6f4e(incorrect->0x07fa),seq4097:8193,ack95,win227,length4096:HTTP 22:11:01.815236IP(tos0x0,ttl64,id29830,offset0,flags[DF],protoTCP(6),length40) host-46.54550>110.242.68.3.http:Flags[.],cksum0x5f4e(incorrect->0x9117),seq95,ack8193,win357,length0 22:11:01.815243IP(tos0x0,ttl43,id664,offset0,flags[DF],protoTCP(6),length5848) ... host-46.54550>110.242.68.3.http:Flags[.],cksum0x5f4e(incorrect->0x51ba),seq95,ack24165,win606,length0 22:11:01.815369IP(tos0x0,ttl64,id29834,offset0,flags[DF],protoTCP(6),length40) host-46.54550>110.242.68.3.http:Flags[R.],cksum0x5f4e(incorrect->0x51b6),seq95,ack24165,win606,length0
通过lsof找到进程关联的TCP连接,然后使用ss或netstat查看读写缓冲区。信息如下,recv-q读缓冲区确实是存在数据。这个缓冲区字节一直未读,直到连接关闭引发了rst。
$lsof-p54330 COMMANDPIDUSERFDTYPEDEVICESIZE/OFFNODENAME ... aaa54330root1uCHR136,00t03/dev/pts/0 aaa54330root2uCHR136,00t03/dev/pts/0 aaa54330root3ua_inode0,1008838[eventpoll] aaa54330root4rFIFO0,90t0223586913pipe aaa54330root5wFIFO0,90t0223586913pipe aaa54330root6uIPv42235965210t0TCPhost-46:60626->110.242.68.3:http(ESTABLISHED) $ss-an|egrep"68.3:80" StateRecv-QSend-QLocalAddress:PortPeerAddress:Port ESTAB724800172.16.0.46:60626110.242.68.3:80
strace跟踪系统调用
通过系统调用可分析出,貌似只是读取了header部分了,还未读到body的数据。
[pid8311]connect(6,{sa_family=AF_INET,sin_port=htons(80),sin_addr=inet_addr("110.242.68.3")},16[pid195519]epoll_pwait(3, [pid8311]<...connectresumed>)=-1EINPROGRESS(操作现在正在进行) [pid8311]epoll_ctl(3,EPOLL_CTL_ADD,6,{EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET,{u32=2350546712,u64=140370471714584}} [pid195519]getsockopt(6,SOL_SOCKET,SO_ERROR, [pid192592]nanosleep({tv_sec=0,tv_nsec=20000}, [pid195519]getpeername(6,{sa_family=AF_INET,sin_port=htons(80),sin_addr=inet_addr("110.242.68.3")},[112->16])=0 [pid195519]getsockname(6, [pid195519]<...getsocknameresumed>{sa_family=AF_INET,sin_port=htons(47746),sin_addr=inet_addr("172.16.0.46")},[112->16])=0 [pid195519]setsockopt(6,SOL_TCP,TCP_KEEPINTVL,[15],4)=0 [pid195519]setsockopt(6,SOL_TCP,TCP_KEEPIDLE,[15],4 [pid8311]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 [pid192595]read(6, [pid192595]<...readresumed>"HTTP/1.1200OK\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,09Dec202013:46:30GMT\r\nExpires:Wed,09Dec202013:45:33GMT\r\nP3p:CP=\"OTIDSPCORIVAOURINDCOM\"\r\nP3p:CP=\"OTIDSPCORIVAOURINDCOM\"\r\nServer:BWS/1.1\r\nSet-Cookie:BAIDUID=996EE645C83622DF7343923BF96EA1A1:FG=1;expires=Thu,31-Dec-3723:55:55GMT;max-age=2147483647;path=/;domain=.baidu.com\r\nSet-Cookie:BIDUPSID=99"...,4096)=4096 [pid192595]close(6
逻辑代码
那么到这里,可以大概猜测问题所在,找到业务方涉及到httpclient的逻辑代码。伪代码如下,跟上面的结论一样,只是读取了header,但并未读取完responsebody数据。
还以为是特殊的场景,结果是使用不当,把请求投递过去后只判断httpcode?真正的业务code是在body里的。????
urls:=[]string{...} for_,url:=rangeurls{ resp,err:=http.Post(url,...) iferr!=nil{ //... } ifresp.StatusCode==http.StatusOK{ continue } //handlerediscache //handlemongodb //handlerocketmq //... resp.Body.Close() }
如何解决
不细说了,把headerlength长度的数据读完就可以了。
分析问题
先不管别人使用不当,再分析下为何出现短连接,连接不能复用的问题。
为什么不读取body就出问题?其实http.Response字段描述中已经有说明了。当Body未读完时,连接可能不能复用。
//ThehttpClientandTransportguaranteethatBodyisalways //non-nil,evenonresponseswithoutabodyorresponseswith //azero-lengthbody.Itisthecaller'sresponsibilityto //closeBody.ThedefaultHTTPclient'sTransportmaynot //reuseHTTP/1.x"keep-alive"TCPconnectionsiftheBodyis //notreadtocompletionandclosed. // //TheBodyisautomaticallydechunkediftheserverreplied //witha"chunked"Transfer-Encoding. // //AsofGo1.12,theBodywillalsoimplementio.Writer //onasuccessful"101SwitchingProtocols"response, //asusedbyWebSocketsandHTTP/2's"h2c"mode. Bodyio.ReadCloser
众所周知,golanghttpclient要注意responseBody关闭问题,但上面的case确实有关了body,只是非常规地没去读取reponsebody数据。这样会造成连接异常关闭,继而引起连接池不能复用。
一般http协议解释器是要先解析header,再解析body,结合当前的问题开始是这么推测的,连接的readLoop收到一个新请求,然后尝试解析header后,返回给调用方等待读取body,但调用方没去读取,而选择了直接关闭body。那么后面当一个新请求被transportroundTrip再调度请求时,readLoop的header读取和解析会失败,因为他的读缓冲区里有前面未读的数据,必然无法解析header。按照常见的网络编程原则,协议解析失败,直接关闭连接。
想是这么想的,但还是看了golangnet/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//defaultvalue,ifnotchangedbelow deferfunc(){ pc.close(closeErr)//关闭连接 pc.t.removeIdleConn(pc)//从连接池中删除 }() ... alive:=true foralive{ ... rc:=<-pc.reqch//从管道中拿到请求,roundTrip对该管道进行输入 trace:=httptrace.ContextClientTrace(rc.req.Context()) varresp*Response iferr==nil{ resp,err=pc.readResponse(rc,trace)//更多的是解析header }else{ err=transportReadFromServerError{err} closeErr=err } ... waitForBodyRead:=make(chanbool,2) body:=&bodyEOFSignal{ body:resp.Body, //提前关闭!!!输出false earlyCloseFn:func()error{ waitForBodyRead<-false ... }, //正常收尾!!! fn:func(errerror)error{ isEOF:=err==io.EOF waitForBodyRead<-isEOF ... }, } resp.Body=body select{ caserc.ch<-responseAndError{res:resp}: case<-rc.callerGone: return } select{ casebodyEOF:=<-waitForBodyRead: replaced:=pc.t.replaceReqCanceler(rc.cancelKey,nil)//beforepcmightreturntoidlepool //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() deferes.mu.Unlock() ifes.closed{ returnnil } es.closed=true ifes.earlyCloseFn!=nil&&es.rerr!=io.EOF{ returnes.earlyCloseFn() } err:=es.body.Close() returnes.condfn(err) }
最终会调用persistConn的close(),连接关闭并关闭closech:
//xiaorui.cc func(pc*persistConn)close(errerror){ pc.mu.Lock() deferpc.mu.Unlock() pc.closeLocked(err) } func(pc*persistConn)closeLocked(errerror){ iferr==nil{ panic("nilerror") } pc.broken=true ifpc.closed==nil{ pc.closed=err pc.t.decConnsPerHost(pc.cacheKey) ifpc.alt==nil{ iferr!=errCallerOwnsConn{ pc.conn.Close()//关闭连接 } close(pc.closech)//通知读写协程 } } }
总之
同事的httpclient使用方法有些奇怪,除了headmethod之外,还真想不到有不读取body的请求。所以,大家知道httpclient有这么一回事就行了。
另外,一直觉得net/http的代码太绕,没看过一些介绍直接看代码很容易陷进去,有时间专门讲讲httpclient的实现。
到此这篇关于Gohttpclient连接池不复用的问题的文章就介绍到这了,更多相关Gohttpclient连接池内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!