掘金 后端 ( ) • 2024-05-17 10:12

0. 前言

连接池是一个非常重要的开发思想,如http client会构建连接池复用底层TCP连接,使用database/sql的使用也会有连接池的配置。那么代码底层是如何实现连接池的呢?这篇文档将以Golang语言为基础,分析http1.1连接池底层实现

注意:我们仅仅关注连接池设计思想、以及关键源码解读,并不会涉及太多的细节,如果想要了解更多的细节,需要读者自己阅读源码

在开始之前,我们思考如下几个问题

  1. 创建的连接应该放在哪里?数组、链表、channel
  2. 为什么官方都不基于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:
    }
}

先思考如下几个问题,等我们分析完成所有逻辑之后,再来看看这几个问题

  1. 如果连接池中不存在连接应该如何处理?
  2. 如果达到最大连接数超过了MaxConnsPerHost怎么办?
  3. 连接用完之后,放回到连接池,发现连接池满了,或者达到最大连接了,如何处理?

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有事件了呢?

  1. 一个新连接被创建出来,准备就绪了
  2. 一个请求完成,需要将连接要放到连接池,也会优先传递给正在等待使用的客户端

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)
    }
}

什么样的连接表示可用呢?

  1. 存活时间没有超过IdleConnTimeout
  2. 底层persistConn没有被关闭

【tryDeliver】逻辑

tryDeliver其实很简单,就是将连接给到wantConn,并且关闭w.readychannel,用于通知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 连接归还

当客户端请求完成之后,会将连接返回给连接池,返回连接池会出现如下几种情况

  1. 有客户端在阻塞等待连接释放(w.ready),会尝试将连接优先给到正在等待的客户端

  2. 没有客户端在等待连接,在优先考虑将连接方法连接池中

  3. 连接池满了,也没有客户端在阻塞等待连接,此时可以将连接关闭了(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

关于 【连接获取】【连接归还】 的逻辑,可以通过上面的一张图总结

我们回到开始的问题

  1. 创建的连接应该放在哪里?数组、链表、channel

Golang的连接池大部分都会放到slice中(http连接池、数据库连接池),然后使用互斥锁保证其并发安全,像mongoDB的连接池是使用链表实现的。当然如果想要使用channel实现,也是可以的

  1. 为什么官方都不基于sync.Pool来实现连接池呢?
  1. sync.Pool没有固定大小,连接池需要

  2. 会被垃圾回收清理,清理的时候没有任何通知。连接池不能由垃圾回收管理,而应该由用户明确回收和管理

  3. sync.Pool更多被使用在对象池、内存池的管理

本篇文章,主要是基于Golang http1.1连接池的源码分析了,连接池的设计原理。但是本文更注重关键源码以及关键原理的解读,如果读者想要看懂本篇文章,还是需要对照相关代码进行对照阅读的。下一篇,我将手把手写一个连接池。

5. 扩展

  • 如果看懂了这篇文档,那么你可以尝试看一下database/sql中连接池的设计与实现么?

  • 更进一步设计属于自己的一个连接池