说明

Http 连接是很珍贵的资源,其基于 Tcp ,Http1.1 的 Connection: keep-alive 使得 Http 客户端在发完一个请求后并不会立即关闭 Tcp 连接,它会继续等待一段时间,如果该连接上又有新的 Http 请求,就复用这个连接。

Tcp 主动关闭的一方会有 TIME_WAIT 状态,该状态会占用端口资源,如果服务器在短时间内发起大量的外部 Http 请求,将会积压很多 TIME_WAIT 连接,消耗服务器资源,甚至可能导致一些不可预料的bug。

所以,复用 Http 请求连接就显得格外重要,当然,Golang 是支持我们复用这个连接的,原理就是利用 http.Client 的参数 Transport 。

Transport is an implementation of RoundTripper that supports HTTP, HTTPS, and HTTP proxies (for either HTTP or HTTPS with CONNECT).

By default, Transport caches connections for future re-use.

客户端测试

type Transport struct {
  .......
  // MaxIdleConns controls the maximum number of idle (keep-alive)
  // connections across all hosts. Zero means no limit.
  MaxIdleConns int

  // 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

  // IdleConnTimeout is the maximum amount of time an idle
    // (keep-alive) connection will remain idle before closing
    // itself.
    // Zero means no limit.
    IdleConnTimeout time.Duration
  ......
}
MaxConnsPerHost

在多提一点,默认的 Transport :

// DefaultTransport is the default implementation of Transport and is
// used by DefaultClient. It establishes network connections as needed
// and caches them for reuse by subsequent calls. It uses HTTP proxies
// as directed by the $HTTP_PROXY and $NO_PROXY (or $http_proxy and
// $no_proxy) environment variables.
var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

即默认 MaxConnsPerHost 是没有限制的。

main.go:

package main

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

func main()  {
    client := &http.Client{
        Timeout: time.Second * 10,
        Transport: &http.Transport{
            MaxConnsPerHost:1,
        },
    }
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0;i < 10 ;i++  {
        go NewRequest(&wg,client)
    }
    wg.Wait()
    fmt.Println("全部完成")
}

func NewRequest(wg *sync.WaitGroup,client *http.Client)  {
    defer wg.Done()
    req,err := http.NewRequest("POST","http://*********:9502",nil)
    if err != nil {
        log.Fatal(err)
    }
    res,err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()
    content,err := ioutil.ReadAll(res.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(content))
}

抓包验证

使用 Wireshark 抓包,tcp.port == 9502 过滤之后可以清晰的观察到整个过程只有一次 TCP 的三次握手和四次挥手过程:

Follow Http 可以看到整个过程是串行的:

前提

复用连接的前提是客户端,服务端同时支持,如果任意一方不支持,连接将不会被复用。调整服务端代码:

  s := &http.Server{
        Addr:           ":9502",
        Handler:        r, // < here Gin is attached to the HTTP server
        //  ReadTimeout:    10 * time.Second,
        //  WriteTimeout:   10 * time.Second,
        //  MaxHeaderBytes: 1 << 20,
    }
    s.SetKeepAlivesEnabled(false)
    err := s.ListenAndServe()
    if err != nil {
        fmt.Println(err)
    }

客户端再测试抓包:

很明显,连接没有复用,仔细数的话有10次连接重建的过程。但在客户端这边是始终只保持一个连接的,后续请求都是在前面的连接关闭之后再发起的。

其他参数

MaxIdleConnsMaxIdleConnsPerHost

调整客户端代码:

client := &http.Client{
        Timeout: time.Second * 10,
        Transport: &http.Transport{
            MaxIdleConnsPerHost:1,
            MaxConnsPerHost:2,
            IdleConnTimeout:time.Second * 2,
        },
    }
    var wg sync.WaitGroup
    wg.Add(6)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    time.Sleep(time.Second * 3)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    time.Sleep(time.Second * 3)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    wg.Wait()

同时恢复服务端,支持连接复用,再抓包观察:

可以明显看到是两个连接建立,各发起一个请求,然后两个连接关闭;再重复该过程。

调整请求间隔:

client := &http.Client{
        Timeout: time.Second * 10,
        Transport: &http.Transport{
            MaxIdleConnsPerHost:1,
            MaxConnsPerHost:2,
            IdleConnTimeout:time.Second * 2,
        },
    }
    var wg sync.WaitGroup
    wg.Add(6)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    time.Sleep(time.Second)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    time.Sleep(time.Second * 3)
    for i := 0;i < 2 ;i++  {
        go NewRequest(&wg,client)
    }
    wg.Wait()

这一次又有不同,这个是只 Sleep 1秒,由于 MaxIdleConnsPerHost=1 所以第一波两个请求发完,必须关闭其中一个连接,没有到达 IdleConnTimeout ,所以第一波的另外一个连接可以复用,只需创建一个新连接即可完成第二波请求。后面 Sleep 3秒,显然都过了 Idle 时间,两个连接都关闭了再发起最后一波请求。

总结

服务间接口调用,尤其是需要调用第三方接口的场景,复用连接对性能非常有帮助。合理设置相关参数,做到”心中有数“。

2021-12-31