连接池设计与实现一——以Golang Http1.1为例
0. 前言
连接池是一个非常重要的开发思想,如http client会构建连接池复用底层TCP连接,使用database/sql
的使用也会有连接池的配置。那么代码底层是如何实现连接池的呢?这篇文档将以Golang语言为基础,分析http1.1连接池
底层实现
★注意:我们仅仅关注连接池设计思想、以及关键源码解读,并不会涉及太多的细节,如果想要了解更多的细节,需要读者自己阅读源码
”
在开始之前,我们思考如下几个问题
- 创建的连接应该放在哪里?数组、链表、channel
- 为什么官方都不基于sync.Pool来实现连接池呢?
1. 关键字段分析
在各种连接池中都有几个比较通用的字段,可以先对这几个字段进行初步的了解
MaxIdleConn: 指的是连接池的大小
MaxConn: 指的是客户端最多可以开多少个连接,如果客户端并发很大, MaxIdleConn等于10,MaxConn等于20,此时连接可能存活20个,但是连接池中只会有10个,如果连接池满了,则关闭丢弃、关闭多余的连接
IdleConnTimeout: 一个连接如果超过IdleConnTimeout这个时间没有没重新利用,则会关闭这个连接
2. http client pool设计与源码
★Golang版本: go1.19.5 linux/amd64 http1.1部分源码
”
★重要:
这里我仅仅展示的是代码片段,并没有将整个代码展示出来,所以,在看到这篇文档的时候,需要打开你的电脑,打开对应的源码,参考阅读
”
2.1 关键字段解读
【连接池demo】
func main() {
// 创建一个HTTP客户端
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 100,
},
}
client.Get("https://www.baidu.com")
}
针对每一个server,客户端都会缓存当前server对应的连接池(基于map缓存),http client有几个控制字段
-
MaxIdleConns: 客户端会和各个server建立连接,那么这里就是总连接池大小
-
MaxIdleConnsPerHost:每一个server的连接池大小
-
MaxConnsPerHost:每一个server的最大连接数量
★
他们之间的数学关系 MaxIdleConnsPerHost * n <= MaxIdleConns (n 代表服务端域名个数) MaxIdleConnsPerHost <= MaxConnsPerHost
注意
为什么在http里面没有设计MaxConns这样一个参数呢?我也没搞懂,如果要实现这样的功能,那么就每一个server创建一个http client
”
idleConn : 维护连接池里面的连接,是一个map数据类型,key是一个代表一个server,value是一个具体的连接对应的数组
idleconn.png
【整体概览getConn方法源码】
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
w := &wantConn{}
// 尝试从连接池中获取连接
if delivered := t.queueForIdleConn(w); delivered {}
// 连接池中没有连接,则需要自己创建
t.queueForDial(w)
// 等待连接就绪,针对的是无法从连接池获取连接的情况,才需要在这里等待
select {
case <-w.ready:
}
}
先思考如下几个问题,等我们分析完成所有逻辑之后,再来看看这几个问题
- 如果连接池中不存在连接应该如何处理?
- 如果达到最大连接数超过了MaxConnsPerHost怎么办?
- 连接用完之后,放回到连接池,发现连接池满了,或者达到最大连接了,如何处理?
2.2连接获取逻辑
★通过http client发送http请求,会通过如下方法获取连接
”
// 【go/src/net/http/transport.go】
pconn, err := t.getConn(treq, cm)
这个getConn
方法中,会将连接包装成wantConn
结构体
w := &wantConn{
cm: cm,
key: cm.key(),
ctx: ctx,
ready: make(chan struct{}, 1),
beforeDial: testHookPrePendingDial,
afterDial: testHookPostPendingDial,
}
这里面有一个重要的字段ready
, 这是一个golang channel,用于通知getConn
协程,告诉getConn
已经有一个连接准备就绪,你可以获取连接了
// 【go/src/net/http/transport.go】 getConn方法
case <-w.ready:
// 省略代码...
return w.pc, w.err
哪些场景代表这个w.ready
有事件了呢?
- 一个新连接被创建出来,准备就绪了
- 一个请求完成,需要将连接要放到连接池,也会优先传递给正在等待使用的客户端
2.2.1 从连接池获取连接
我们先看一下主逻辑,这里只考虑连接池中还有空闲的连接的情况
Transport.queueForIdleConn
中获取连接
// 【go/src/net/http/transport.go】 queueForIdleConn方法
// 1. 从map中获取当前server的连接池, 然后从连接池中获取连接
if list, ok := t.idleConn[w.key]; ok {
// ...
// 连接池中有数据
for len(list) > 0 && !stop {
// 这里可以看到拿的是数组最后的一个元素
pconn := list[len(list)-1]
// 如果连接可用的话,
// 会将连接放到wantConn,并且关闭ready,getConn协程会收到通知,获取连接
delivered = w.tryDeliver(pconn, nil)
}
}
★什么样的连接表示可用呢?
”
- 存活时间没有超过IdleConnTimeout
- 底层persistConn没有被关闭
【tryDeliver】逻辑
★tryDeliver其实很简单,就是将连接给到
”wantConn
,并且关闭w.ready
channel,用于通知getConn去拿连接
func (w *wantConn) tryDeliver(pc *persistConn, err error) bool {
w.mu.Lock()
defer w.mu.Unlock()
if w.pc != nil || w.err != nil {
return false
}
w.pc = pc
w.err = err
if w.pc == nil && w.err == nil {
panic("net/http: internal error: misuse of tryDeliver")
}
close(w.ready)
return true
}
2.2.2 连接池为空如何处理
如果连接池空了,那么就没办法从连接池中获取连接,这是时候只能去创建一个新的连接,这里暂时不考虑达到最大连接数的情况
//【go/src/net/http/transport.go】 getConn方法
// 这里会去创建一个新的连接
t.queueForDial(w)
在t.queueForDial方法中会开一个协程去创建一个连接
go t.dialConnFor(w)
func (t *Transport) dialConnFor(w *wantConn) {
// 创建连接
pc, err := t.dialConn(w.ctx, w.cm)
// 这里就和从连接池中获取连接一样了,通知getConn获取连接
delivered := w.tryDeliver(pc, err)
}
2.2.3 超过最大连接数如何处理
在dialConnFor中会判断连接的数量是否超过MaxConnsPerHost的限制,如果超过了,则不是创建新连接
func (t *Transport) queueForDial(w *wantConn) {
if t.MaxConnsPerHost <= 0 {
// 省略创建新连接代码
}
t.connsPerHostMu.Lock()
defer t.connsPerHostMu.Unlock()
if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
// 省略创建新连接代码
}
// 会将当前key放入等待队列中,当有人释放连接之后,会唤醒getConn获取连接
q := t.connsPerHostWait[w.key]
q.cleanFront()
q.pushBack(w)
t.connsPerHostWait[w.key] = q
}
那么不创建新连接,那要怎么办呢?我们再回到getConn
方法中, 这里会有一个select在等待连接创建(w.ready),或者等到超时(req.Context().Done()),或者等待客户端取消请求件(req.Cancel)
select {
case <-w.ready:
case <-req.Cancel:
case <-req.Context().Done():
case err := <-cancelc:
}
2.3 连接归还
当客户端请求完成之后,会将连接返回给连接池,返回连接池会出现如下几种情况
-
有客户端在阻塞等待连接释放(w.ready),会尝试将连接优先给到正在等待的客户端
-
没有客户端在等待连接,在优先考虑将连接方法连接池中
-
连接池满了,也没有客户端在阻塞等待连接,此时可以将连接关闭了(TCP四次挥手)
func (t *Transport) putOrCloseIdleConn(pconn *persistConn) {
// 如果无法放到连接池,或者连接没办法被复用,则关闭连接
if err := t.tryPutIdleConn(pconn); err != nil {
pconn.close(err)
}
}
将连接发送给正在等待的客户端
// 【tryPutIdleConn】
// 根据key查找是否有正在等待的客户端
if q, ok := t.idleConnWait[key]; ok {
if pconn.alt == nil {
for q.len() > 0 {
// 从队列的头部获取一个wantConn
w := q.popFront()
// 将准备好的连接发送给等待的客户端
if w.tryDeliver(pconn, nil) {
done = true
break
}
}
}
}
// 【tryPutIdleConn】
idles := t.idleConn[key]
// 连接池满了,直接返回错误
if len(idles) >= t.maxIdleConnsPerHost() {
return errTooManyIdleHost
}
// 连接池还没满,则将连接返回给连接池
t.idleConn[key] = append(idles, pconn)
3. 其他细节
MaxIdleConns
是如何管理所有的连接的?
每一个server对应一个连接池,然后MaxIdleConns
是管理所有server的连接池的,如果超过了,也是需要移除的
// 【tryPutIdleConn】
t.idleConn[key] = append(idles, pconn)
// 把连接放到lru缓存
t.idleLRU.add(pconn)
// 如果发现总连接t.idleLRU.len() > t.MaxIdleConns, 此时会移除LRU中最后那个元素
if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns {
oldest := t.idleLRU.removeOldest()
oldest.close(errTooManyIdle)
// 移除连接
// 1. 从idleLRU缓存中移除
// 2. 从idleConn[key]移除对应server的连接
t.removeIdleConnLocked(oldest)
}
4. 总结
连接池.PNG
关于 【连接获取】 和 【连接归还】 的逻辑,可以通过上面的一张图总结
我们回到开始的问题
- 创建的连接应该放在哪里?数组、链表、channel
★Golang的连接池大部分都会放到slice中(http连接池、数据库连接池),然后使用互斥锁保证其并发安全,像mongoDB的连接池是使用链表实现的。当然如果想要使用channel实现,也是可以的
”
- 为什么官方都不基于sync.Pool来实现连接池呢?
★”
sync.Pool没有固定大小,连接池需要
会被垃圾回收清理,清理的时候没有任何通知。连接池不能由垃圾回收管理,而应该由用户明确回收和管理
sync.Pool更多被使用在对象池、内存池的管理
本篇文章,主要是基于Golang http1.1连接池的源码分析了,连接池的设计原理。但是本文更注重关键源码以及关键原理的解读,如果读者想要看懂本篇文章,还是需要对照相关代码进行对照阅读的。下一篇,我将手把手写一个连接池。
5. 扩展
-
如果看懂了这篇文档,那么你可以尝试看一下database/sql中连接池的设计与实现么?
-
更进一步设计属于自己的一个连接池