Golang后台开发中,大家潜规则的会认为标准库里的http库肯定不好用,我先前也是这么考虑的,后来发现golang社区里的http client基本是围绕net/http造轮子。 那些go http client会像python requests那样好用,而不是在功能和底层上有提升。

既然大家都在用net/http,那么有必要深入测试下,别掉坑里。 这篇主要说下测试net/http连接池的测试结果,以及go net/http连接池是怎么实现的,具体到源码方面的体现.

关于连接超时的问题

Go创建一个服务端HTTP服务

func main() {
	http.HandleFunc("/app", func(writer http.ResponseWriter, request *http.Request) {
		writer.Write([]byte("hello world"))
	})
	http.ListenAndServe(":8888", nil)
}

go一般用net/http包下的http.ListenAndServehttp.ListenAndServeTLShttp.Serve等函数开启一个接口服务,但是这些函数默认不设置超时时长,如果客户端请求这些接口耗时比较长(例如文件处理接口),服务器不设置超时,就会发现有很多连接泄露的问题。

正确的方法应该如下:

func main() {
	http.HandleFunc("/app", func(writer http.ResponseWriter, request *http.Request) {
		writer.Write([]byte("hello world"))
	})

	server := &http.Server{
		Addr:         ":8888",
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}
	server.ListenAndServe()
}

先创建一个http.Server对象,然后给这个对象设置ReadTimeoutWriteTimeout,这样当客户端处理请求比较慢,长时间占用连接超时会被自动释放,就不会造成过多连接泄露的问题。

Go创建一个客户端请求

func main() {
	req, err := http.NewRequest("GET", "http://127.0.0.1:8888/app", nil)
	if err != nil {
		fmt.Println("http new request error = ", err)
		return
	}
	resp, err := http.DefaultClient.Do(req)
  // resp, err := http.Get("http://127.0.0.1:8888/app")
	if err != nil {
		fmt.Println("http client request error = ", err)
		return
	}
	respBytes, _ := ioutil.ReadAll(resp.Body)
	fmt.Println("===", string(respBytes))
}

这里使用的http.DefaultClient是没有设置超时时长的,这样在互联网上使用是非常危险的。如果服务器接口耗时比较久,会造成客户端无限等待。

正确的client创建方法如下:

func main() {
	client := &http.Client{
		Transport: &http.Transport{
			MaxIdleConns:          1200,            // 连接池中最大连接数
			MaxIdleConnsPerHost:   300,             // 连接池中每个ip的最大连接数
			TLSHandshakeTimeout:   5 * time.Second, // 限制TLS握手的时间
			ResponseHeaderTimeout: 5 * time.Second, // 限制读取response header的超时时间
			IdleConnTimeout:       90 * time.Second,
		},
	}
	req, err := http.NewRequest("GET", "http://127.0.0.1:8888/app", nil)
	if err != nil {
		fmt.Println("http new request error = ", err)
		return
	}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("http client request error = ", err)
		return
	}
	respBytes, _ := ioutil.ReadAll(resp.Body)
	fmt.Println("===", string(respBytes))
}

TCP连接池问题

为什么要使用连接池?我觉得最大的一个好处是减少连接的创建和关闭,增加系统负载能力

使用连接池后,就不再是短连接了,而是长连接,就会引发下面的一些问题:

  • 长时间空间,连接就会断开?通过两个方法可以解决:客户端增加心跳,定时给服务端发送请求;给连接池中的连接增加最大的空闲时间,超时的连接不再使用
  • 当服务端重启服务之后,连接会失效?连接失效的特征主要有:对连接进行read读操作时,返回EOF错误;对连接进行write操作时,返回write:broken pipe错误

Server端出现大量的TIME_WAIT

TIME_WAIT只会出现在主动关闭连接的一方,也就是server端出现了大量的主动关闭行为。 默认我们是使用长连接的,只有在超时的情况下server端才会主动关闭连接。如果超出连接池的部分就会在client端主动关闭连接,连接池的连接会复用,看着似乎没有什么问题。问题出在我们每次请求都会new一个新的client,这样每个client的连接池里的连接并没有得到复用,而且这时client也不会主动关闭这个连接,所以server端出现了大量的keep-alive但是没有请求的连接,就会主动发起关闭。

因此需要解决下面几个问题:

  • client复用,来保证client连接池里面的连接得到复用,而减少出现超时关闭的情况,意思就是每次请求重新new了一个client
  • 设置http.Transport.MaxIdleConnsPerHost < 0:这样每次请求后都会由client发起主动关闭连接的请求,server端就不会出现大量的TIME_WAIT
  • 修改server内核参数: 当出现大量的TIME_WAIT时危害就是导致fd不够用,无法处理新的请求。我们可以通过设置/etc/sysctl.conf文件中的以下参数,达到快速回收和重用的效果,不影响其对新连接的处理。
#表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭  
net.ipv4.tcp_tw_reuse = 1  
#表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭  
net.ipv4.tcp_tw_recycle = 1
  • resp.body忘了读取,直接导致新请求会直接新建连接。其实可以理解,没read body 的socket, 如果直接复用,会产生什么样后果?所有使用这个套接字的连接都会错乱。

var client *http.Client

func init() {
	client = &http.Client{
		Transport: &http.Transport{
			MaxIdleConns:          1200,            // 连接池中最大连接数
			MaxIdleConnsPerHost:   300,             // 连接池中每个ip的最大连接数
			TLSHandshakeTimeout:   5 * time.Second, // 限制TLS握手的时间
			ResponseHeaderTimeout: 5 * time.Second, // 限制读取response header的超时时间
			IdleConnTimeout:       90 * time.Second,
		},
	}
}

func main() {
	req, err := http.NewRequest("GET", "http://127.0.0.1:8888/app", nil)
	if err != nil {
		fmt.Println("http new request error = ", err)
		return
	}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("http client request error = ", err)
		return
	}
	respBytes, _ := ioutil.ReadAll(resp.Body)
	fmt.Println("===", string(respBytes))
}