go标准http库的长连接内存泄露问题排查

当前使用go版本:1.21.4,看源代码应该21版本之前的都有这个问题。

最近使用golang的标准http库的客户端发起请求的时候,出现了内存泄漏,随着时间的增加,内存占用匀速增长。在排除了各种使用错误的情况后,发现是长连接的tls数据因为某些原因滞留在内存中,某些特殊情况可能会出现这个问题,所以记录一下排查的过程与对应的解决办法。

情况

监控面板的趋势:

pprof查看泄漏问题:

可以清楚问题出现在在http内部网络连接的缓存上,可能是连接没有成功释放导致。

  • 首先介绍一下http客户端的连接池管理逻辑,知道了这个脉络才清楚问题的原因。(源代码过程可以见下文附录)

    • 开启连接池后,每一次创建完连接会放入池(idleConn属性)中复用

    • 如果有请求到达且查无对应该请求的空闲连接,会加入到idleConnWait形成等待队列,申请到TCP连接后将传输给对应请求使用。

    • 请求与响应完成后则会将连接放回到连接池中完成复用------放回池中的动作会检查idleConnWait中的等待队列并传递空闲的连接,并且会维护idleConnWait的长度。(连接池通过idleLRU管理空闲链接的生命周期)

      idleLRU通过MaxIdleConns维护连接的数量,并负责调用连接的close关闭多余的连接。

      idleConn、idleConnWait中的key,是一个三元组,用来确定一条连接------ 代理、协议、目标IP+端口


  • 业务上对http库的使用有些特殊。因为业务侧的qps很高,发起请求的时候为了减少网络带宽的占用,期望只读取响应的header,不再读取剩下的data数据。所以在代码层面就是:如果请求成功响应,直接close掉body。

    golang 复制代码
    resp, err := cli.Get(targetUrl)  
    if resp != nil {  
        //_, _ = io.ReadAll(resp.Body)  
        _ = resp.Body.Close()  
        println(resp.StatusCode)  
    }
  • 这个不读取bdoy内容反而提前关闭的行为,就导致了连接滞留在内存中,留在了idleConnWait里。

    从http库源代码中可以看到,body的返回其实封装了一个结构体,如果没有读取内容就关闭body,会执行body的earlyCloseFn逻辑。这会导致连接没有回到连接池复用(没有执行到tryPutIdleConn方法,这个方法会清理idleConnWait的对象),没有清理idleConnWait中滞留的连接对象,让这条连接的tls数据滞留在内存中。

    具体body的close过程与滞留情况代码过程可以看文后的附录章节。

    • 正常情况下,使用连接的过程中如果出现了错误,会进入transport.IdleConnWait中,等待后续有对应的连接的话,将会发送消息。此时连接persistConn是nil。

    • 如果成功请求,因为没有读取body后直接关闭,也会进入到transport.IdleConnWait中。

      但是,此时连接persistConn不是nil,是一条已经成功握手的https连接。

  • 业务侧qps很高的情况下,idleConnWait就滞留了大量连接对象,其中tls握手的数据占大部分,导致内存不断攀升。

解决与总结

解决方式

  • 开启短连接 DisableKeepAlive = true。可以解决。

  • 尝试设置连接池 MaxIdleHostPer = -1。

    仍然出现了内存泄漏,因为MaxIdleHostPer是后置控制,在连接加入idleConnWait时候并没有发挥作用,只在准备将连接加入连接池的时候才发挥作用,阻止不了内存泄漏。

  • 二次封装transport,采用多连接池的形式transport。可以解决。

  • 完整读取body。可行。

  • 升级最新的golang(大于1.22)。可以解决。

    最新的里面修复别的问题而定义了新的结构(修改了wantConn),刚好可以解决这个问题。 go-review.googlesource.com/c/go/+/5220...

总结

这是因为使用姿势与http库自身导致的问题,如果不读取body内容而直接关闭,一方面是导致连接不可复用,在大流量请求的时候,会造成连接对象的内存泄漏。

在解决问题的过程中,看到了一些文章说body忘记关闭、等使用姿势会导致一些问题。经过验证,有的可信有的不可信,因为http库中的body是多次封装的,到了用户侧其实已经是比较高级封装的body了,使用姿势导致的问题都是在很极端的情况,正常使用的话,body关闭问题应该不需要很大的关注。

具体可以看小白哥这篇文章的验证过程: mp.weixin.qq.com/s/iB8RPOwdw...

参考

github.com/golang/go/i...
github.com/golang/go/i...
github.com/golang/go/i...
github.com/golang/go/i...

附录

标准http库客户端核心过程源码

golang 复制代码
// go version go1.21.2 
// http client.go
func (c *Client) Do(req *Request) (*Response, error) {
	return c.do(req)
}

if resp, didTimeout, err = c.send(req, deadline); err != nil { // client的核心就是调用send方法
    --> resp, didTimeout, err = send(req, c.transport(), deadline)  // send函数传入连接池
      -->  resp, err = rt.RoundTrip(req)  // 此时调用http transport.go 中的方法,如果没有设定,则使用代码中默认的DefaultTransport


// 到http transport.go中查看接下来的逻辑
func (t *Transport) roundTrip(req *Request) (*Response, error) {...
    --> cm, err := t.connectMethodForRequest(treq) // 如果有网络代理,则会在这里构建代理请求
    --> pconn, err := t.getConn(treq, cm) // 获取一条网络连接
        --> if delivered := t.queueForIdleConn(w); delivered {... //检查是否有空闲可复用的连接
        --> t.queueForDial(w) // 如果没有空闲链接,则创建新连接
            --> go t.dialConnFor(w) 
               --> pc, err := t.dialConn(w.ctx, w.cm) // 创建链接(TLS握手是这个阶段)
                   --> conn, err := t.dial(ctx, "tcp", cm.addr())
                   --> pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize());pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())
                   --> 	go pconn.readLoop();	go pconn.writeLoop() // 启动两个协程负责连接数据的读与写
                       --> tryPutIdleConn := func(trace *httptrace.ClientTrace) bool { ...//链接用完尝试丢进池中复用
        --> case <-w.ready: ...// 空闲连接放回连接池时,或者异步建立连接成功后,关闭管道w.ready,这里select就会触发,表示拿到一条就绪的连接


type persistConn struct {
    //协程间通信用的管道(请求与响应)
    reqch     chan requestAndChan // written by roundTrip; read by readLoop
    writech   chan writeRequest   // written by roundTrip; read by writeLoop
}

func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
    //通知准备发起HTTP请求(写数据)
    pc.writech <- writeRequest{req, writeErrCh, continueCh}
    //通知准备读取响应
    pc.reqch <- requestAndChan{
    }

    for {
        select {
            //获取到响应了
            case re := <-resc:
                return re.res, nil

            //超时,出错等等case处理(可能直接关闭该连接)
        }
    }
}

提前关闭body是否能阻断带宽占用

一般 http 协议解释器是要先解析 header,再解析 body。

go 复制代码
// http.transport.go
 
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
  }

    ...
}

如果不读取body直接关闭,可以关闭底下的body网络数据传输,socket文件也就不再读取内容。

body的提前关闭代码逻辑

返回响应的时候构造了bodyEOFSignal对象,如果提前关闭,会触发earlyCloseFn逻辑,通过channel传递一个false值,将readloop协程退出。

go 复制代码
// http.transport.go

func (pc *persistConn) readLoop() {

...

waitForBodyRead := make(chan bool, 2)
		body := &bodyEOFSignal{
			body: resp.Body,
			earlyCloseFn: func() error {
				waitForBodyRead <- false
				<-eofc // will be closed by deferred call at the end of the function
				return nil

			},
			fn: func(err error) error {
				isEOF := err == io.EOF
				waitForBodyRead <- isEOF
				if isEOF {
					<-eofc // see comment above eofc declaration
				} else if err != nil {
					if cerr := pc.canceled(); cerr != nil {
						return cerr
					}
				}
				return err
			},
		}

...


select {
		case bodyEOF := <-waitForBodyRead:
			replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) // before pc might return to idle pool
			alive = alive &&
				bodyEOF && // 这里为false,不会再判断后续的了,整个循环结束掉
				!pc.sawEOF &&
				pc.wroteRequest() &&
				replaced && tryPutIdleConn(trace)
			if bodyEOF {
				eofc <- struct{}{}
			}
		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
		}

...
}

不读取body然后直接关闭的时候:

go 复制代码
// 关闭body时候执行的函数
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() // 没有读取body后关闭会直接进入这里
	}
	err := es.body.Close()
	return es.condfn(err)
}


func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
	es.mu.Lock()
	closed, rerr := es.closed, es.rerr
	es.mu.Unlock()
	if closed {
		return 0, errReadOnClosedResBody
	}
	if rerr != nil {
		return 0, rerr
	}

	n, err = es.body.Read(p)
	if err != nil {
		es.mu.Lock()
		defer es.mu.Unlock()
		if es.rerr == nil {
			es.rerr = err
		}
		err = es.condfn(err)
	}
	return
}

https连接滞留内存中,实际的tcp连接是否也滞留

实际的tcp连接会被系统回收,滞留的只是persistConn对象里的buffer数据。(也因为readloop里退出时会调用defer,关闭连接)

yaml 复制代码
sudo tcpdump host 10.10.16.59

11:59:04.177962 IP 192.168.1.78.52366 > 10.10.16.59.dc: Flags [F.], seq 708, ack 6580, win 2048, options [nop,nop,TS val 1218717194 ecr 2719043116], length 0
11:59:04.182230 IP 10.10.16.59.dc > 192.168.1.78.52366: Flags [P.], seq 6580:6730, ack 677, win 506, options [nop,nop,TS val 2719043120 ecr 1218717194], length 150
11:59:04.182232 IP 10.10.16.59.dc > 192.168.1.78.52366: Flags [F.], seq 6730, ack 709, win 506, options [nop,nop,TS val 2719043120 ecr 1218717194], length 0

连接关闭

####当目标网站是http2协议的时候

演示代码

go 复制代码
func TestHttpLeak(t *testing.T) {
	targetUrl := "https://www.baidu.com"
	cli := http.DefaultClient
	tran := &http.Transport{
		IdleConnTimeout: 2 * time.Second,
		//DisableKeepAlives: true,
		//MaxIdleConnsPerHost: -1,
	}

	var i int
	tran.Proxy = func(r *http.Request) (*url.URL, error) {
		if len(ip) > 0 {
			uri := ""
			u, err := url.Parse(uri)
			if err == nil {
				return u, nil
			}
		}
		return nil, nil
	}

	cli.Transport = tran

	for i = 0; i < 5; i++ {
		resp, err := cli.Get(targetUrl)
		if resp != nil {
			//_, _ = io.ReadAll(resp.Body)
			_ = resp.Body.Close()
			println(resp.StatusCode)
		}
		if err != nil {
			t.Errorf("get fail , err=%s", err)
		}
	}
}
相关推荐
煎鱼eddycjy4 小时前
新提案:由迭代器启发的 Go 错误函数处理
go
煎鱼eddycjy4 小时前
Go 语言十五周年!权力交接、回顾与展望
go
不爱说话郭德纲21 小时前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
0x派大星2 天前
【Golang】——Gin 框架中的 API 请求处理与 JSON 数据绑定
开发语言·后端·golang·go·json·gin
IT书架2 天前
golang高频面试真题
面试·go
郝同学的测开笔记2 天前
云原生探索系列(十四):Go 语言panic、defer以及recover函数
后端·云原生·go
秋落风声3 天前
【滑动窗口入门篇】
java·算法·leetcode·go·哈希表
0x派大星5 天前
【Golang】——Gin 框架中的模板渲染详解
开发语言·后端·golang·go·gin
0x派大星5 天前
【Golang】——Gin 框架中的表单处理与数据绑定
开发语言·后端·golang·go·gin
三里清风_6 天前
如何使用Casbin设计后台权限管理系统
golang·go·casbin