连接池的结构
我们了解到连接池可以复用网络连接,接下来我们通过一个例子来看看网络连接池的结构。
func main() { tr := &http.Transport{ MaxIdleConns: 100, IdleConnTimeout: 3 * time.Second, } n := 5 for i := 0; i < n; i++ { req, _ := http.NewRequest("POST", "http://www.baidu.com", nil) req.Header.Add("content-type", "application/json") client := &http.Client{ Transport: tr, Timeout: 3 * time.Second, } resp, _ := client.Do(req) _, _ = ioutil.ReadAll(resp.Body) _ = resp.Body.Close() } time.Sleep(time.Second * 1) fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine()) }
注意这里请求的不是https
,而是http
。最终结果输出5
,为什么?
这是因为,http://www.baidu.com
会返回307,重定向到https://www.baidu.com
。
http重定向为https
在网络中,我们可以通过一个五元组来唯一确定一个TCP连接。
五元组
它们分别是源ip,源端口,协议,目的ip,目的端口。只有当多次请求的五元组一样的情况下,才有可能复用连接。
放在我们这个场景下,源ip、源端口、协议都是确定的,也就是两次http请求的目的ip或目的端口有区别的时候,就需要使用不同的TCP长连接。
而http用的是80端口,https用的是443端口。于是连接池就为不同的网络目的地建立不同的长连接。
因此最终结果5个goroutine,其实2个goroutine来自http,2个goroutine来自https,1个main goroutine。
我们来看下源码的具体实现。net/http底层通过一个叫idleConn
的map去存空闲连接,也就是空闲连接池。
idleConn
这个map的key是协议和地址,其实本质上就是ip和端口。map的value是长连接的数组([]*persistConn
),说明net/http支持为同一个地址建立多个TCP连接,这样可以提升传输的吞吐。
连接池的结构和逻辑
Transport是什么?
Transport本质上是一个用来控制http调用行为的一个组件,里面包含超时控制,连接池等,其中最重要的是连接池相关的配置。
我们通过下面的例子感受下。
func main() { n := 5 for i := 0; i < n; i++ { httpClient := &http.Client{} resp, _ := httpClient.Get("https://www.baidu.com") _, _ = ioutil.ReadAll(resp.Body) _ = resp.Body.Close() } time.Sleep(time.Second * 1) fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine()) } func main() { n := 5 for i := 0; i < n; i++ { httpClient := &http.Client{ Transport: &http.Transport{}, } resp, _ := httpClient.Get("https://www.baidu.com") _, _ = ioutil.ReadAll(resp.Body) _ = resp.Body.Close() } time.Sleep(time.Second * 1) fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine()) }
上面的代码第一个例子的代码会输出3
。分别是main goroutine + read goroutine + write goroutine,也就是有一个被不断复用的TCP连接。
在第二例子中,当我们在每次client中都创建一个新的http.Transport
,此时就会输出11
。
说明TCP连接没有复用,每次请求都会产生新的连接。这是因为每个http.Transport内都会维护一个自己的空闲连接池,如果每个client都创建一个新的http.Transport,就会导致底层的TCP连接无法复用。如果网络请求过大,上面这种情况会导致协程数量变得非常多,导致服务不稳定。
因此,最佳实践是所有client都共用一个transport。
func main() { tr := &http.Transport{ MaxIdleConns: 100, IdleConnTimeout: 3 * time.Second, } n := 5 for i := 0; i < n; i++ { req, _ := http.NewRequest("POST", "https://www.baidu.com", nil) req.Header.Add("content-type", "application/json") client := &http.Client{ Transport: tr, Timeout: 3 * time.Second, } resp, _ := client.Do(req) _, _ = ioutil.ReadAll(resp.Body) _ = resp.Body.Close() } time.Sleep(time.Second * 1) fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine()) }
如果创建客户端的时候不指定http.Client
,会默认所有http.Client都共用同一个DefaultTransport
。这一点可以从源码里看出。
默认使用DefaultTransport
DefaultTransport
因此当第二段代码中,每次都重新创建一个Transport的时候,每个Transport内都会各自维护一个空闲连接池。因此每次建立长连接后都会多两个协程(读+写),对应1个main goroutine+(read goroutine + write goroutine)* 5 =11。