在Go语言中重用HTTP连接

104

目前我正在努力寻找在Go中进行HTTP POST时重复使用连接的方法。

我已创建了这样的传输和客户端:

// Create a new transport and HTTP client
tr := &http.Transport{}
client := &http.Client{Transport: tr}

我将客户端指针传递给一个 goroutine,该 goroutine 将多次对同一端点进行发布,如下所示:

r, err := client.Post(url, "application/json", post)

查看netstat,发现每次POST都会导致新连接的建立,造成大量并发连接打开。

在这种情况下,正确的重用连接的方法是什么?


2
这个问题的正确答案已经在这个重复的帖子中发布了:Go客户端程序生成大量处于TIME_WAIT状态的套接字 - Brent Bradburn
10个回答

132

确保你一直读取响应直到完成并调用Close()

例如:

res, _ := client.Do(req)
io.Copy(ioutil.Discard, res.Body)
res.Body.Close()

再次强调... 确保http.Client连接重用,请执行以下操作:

  • 读取完整的响应内容(例如ioutil.ReadAll(resp.Body)
  • 调用Body.Close()

1
我正在向同一主机发送请求。然而,我的理解是MaxIdleConnsPerHost会导致空闲连接被关闭。这不是事实吗? - sicr
6
因为我在一个类似的程序中调用了 defer res.Body.Close(),但有时在执行这部分代码之前就从函数中返回(例如,如果 resp.StatusCode != 200),这会导致许多打开的文件描述符空闲并最终杀死我的程序。看到这个帖子让我重新审视了代码的这一部分,同时也感到很惭愧。谢谢! - sa125
3
有趣的一点是,读取步骤似乎是必要且充分的。仅使用读取步骤将返回连接到池中,但仅关闭不会; 连接最终将进入TCP_WAIT状态。我因为使用json.NewDecoder()读取response.Body而遇到了问题,它没有完全读取。如果不确定,请确保包括io.Copy(ioutil.Discard, res.Body)。 - Sam Russell
3
有没有一种方法可以检查是否已经完全读取了请求主体?使用ioutil.ReadAll()函数是否足够保证,还是我需要到处添加io.Copy()调用,以防万一? - Patrik Iselind
6
我查看了源代码,似乎响应体的 Close() 已经处理了排空响应体的问题: https://github.com/golang/go/blob/9d23975d89e6cc3df4f2156b2ae0df5d2cef16fb/src/net/http/transfer.go#L979 - dr.scre
显示剩余7条评论

51

如果还有人在寻找如何做到这一点的答案,这是我正在使用的方法。

package main

import (
  "bytes"
  "io/ioutil"
  "log"
  "net/http"
  "time"
)

func httpClient() *http.Client {
    client := &http.Client{
        Transport: &http.Transport{
            MaxIdleConnsPerHost: 20,
        },
        Timeout: 10 * time.Second,
    }

    return client
}

func sendRequest(client *http.Client, method string) []byte {
    endpoint := "https://httpbin.org/post"
    req, err := http.NewRequest(method, endpoint, bytes.NewBuffer([]byte("Post this data")))
    if err != nil {
        log.Fatalf("Error Occured. %+v", err)
    }

    response, err := client.Do(req)
    if err != nil {
        log.Fatalf("Error sending request to API endpoint. %+v", err)
    }

    // Close the connection to reuse it
    defer response.Body.Close()

    body, err := ioutil.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("Couldn't parse response body. %+v", err)
    }

    return body
}

func main() {
    c := httpClient()
    response := sendRequest(c, http.MethodPost)
    log.Println("Response Body:", string(response))
}

Go Playground: https://play.golang.org/p/cYWdFu0r62e

总之,我正在创建一种不同的方法来创建一个HTTP客户端并将其分配给一个变量,然后使用它来发出请求。请注意:

defer response.Body.Close() 

在函数执行结束时,这将关闭连接,您可以多次重复使用客户端。

如果您想在循环中发送请求,请调用发送请求的函数。

如果您想更改客户端传输配置中的任何内容,例如添加代理配置,请在客户端配置中进行更改。

希望这能帮助到某个人。


5
如果有多个 goroutine 调用一个使用 http.Client 作为全局变量的函数,那么使用这个变量是否安全免受竞态条件的影响? - Bart Silverstrim
3
@bn00d是defer response.Body.Close()正确的吗?我问这个问题是因为通过延迟关闭,我们实际上不会关闭连接以便重复利用,直到主函数退出,因此应该在.ReadAll()之后直接调用.Close()。在您的示例中,这似乎不是问题,因为它实际上并没有演示如何进行多个请求,它只是进行了一个请求,然后退出,但是如果我们连续进行多个请求,似乎由于是defer.Close()直到函数退出才会被调用。或者...我有什么疏忽吗?谢谢。 - mad.meesh
1
@mad.meesh 如果你进行多次调用(例如在循环内),只需将对Body.Close()的调用包装在闭包中,这样它将在处理完数据后立即关闭。 - Antoine Cotten
我该如何以这种方式为每个请求设置不同的代理?这是可能的吗? - Amir Khoshhal
@bn00d你的示例似乎不起作用。在添加了循环之后,每个请求仍然会导致一个新的连接。https://play.golang.org/p/9Ah_lyfYxgV - Lewis Chan
这对我来说不起作用。我能看到我代码和这个之间唯一的显著区别是我的服务器是http而不是httpstcpdump显示每个连接都有一个新的TCP SYN/ACK。 - Tom

41

编辑:这更像是给那些为每个请求构建传输和客户端的人的注意事项。

编辑2:将链接更改为godoc。

Transport 是保存连接以供重复使用的结构体;请参见 https://godoc.org/net/http#Transport (“默认情况下,Transport 缓存连接以供将来重复使用。”)

因此,如果您为每个请求创建一个新的 Transport,它将每次创建新的连接。在这种情况下,解决方案是在客户端之间共享一个 Transport 实例。


请使用特定的提交链接。你的链接不再正确。 - Inanc Gumus
这个示例只展示了一个传输方式,但仍然为每个请求生成一个连接。为什么会这样? - Lewis Chan

13

据我所知,默认的客户端确实会重用连接。你是否关闭了响应

调用者在读取完resp.Body后应该关闭它。如果不关闭resp.Body,则客户端底层的RoundTripper(通常是Transport)可能无法重用持久的TCP连接进行后续的“keep-alive”请求。


嗨,谢谢回复。是的,抱歉我应该也包括在内。我正在使用r.Body.Close()关闭连接。 - sicr
@sicr,你确定服务器没有关闭连接吗?我的意思是,这些未完成的连接可能处于*_WAIT状态或类似状态。 - kostix
1
@kostix,当查看netstat时,我发现有大量与状态ESTABLISHED的连接。似乎每个POST请求都会产生一个新的连接,而不是重用相同的连接。 - sicr
@ sicr,你找到关于连接复用的解决方案了吗?非常感谢,Daniele。 - Daniele B

5
关于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.

如果您想重用TCP连接,则必须在每次读取完成后关闭Body。同时,使用defer可以确保在所有操作完成后调用Body.Close()

建议使用以下函数ReadBody(io.ReadCloser)

package main

import (
    "fmt"
    "io"
    "io/ioutil"
    "net/http"
    "time"
)

func main() {
    req, err := http.NewRequest(http.MethodGet, "https://github.com", nil)
    if err != nil {
        fmt.Println(err.Error())
        return
    }
    client := &http.Client{}
    i := 0
    for {
        resp, err := client.Do(req)
        if err != nil {
            fmt.Println(err.Error())
            return
        }
        _, _ = readBody(resp.Body)
        fmt.Println("done ", i)
        time.Sleep(5 * time.Second)
    }
}

func readBody(readCloser io.ReadCloser) ([]byte, error) {
    defer readCloser.Close()
    body, err := ioutil.ReadAll(readCloser)
    if err != nil {
        return nil, err
    }
    return body, nil
}

不要像下面这样调用Close:

res, _ := client.Do(req)
io.Copy(ioutil.Discard, res.Body) // what if io.Copy panics, res.Body.Close() will not called.
res.Body.Close()

3

使用单例方法获取HTTP客户端是 init() 的另一种方法。通过使用 sync.Once,您可以确保所有请求上只使用一个实例。

var (
    once              sync.Once
    netClient         *http.Client
)

func newNetClient() *http.Client {
    once.Do(func() {
        var netTransport = &http.Transport{
            Dial: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).Dial,
            TLSHandshakeTimeout: 2 * time.Second,
        }
        netClient = &http.Client{
            Timeout:   time.Second * 2,
            Transport: netTransport,
        }
    })

    return netClient
}

func yourFunc(){
    URL := "local.dev"
    req, err := http.NewRequest("POST", URL, nil)
    response, err := newNetClient().Do(req)
    // ...
}


这对我来说非常完美,每秒处理100个HTTP请求。 - philip mudenyo
@philipmudenyo 你如何测试每秒100个HTTP请求。你用什么命令进行测试? - suresh manda

2
这里缺失的是"goroutine"。Transport有自己的连接池,默认情况下,该池中的每个连接都会被重用(如果body已完全读取并关闭),但如果有多个goroutine发送请求,则会创建新的连接(池中所有连接都忙碌,并将创建新的连接)。为了解决这个问题,您需要限制每个主机的最大连接数:Transport.MaxConnsPerHosthttps://golang.org/src/net/http/transport.go#L205)。
可能您还希望设置IdleConnTimeout和/或ResponseHeaderTimeout

0

这是一个非常有用的GO语言HTTP调用函数,您可以保持连接活动并重复使用此连接。

    var (
        respReadLimit       = int64(4096)
    )
    
    // Try to read the response body so we can reuse this connection.
    func (c *Client) drainBody(body io.ReadCloser) error {
        defer body.Close()
        _, err := io.Copy(ioutil.Discard, io.LimitReader(body, respReadLimit))
        if err != nil {
            return err
        }
        return nil
    }

0

https://golang.org/src/net/http/transport.go#L196

你应该显式地设置MaxConnsPerHost到你的http.Client中。虽然Transport会重用TCP连接,但你应该限制MaxConnsPerHost(默认为0表示没有限制)。

func init() {
    // singleton http.Client
    httpClient = createHTTPClient()
}

// createHTTPClient for connection re-use
func createHTTPClient() *http.Client {
    client := &http.Client{
        Transport: &http.Transport{
            MaxConnsPerHost:     1,
            // other option field
        },
        Timeout: time.Duration(RequestTimeout) * time.Second,
    }

    return client
}

-3

有两种可能的方法:

  1. 使用一个内部重用和管理与每个请求相关联的文件描述符的库。Http Client在内部执行相同的操作,但是您将控制打开多少并发连接以及如何管理资源。如果您感兴趣,请查看netpoll实现,它在内部使用epoll / kqueue进行管理。

  2. 更简单的方法是,不是池化网络连接,而是为goroutine创建一个工作池。这将是一种简单且更好的解决方案,不会妨碍您当前的代码库,并且只需要进行轻微的更改。

假设您需要在收到请求后进行n个POST请求。

enter image description here

enter image description here

你可以使用通道来实现这个功能。
或者,你也可以使用第三方库。
比如: https://github.com/ivpusic/grpool

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接