概述
最近在golang程序中,连续遇到了两次在大并发下,出现`connect: cannot assign requested address`的问题,在系统中查询可以发现出现了大量`TIME-WAIT`。下面逐渐梳理一下出现这个问题的原因,以及大并发下,该如何正确使用httpClient。
第一次问题出现
线上的一个go程序在每秒4000个请求的情况下,很快就出现了大量`connect: cannot assign requested address`的请求错误。
这里出现问题的代码如下:
func (c *HttpClient) httpClient() *http.Client {
if c.HTTPClient == nil {
return http.DefaultClient
}
return c.HTTPClient
}
可以看到这里的代码中定义了一个结构体`HttpClient`,其中虽然有`*http.Client`,但是实际上并没有初始化,所以在执行Do请求的时候,使用的仍然是`http.DefaultClient`,可以看一下它的参数定义:
// 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`,修改后使用如下代码:
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`等参数导致的,但查看代码之后,发现其实已经设置了,代码如下:
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`,并且其中也设置了`MaxConnsPerHost`和`MaxIdleConnsPerHost`,端口理论上是一个2字节的整型,也就是范围是0-65535,这里就算1个host跑满使用1200个连接,10个host也才占用12000个连接,而且实际上看日志发现只有几个host,而且这也无法解释大量`TIME-WAIT`。
再次看代码,确定可以排除连接池参数设置问题,终于找到了问题所在。
看看库里面的几段注释:
// 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.
翻译一下:
> 如果未将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`。
那么再看看出问题的代码:
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
}
可以看到这里有一个if判断`if resp.StatusCode != 404 || .... `,如果这里判断没有通过,则不会进行读取body的操作,而是直接`resp.Body.Close()`。这样就会导致在完成body读取之前就关闭了连接。于是这些连接在关闭之前并没有把body读完,导致连接不会重用,于是被close,然后进入`TIME_WAIT`。
为了保证无论如何都可以把body读取了再关闭,改写代码如下:
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
}
这里使用了`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导致。
这里可以看一些讨论:
> 这个“读取完整的消息体”在某些时候不是安全问题吗?我不记得这是与服务器还是客户端相关(或者两者都有),但是如果 Go 自动读取完整的消息体,那么程序就很容易被拒绝服务攻击(CPU / 内存消耗)。对手只需发送一个无限消息体,Go 程序将一直消耗它,如果您在代码中没有显式地消耗它(使用 ioutil.Discard 或其他方式),则程序的开发者可能不会知道它正在发生。
> 我记得有一段时间前的提交,让 Go 读取消息体的一部分,但如果它继续读取,那么它将突然关闭连接,以避免这种潜在的拒绝服务攻击。如果是这种情况,而且当某个 goroutine 还在执行时你发起了另一个请求,那么创建新连接就是有意义的(这也是你在这里看到的,以及为什么读取完整消息体是“解决方案”的原因)。实际上,从安全和“HTTP”角度来看,如果您不从两方面都读取完整的消息体,那么在消息体关闭时或接近关闭时关闭连接并在需要时重新打开一个新连接是不是更有意义?这样一次只有一个连接。
也就是说,其实在之前的某一次提交中,http会去自动处理剩余消息,但是这里确带来了安全问题`对手只需发送一个无限消息体`,所以又取消的了这个操作。
再看一部分讨论:
> 这么做的风险是你可能面临大量(可能是无限的)数据流。
> 建议是“读取,比如,512 字节,在关闭时发生 50 毫秒超时,这对于这种情况可能足够好。”
这里可以看到,即使是调用者自己去读取剩余消息,也要注意剩余消息过长或者读取时间过久的问题,所以相比使用`io.Copy`,这里使用`io.CopyN`其实更好。
如上所述可以加上超时更保险,如下:
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的重复创建。