0%

go程序中http请求出现"connect:cannot-assign-requested-address"以及TIME-WAIT的问题调查


概述

最近在golang程序中,连续遇到了两次在大并发下,出现connect: cannot assign requested address的问题,在系统中查询可以发现出现了大量TIME-WAIT。下面逐渐梳理一下出现这个问题的原因,以及大并发下,该如何正确使用httpClient。


第一次问题出现

线上的一个go程序在每秒4000个请求的情况下,很快就出现了大量connect: cannot assign requested address的请求错误。

这里出现问题的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type HttpClient struct {
host *url.URL
HTTPClient *http.Client
}

func NewHttpClient(host string) *HttpClient {
var hostURL *url.URL = nil
var err error
if host != "" {
hostURL, err = url.Parse(host)
if err != nil {
panic(err.Error())
}
}

return &HttpClient{host: hostURL}
}

func (c *HttpClient) httpClient() *http.Client {
if c.HTTPClient == nil {
return http.DefaultClient
}
return c.HTTPClient
}

func (c *HttpClient) Do(req *http.Request, v interface{}) (*http.Response, error) {
response, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()

if v != nil {
err = osjson.NewDecoder(response.Body).Decode(v)
if err == io.EOF {
err = nil // ignore EOF, empty response body
}
}

return response, err
}

可以看到这里的代码中定义了一个结构体HttpClient,其中虽然有*http.Client,但是实际上并没有初始化,所以在执行Do请求的时候,使用的仍然是http.DefaultClient,可以看一下它的参数定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}

type Client struct {
// Transport specifies the mechanism by which individual
// HTTP requests are made.
// If nil, DefaultTransport is used.
Transport RoundTripper
}

var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}

type Transport struct {
// MaxIdleConnsPerHost, if non-zero, controls the maximum idle
// (keep-alive) connections to keep per-host. If zero,
// DefaultMaxIdleConnsPerHost is used.
MaxIdleConnsPerHost int

// MaxConnsPerHost optionally limits the total number of
// connections per host, including connections in the dialing,
// active, and idle states. On limit violation, dials will block.
//
// Zero means no limit.
MaxConnsPerHost int
}

// DefaultMaxIdleConnsPerHost is the default value of Transport's
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2

所以其实http.DefaultClient没有定义Transport,于是在这里会使用到DefaultTransport,由于DefaultTransport中没有定义MaxIdleConnsPerHost,所以便会用到DefaultMaxIdleConnsPerHost,也就是每个host的最大空闲连接数为2,同时MaxConnsPerHost没有限制。这样在大量并发的情况下,就会出现问题:

  1. 假如同时有2000个请求发送,则当2000个请求完成时,会有2000个Idle请求。
  2. 由于使用了DefaultMaxIdleConnsPerHost = 2,所以这里会有1998个连接被关闭。
  3. 又有2000个请求,循环以上。

这样就会产生大量的TIME-WAIT,在大量并发的情况下,一样会造成端口耗尽。


解决问题

解决这个问题很简单,就是避免使用http.DefaultClient,它只适合在测试的时候使用,在生产上应该创建新的http.Client,修改后使用如下代码:

1
2
3
4
5
6
7
8
9
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 500, // 最大空闲连接数
MaxConnsPerHost: 100, // 每个host最大连接数
MaxIdleConnsPerHost: 100, // 每个host最大空闲连接数
IdleConnTimeout: 60 * time.Second, // 空闲连接超时时间
},
Timeout: time.Duration(conf.Timeout) * time.Second,
}

问题得到解决。

其实主要的关键在于限制MaxConnsPerHost,否则当有大量请求要发送的时候,它们不会阻塞等待空闲连接,而是会直接新建连接进行发送,导致远远超过空闲连接池的大小,以至于被迫关闭大量连接,出现大量TIME-WAIT


第二次问题出现

这一次还是线上go程序,不过是另一个,同样的出现了connect: cannot assign requested address的问题,在系统中查询可以发现出现了大量TIME-WAIT

第一反应就是没有设置MaxConnsPerHost等参数导致的,但查看代码之后,发现其实已经设置了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func NewHttpClientManager() *HttpClientManager {
mgr := &HttpClientManager{
}

mgr.transport = &http.Transport{
MaxIdleConns: 6000,
MaxConnsPerHost: 1200,
MaxIdleConnsPerHost: 1200,
IdleConnTimeout: 60 * time.Second,
}

...
}

func (mgr *HttpClientManager) createHttpClient() *HttpClientInfo {
client := &http.Client{
Transport: mgr.transport,
}

...
}

可以看到上面的部分代码中,已经定义了一个连接池transport,并且其中也设置了MaxConnsPerHostMaxIdleConnsPerHost,端口理论上是一个2字节的整型,也就是范围是0-65535,这里就算1个host跑满使用1200个连接,10个host也才占用12000个连接,而且实际上看日志发现只有几个host,而且这也无法解释大量TIME-WAIT

再次看代码,确定可以排除连接池参数设置问题,终于在一个博客上找到了问题所在Golang 产生大量TIME_WAIT或ESTABLISHED的问题(额外说一句,现在的csdn就是个垃圾)其中提到了使用golang的http.Client容易出现TIME_WAIT上涨的几种情况和解决方案

可以看到博客中所述:

由于忘记读取响应的body导致创建大量处于TIME_WAIT状态的连接

还能有这种问题?看看库里面的几段注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// By default, Transport caches connections for future re-use.
// This may leave many open connections when accessing many hosts.
// This behavior can be managed using Transport's CloseIdleConnections method
// and the MaxIdleConnsPerHost and DisableKeepAlives fields.
type Transport struct {
...
}

// If the returned error is nil, the Response will contain a non-nil
// Body which the user is expected to close. If the Body is not both
// read to EOF and closed, the Client's underlying RoundTripper
// (typically Transport) may not be able to re-use a persistent TCP
// connection to the server for a subsequent "keep-alive" request.
func (c *Client) Do(req *Request) (*Response, error) {
return c.do(req)
}

// Response represents the response from an HTTP request.
//
// The Client and Transport return Responses from servers once
// the response headers have been received. The response body
// is streamed on demand as the Body field is read.
type Response struct {

// Body represents the response body.
//
// The response body is streamed on demand as the Body field
// is read. If the network connection fails or the server
// terminates the response, Body.Read calls return an error.
//
// 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
}

注意到其中的两段注释:

If the Body is not both read to EOF and closed, the Client’s underlying RoundTripper (typically Transport) may not be able to re-use a persistent TCP connection to the server for a subsequent “keep-alive” request.

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.

chatgpt翻译一下:

如果未将Body完全读取并关闭,客户端底层的RoundTripper(通常是Transport)可能无法重新使用持久的TCP连接进行后续的“keep-alive”请求。

HTTP客户端和Transport保证Body始终为非零值,即使在没有主体或主体长度为零的响应中也是如此。关闭Body是调用方的责任。如果未将Body完全读取并关闭,则默认的HTTP客户端的Transport可能无法重用HTTP/1.x“keep-alive”TCP连接。

这里已经说得很明白了,如果想要RoundTripper(也就是Transport)重用连接,必需both read to EOF and closed

那么再看看出问题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (server *Server) HandleReq(w http.ResponseWriter, req *http.Request) {

...

resp, err := server.PassReq(req)
if err != nil {
log.Error(err.Error())
return
}

if resp.StatusCode != 404 || .... {
_, err = io.Copy(w, resp.Body)
if err != nil {
log.Error(err.Error())
}

_ = resp.Body.Close()
return
}

_ = resp.Body.Close()

...
}

可以看到这里有一个if判断if resp.StatusCode != 404 || ....,如果这里判断没有通过,则不会进行读取body的操作,而是直接resp.Body.Close()。这样就会导致在完成body读取之前就关闭了连接。于是这些连接在关闭之前并没有把body读完,导致连接不会重用,于是被close,然后进入TIME_WAIT

为了保证无论如何都可以把body读取了再关闭,改写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (server *Server) HandleReq(w http.ResponseWriter, req *http.Request) {

...

resp, err := server.PassReq(req)
if err != nil {
log.Error(err.Error())
return
}
defer func() {
_, _ = io.CopyN(io.Discard, resp.Body, 1024*4)
_ = resp.Body.Close()
}()

if resp.StatusCode != 404 || .... {
_, err = io.Copy(w, resp.Body)
if err != nil {
log.Error(err.Error())
}
return
}

...
}

这里添加了:

1
2
3
4
defer func() {
_, _ = io.CopyN(io.Discard, resp.Body, 1024*4)
_ = resp.Body.Close()
}()

这里使用了io.CopyN将多余的数据全部写入到io.Discard(也就是丢弃这些不要的数据),同时使用了1024*4来限制读取body剩余数据的最大上限为4k。这个代码就保证了在关闭body之前,将剩余数据读取出来,即使之前body已经被读取完了,这里再次重复读取也没问题。

至于为什么要用io.CopyN而不是io.Copy,下面会进行解释。


为什么golang要这样设计Http

第一个问题:为什么golang不给http.DefaultClient配置连接数上限?

这个问题还好解释,毕竟生产环境上面应该调用者自己配置http.Client,而不是偷懒使用http.DefaultClient,那么这个问题姑且认为调用者占大部分锅。

第二个问题:为什么明明都调用body.close了,剩下的数据已经没法读取了,http库为什么不自动丢弃这些数据?

我们在学习go语言的时候,大部分示例代码都教我们使用defer body.Close()来关闭body即可,直到实际使用时出现大量TIME-WAIT,才会去调查问题,最终发现源自于没有读取完毕剩余数据而提前close导致。

这里可以看一下github上的一些讨论:

net/http: Docs update for connection reuse

Drain Response.Body to enable TCP/TLS connection reuse (4x speedup)

可以看到其中一位用户的发言:

images

chatgpt翻译一下:

这个“读取完整的消息体”在某些时候不是安全问题吗?我不记得这是与服务器还是客户端相关(或者两者都有),但是如果 Go 自动读取完整的消息体,那么程序就很容易被拒绝服务攻击(CPU / 内存消耗)。对手只需发送一个无限消息体,Go 程序将一直消耗它,如果您在代码中没有显式地消耗它(使用 ioutil.Discard 或其他方式),则程序的开发者可能不会知道它正在发生。

我记得有一段时间前的提交,让 Go 读取消息体的一部分,但如果它继续读取,那么它将突然关闭连接,以避免这种潜在的拒绝服务攻击。如果是这种情况,而且当某个 goroutine 还在执行时你发起了另一个请求,那么创建新连接就是有意义的(这也是你在这里看到的,以及为什么读取完整消息体是“解决方案”的原因)。实际上,从安全和“HTTP”角度来看,如果您不从两方面都读取完整的消息体,那么在消息体关闭时或接近关闭时关闭连接并在需要时重新打开一个新连接是不是更有意义?这样一次只有一个连接。

也就是说,其实在之前的某一次提交中,http会去自动处理剩余消息,但是这里确带来了安全问题对手只需发送一个无限消息体,所以又取消的了这个操作。

再看一部分讨论:

images

chatgpt翻译一下:

这么做的风险是你可能面临大量(可能是无限的)数据流。因此,解决方案是仅在从可信源(例如此 PR 中的 GitHub)获取数据时使用此“技巧”?

@Slimmy,你看到了 #317(评论)中的内联建议吗?建议是“读取,比如,512 字节,在关闭时发生 50 毫秒超时,这对于这种情况可能足够好。”

我正在尽力以其最纯粹的形式重现此问题(即:除标准软件包之外没有任何依赖项),但到目前为止无法做到。我想充分了解这是否是任何使用 net/http 的人的问题,还是仅限于此项目。

@kevin-cantwell,非常重要的一点是这些是 TLS 连接,因为所使用的协议是 https,而不是 http。TLS 连接握手非常昂贵,因此重用已建立的到远程服务器的 TLS 连接将提供很大的节省。重用到本地主机的非 TLS 连接将几乎没有什么收益,这就是从你的测试中我可以看到的情况。

这里可以看到,即使是调用者自己去读取剩余消息,也要注意剩余消息过长或者读取时间过久的问题,所以这里提到了read, say, 512 bytes with a 50ms timeout happening on close,所以相比使用io.Copy,这里使用io.CopyN其实更好。

如上所述可以加上超时更保险,如下:

1
2
3
4
5
6
7
8
defer func() {
go func() {
time.Sleep(time.Millisecond * 50)
_ = body.Close()
}()
_, _ = io.CopyN(io.Discard, body, 1024*4)
_ = body.Close()
}()

这里的代码只是随便写一下,这里无论如何都要有一个协程阻塞50ms可能也不是什么好主意。


总结一下

如果你在golang中使用了HttpClient并且想要让它重用连接,那么有几个注意事项:

  1. 不要使用DefaultClinet,自己New一个Client。
  2. Client要设置合理的参数。
  3. 在关闭http响应的body之前,要把里面的数据读完。

另外提一点,每次调用io.Copy()时,它都会在内部创建一个32k的buf []byte,如果想要优化这个地方的性能,可以使用io.CopyBuffer(),然后通过一个缓存池来避免buf的重复创建。


参考

Golang 产生大量TIME_WAIT或ESTABLISHED的问题

使用golang的http.Client容易出现TIME_WAIT上涨的几种情况和解决方案

net/http: Docs update for connection reuse

Drain Response.Body to enable TCP/TLS connection reuse (4x speedup)](