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)
		}
	}
}
相关推荐
童先生4 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
幼儿园老大*6 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
架构师那点事儿11 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
于顾而言1 天前
【笔记】Go Coding In Go Way
后端·go
qq_172805591 天前
GIN 反向代理功能
后端·golang·go
follycat1 天前
2024强网杯Proxy
网络·学习·网络安全·go
OT.Ter2 天前
【力扣打卡系列】单调栈
算法·leetcode·职场和发展·go·单调栈
探索云原生2 天前
GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU
ai·云原生·kubernetes·go·gpu
OT.Ter2 天前
【力扣打卡系列】移动零(双指针)
算法·leetcode·职场和发展·go
码财小子2 天前
k8s 集群中 Golang pprof 工具的使用
后端·kubernetes·go