Golang中使用HTTP连接池及实际案例
连接池
碎碎念
其实所谓的“连接池”,个人观点是一种在工程实践中以空间换时间的优化方案。
我们在实际的开发中,常见的资源表现形式:一种是存储(内存+磁盘存储)资源,还有是IO(磁盘IO+网络IO)资源,另外当然还有操作系统CPU的调度/计算等等。
而在实际中,存储资源相对于IO及计算来说便宜很多,比如说当我们的服务遇到瓶颈的时候,最直接的方案就是升级机器、增加存储,先让线上的服务恢复稳定然后再去考虑优化。
什么是连接池
连接池其实是基于“连接”的概念而来的。所谓连接,在绝大多数情况下其实就是Client-Server模式的一种资源传输的通道。当客户端与服务端想要创建通信时,首先需要在二者之间创建连接(TCP、UDP),然后二者进行数据通信、资源传输等等操作。
而客户端与服务端每创建一次连接需要很多次的网络IO操作,期间也会使用CPU的计算、调度开销。而且实际中如果二者需要长时间不断创建、断开链接资源,会浪费大量的CPU调度资源。
而如果使用连接池的话:我们在客户端与服务端之间“提前”维护好“固定数量的链接”,这些链接整合到一起会占用操作系统的内存资源,但是这个连接池中的连接使用完以后会重新放入连接池中而不是直接断开,等需要处理后续的业务的时候还能使用原来的连接资源,这样就大大降低了网络IO的开销(因为每次创建、释放连接的时候都需要握手、挥手的过程);而且上面也提到了内存资源相对于IO及计算资源相对便宜一些,虽然连接池占用了一定的内存资源,但是通过这种方式我们节约了网络IO的开销,在很大程度上能使系统保持稳定。
常见的连接池:数据库连接池、redis连接池、HTTP连接池;当然代码级别中我们常见的有进程池、线程池(线程与进程严格意义上不算是连接啦,是操作系统中的资源)等等。
HTTP连接池使用前提
HTTP连接池使实现的效果其实是连接可以复用,那复用的前提是连接一直存在,所以:需要服务端与客户端都支持长链接!只要有一方断开了连接那么这个连接就不能复用了!
实际业务中的一个案例
使用go-retryablehttp包实现http“链接池”效果
HTTP连接池的参数实验(一)
默认值说明
这里用一个例子简单说明一下:
package main import ( "flag" "fmt" "net/http" "os" "os/signal" "syscall" "time" "github.com/davecgh/go-spew/spew" "github.com/sirupsen/logrus" ) // TODO 参考文章链接:https://xujiahua.github.io/posts/20200723-golang-http-reuse/ // TODO 计算客户端host(IP+Port)的数量 var m = make(map[string]int) var ch = make(chan string, 10) // TODO 计算链接数量 func count() { for s := range ch { m[s]++ } } func home(w http.ResponseWriter, r *http.Request) { logrus.Info(r.RemoteAddr) // TODO 最后打印的是 remoteAddr ch <- r.RemoteAddr // time.Sleep(time.Second) w.Write([]byte("helloworld")) } func init() { logrus.SetFormatter(&logrus.TextFormatter{ DisableColors: true, FullTimestamp: true, }) } func graceClose() { c := make(chan os.Signal) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c close(ch) time.Sleep(time.Second) spew.Dump(m) os.Exit(0) }() } func main() { graceClose() go count() port := flag.Int("port", 8087, "") flag.Parse() logrus.Println("Listen port:", *port) http.HandleFunc("/", home) if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), nil); err != nil { panic(err) } }
package main import ( "io/ioutil" "net/http" "testing" "time" ) var _httpCli = &http.Client{ Timeout: time.Duration(15) * time.Second, Transport: &http.Transport{ MaxIdleConns: 1, MaxIdleConnsPerHost: 1, MaxConnsPerHost: 1, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, } func get(url string) { resp, err := _httpCli.Get(url) if err != nil { // do nothing return } defer resp.Body.Close() _, err = ioutil.ReadAll(resp.Body) if err != nil { // do nothing return } } func TestLongShort(t *testing.T) { go func() { for i := 0; i < 100; i++ { if i%10 == 0 { time.Sleep(time.Second) } go get("http://127.0.0.1:8087") } }() go func() { for i := 0; i < 100; i++ { if i%10 == 0 { time.Sleep(time.Second) } go get("http://127.0.0.1:8088") } }() time.Sleep(time.Second * 10) } func TestLongLong(t *testing.T) { go func() { for i := 0; i < 100; i++ { if i%10 == 0 { time.Sleep(time.Second) } go get("http://127.0.0.1:8087") } }() go func() { for i := 0; i < 100; i++ { if i%10 == 0 { time.Sleep(time.Second) } go get("http://127.0.0.1:8089") } }() time.Sleep(time.Second * 10) } func TestLong(t *testing.T) { go func() { for i := 0; i < 100; i++ { if i%10 == 0 { time.Sleep(time.Second) } go get("http://127.0.0.1:8087") } }() time.Sleep(time.Second * 10) }
案例一:
客户端连接池参数如下:
启动项目:
运行 TestLong 这个函数,可以看到,客户端只用了一个TCP连接去处理请求:
案例二:
客户端连接池设置参数如下:
再运行 TestLong 结果如下:
在客户端程序运行期间,也可以使用netstat命令看看效果:
案例三:
客户端连接池设置如下:最大连接数设置为2,两个空闲的配置为1
再运行 TestLong 结果如下:也就是说,超过了最大连接数,设置了空闲连接数的话,会自动再开一个链接处理请求,而不是等“最大连接数的计数-1”~
案例四:多个host连接(一)
MaxIdleConns vs MaxIdleConnsPerHost 两个连接池
如下源码,先检查 PerHost 的池子有没有满,再检查总的池子有没有满。也就是说,MaxIdleConns设置不合理,会对MaxIdleConnsPerHost有影响。
// tryPutIdleConn adds pconn to the list of idle persistent connections awaiting // a new request. // If pconn is no longer needed or not in a good state, tryPutIdleConn returns // an error explaining why it wasn't registered. // tryPutIdleConn does not close pconn. Use putOrCloseIdleConn instead for that. func (t *Transport) tryPutIdleConn(pconn *persistConn) error { ... idles := t.idleConn[key] if len(idles) >= t.maxIdleConnsPerHost() { // 如果超过了maxIdleConnsPerHost,报连接太多,当前pconn被关掉。 return errTooManyIdleHost } for _, exist := range idles { if exist == pconn { log.Fatalf("dup idle pconn %p in freelist", pconn) } } t.idleConn[key] = append(idles, pconn) t.idleLRU.add(pconn) if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns { oldest := t.idleLRU.removeOldest() // 如果超过了MaxIdleConns,杀掉老的idle connection oldest.close(errTooManyIdle) t.removeIdleConnLocked(oldest) } ...
客户端参数配置如下:
现在需要运行2个服务端:
然后客户端执行 TestLongLong 结果如下,都有不断重建的情况:
案例五:多个host连接(二)
客户端这样配置连接池参数:
现在需要运行2个服务端:
然后客户端执行 TestLongLong 结果如下,两个客户都端维持一个链接:
HTTP连接池的参数实验(二)客户端连接复用需要Client与Server同时支持
服务间接口调用,维持稳定数量的长连接,对性能非常有帮助。
几个参数:
MaxIdleConnsPerHost:优先设置这个,决定了对于单个Host需要维持的连接池大小。该值的合理确定,应该根据性能测试的结果调整。
MaxIdleConns:客户端连接单个Host,不少于MaxIdleConnsPerHost大小,不然影响MaxIdleConnsPerHost控制的连接池;客户端连接 n 个Host,少于 n X MaxIdleConnsPerHost 会影响MaxIdleConnsPerHost控制的连接池(导致连接重建)。嫌麻烦,建议设置为0,不限制。
MaxConnsPerHost:对于单个Host允许的最大连接数,包含IdleConns,所以一般大于等于MaxIdleConnsPerHost。设置为等于MaxIdleConnsPerHost,也就是尽可能复用连接池中的连接。另外设置过小,可能会导致并发下降,超过这个值会 block 请求,直到有空闲连接。(所以默认值是不限制的)
服务端不支持长链接的情况
任何一方主动关闭连接,连接就无法复用。
客户端参数:
运行服务端的8088:(会主动断开链接!)
然后运行一下8087(不会主动断开链接):
然后客户端执行 TestLongShort 结果如下,8087这个客户端还是维持一个链接:
很明显:8088端口服务端每次链接都断开的话~客户端每次会用不同的链接:
客户端不获取响应体数据链接也无法复用
连接池设置的没问题:
但是,客户端不获取响应数据:
看一下8087端口的结果:最终还是创建了好多个链接!!!
参考文章
Tuning the Go HTTP Client Settings for Load Testing
Build a TCP Connection Pool From Scratch With Go
查看 mac os 系统本地端口连接数,记一次ES client验证
通过实例理解Go标准库http包是如何处理keep-alive连接的
LINUX下解决netstat查看TIME_WAIT状态过多问题 ** netstat命令
netstat -an|awk '/tcp/ {print $6}'|sort|uniq -c
~~~