抓包查看 Golang 下 HTTP 连接池


使用 HTTP 连接池有诸多好处,在高频访问网站时,复用 TCP 连接可以节省资源,加速请求。

前两天同事分享了相关内容,本文章就当时 Leader 提出的问题进行验证。

Q:接口请求耗时 100ms,连接池里有 10 个连接,使用 HTTP 连接池一秒钟能有多少次请求?

TCP 短连接与长连接

这里有一个 Python 的简单示例,正常请求服务,每次请求后会断开 TCP 连接,而使用 Session 创建的长连接则会在与远端通信时复用 TCP 连接。

import requests


def stand_alone():

    for i in range(3):
        r = requests.get("http://192.168.3.111:8081/")
        print(r.status_code)


def using_session():

    s = requests.Session()

    for i in range(3):
        r = s.get('http://192.168.3.111:8081/')
        print(r.status_code)


if __name__ == "__main__":

    # stand_alone()

    using_session()

短连接

观察 Wireshark 抓包数据,最左侧的框线选择的就是一次 TCP 请求的起止,另外数据中的 [SYN] 表示 TCP 握手请求,也有助于判断三次请求共三个 TCP 连接过程。

长连接

使用长连接建立一次 TCP 请求后,三次 HTTP 请求使用同一连接。

粗略的计算可知短连接请求三次网站数据耗时 1.24s,使用长连接只需 0.83s,节省了三分之一的时间,请求量的增加,将更加明显。

HTTP 连接池

以上通过 Python 示例,直观了解到短连接和长连接在网络传输的不同。

HTTP 连接池即基于长连接构建。

Golang 使用连接池

在 Golang 中,借助 http.Client 可以实现相同的效果。

req-client.go

package main

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

var c = &http.Client{Timeout: time.Duration(3) * time.Second}

func makeRequest() {
    resp, err := c.Get("http://192.168.3.111:8081/")
    if err != nil {
        fmt.Printf("Error %s", err)
        return
    }
    defer resp.Body.Close()

    _, err = io.Copy(ioutil.Discard, resp.Body)
    fmt.Printf("status_code: %d\n", resp.StatusCode)
}

func main() {
  for i:=0; i<3; i++ {
    makeRequest()
  }
}

按照预期,请求复用了 TCP 连接

进一步观察图中 HTTP 请求,单次请求从 GET 发起请求到服务回复 200OK 正好间隔约 100ms

对于文章顶部的问题,似乎可以得出结论,在与服务器的 TCP 连接建立后,通过连接池访问网站,一秒钟能够请求 10 次。

协程请求试试

以上方式得出一个“结论”,但如果结合协程来看,又一个问题出现了—— “如果开多个协程请求,那么协程间复用同一个 TCP 连接还是自己用自己的呢?”

带着问题进行验证,以下是协程请求的代码

req-client-go.go

package main

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

var c = http.Client{Timeout: time.Duration(3) * time.Second}

func makeRequest(wg *sync.WaitGroup) {
    resp, err := c.Get("http://192.168.3.111:8081/")
    if err != nil {
        fmt.Printf("Error %s", err)
        return
    }
    defer resp.Body.Close()

    _, err = io.Copy(ioutil.Discard, resp.Body)

    fmt.Printf("status_code: %d\n", resp.StatusCode)
    defer wg.Done()
}

func main() {
  var wg sync.WaitGroup

  for i:=0;i<3;i++ {
    wg.Add(1)
    go makeRequest(&wg)
  }

  wg.Wait()
}

从请求结果看,每个协程在使用 HTTP 连接池的时候,连接池都创建了一条 TCP 连接。

跟之前的效果相同,使用 for 循环没开协程请求的例子,main 也只是使用了一个 TCP 连接。

那么,应该有什么参数能控制 HTTP 连接池的最大创建连接数量

参数初探

携带参数的初始化

var c = &http.Client{
    Timeout: time.Duration(10) * time.Second,
    Transport: &http.Transport{
        Proxy:                 http.ProxyFromEnvironment,
        MaxIdleConns:          100, // 最大的空闲链接,设置为0 不限制
        MaxIdleConnsPerHost:   10,  // 单个 Host 最大的空闲链接,设置为 0 为 DefaultMaxIdleConnsPerHost
        MaxConnsPerHost:       2,   // 单个 Host 最大的链接限制,设置为 0 不限制
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    },
}

根据配置说明,当 MaxConnsPerHost 设置为 2 的时候,如果使用协程请求,那么相同的 IP+Port 只会创建两个 TCP 连接。

再次并发请求服务,只创建了两个 TCP 连接,符合预期。

在其中一个 TCP 连接上 “右键 - Follow - TCP Stream” 可以更为直观的查看到这个 TCP 连接包含两个 HTTP 请求。

结论更新

当不使用协程请求时,一秒钟可以发起 10 次请求。 当使用协程并发请求,一秒钟可以发起 100 次请求。

借助这个问题逐步的验证,可以直观了解到 HTTP 连接池的用处,以及在 Golang 中使用连接池的行为,小有收获。

另外,在 Golang 中要用好连接池,还需要对各项参数有更多的了解,本次的学习就先到这里。

参考