trpc.group/trpc-go/trpc-go@v1.0.3/pool/connpool/README.zh_CN.md (about) 1 [English](README.md) | 中文 2 3 ## 背景 4 5 客户端请求服务端,如果是以 tcp 协议进行通信的话,需要考虑三次握手建立连接的开销。通常情况下 tcp 通信模式会预先建立好连接,或者发起请求时建立好连接,这里的连接用完之后不会直接 close 掉,而是会被后续复用。 6 连接池就是为了实现此功能而进行的一定程度的封装。 7 8 ## 原理 9 10 pool 维护一个 sync.Map 作为连接池,key 为<network, address, protocol>编码,value 为与目标地址建立的连接构成的 ConnectionPool, 其内部以一个链表维护空闲连接。在短连接模式中,transport 层会在 rpc 调用后关闭连接,而在连接池模式中,会把使用完的连接放回连接池,以待下次需要时取出。 11 为实现上述目的,连接池需要具备以下功能: 12 - 提供可用连接,包括创建新连接和复用空闲连接; 13 - 回收上层使用过的连接作为空闲连接管理; 14 - 对连接池中空闲连接的管理能力,包括复用连接的选择策略,空闲连接的健康监测等; 15 - 根据用户配置调整连接池运行参数。 16 17 ## 设计实现 18 19 连接池的整体代码结构如下图所示: 20 ![design_implementation](/.resources/pool/connpool/design_implementation.png) 21 22 ### 初始化连接池 23 24 `NewConnectionPool` 创建一个连接池,支持传入 Option 修改参数,不传则使用默认值初始化。Dial 是默认的创建连接方式,每个 ConnectionPool 会根据自己的 GetOptions 生成 DialOptions, 来建立对应目标的连接。 25 26 ```go 27 func NewConnectionPool(opt ...Option) Pool { 28 opts := &Options{ 29 MaxIdle: defaultMaxIdle, 30 IdleTimeout: defaultIdleTimeout, 31 DialTimeout: defaultDialTimeout, 32 Dial: Dial, 33 } 34 for _, o := range opt { 35 o(opts) 36 } 37 return &pool{ 38 opts: opts, 39 connectionPools: new(sync.Map), 40 } 41 } 42 ``` 43 44 ### 获取连接 45 46 通过 pool.Get 可以获取一个连接,参考 client_transport_tcp.go 的实现。 47 48 ```go 49 // Get 50 getOpts := connpool.NewGetOptions() 51 getOpts.WithContext(ctx) 52 getOpts.WithFramerBuilder(opts.FramerBuilder) 53 getOpts.WithDialTLS(opts.TLSCertFile, opts.TLSKeyFile, opts.CACertFile, opts.TLSServerName) 54 getOpts.WithLocalAddr(opts.LocalAddr) 55 getOpts.WithDialTimeout(opts.DialTimeout) 56 getOpts.WithProtocol(opts.Protocol) 57 conn, err = opts.Pool.Get(opts.Network, opts.Address, getOpts) 58 ``` 59 60 ConnPool 对外仅暴露 Get 接口,确保连接池状态不会因用户的误操作被破坏。 61 62 `Get` 会根据 <network, address, protocol> 获取 ConnectionPool, 如果获取失败需要首先创建,这里做了并发控制,防止 ConnectionPool 被重复建立,核心代码如下所示: 63 64 ```go 65 func (p *pool) Get(network string, address string, opts GetOptions) (net.Conn, error) { 66 // ... 67 key := getNodeKey(network, address, opts.Protocol) 68 if v, ok := p.connectionPools.Load(key); ok { 69 return v.(*ConnectionPool).Get(ctx) 70 } 71 // create newPool... 72 v, ok := p.connectionPools.LoadOrStore(key, newPool) 73 if !ok { 74 // init newPool... 75 return newPool.Get(ctx) 76 } 77 return v.(*ConnectionPool).Get(ctx) 78 } 79 ``` 80 81 获取到 ConnectionPool 后,尝试获取连接。首先需要获取 token, token 是一个用于并发控制的 ch, 其缓冲长度根据 MaxActive 设置,代表用户可以同时使用 MaxActive 个连接,当活跃连接被归还连接池或关闭时,归还 token. 如果设置 `Wait=True`, 会在获取不到 token 时等待直到超时返回,如果设置 `Wait=False`, 会在获取不到 token 时直接返回 `ErrPoolLimit`。 82 83 ```go 84 func (p *ConnectionPool) getToken(ctx context.Context) error { 85 if p.MaxActive <= 0 { 86 return nil 87 } 88 89 if p.Wait { 90 select { 91 case p.token <- struct{}{}: 92 return nil 93 case <-ctx.Done(): 94 return ctx.Err() 95 } 96 } else { 97 select { 98 case p.token <- struct{}{}: 99 return nil 100 default: 101 return ErrPoolLimit 102 } 103 } 104 } 105 106 func (p *ConnectionPool) freeToken() { 107 if p.MaxActive <= 0 { 108 return 109 } 110 <-p.token 111 } 112 ``` 113 114 成功获取 token 后,优先从 idle list 中获取空闲连接,如果失败则新创建连接返回。 115 116 ### 初始化 ConnectionPool 117 118 在 Get 时要进行 ConnectionPool 的初始化,主要分为启动检查协程和根据 MinIdle 预热空闲连接。 119 120 #### KeepMinIdles 121 122 业务的突发流量可能会导致大量新连接建立,创建连接是一个比较耗时的操作,可能导致请求超时。提前创建部分空闲连接可以起到预热效果。连接池在创建时创建 MinIdle 个连接备用。 123 124 #### 检查协程 125 126 ConnectionPool 周期性的进行以下检查: 127 128 - 空闲连接健康检查 129 默认健康检查策略如下图所示,健康检查扫描 idle 链表,如果未通过安全检查则将连接直接关闭,首先检查连接是否正常,然后检查是否到达 IdleTimeout 和 MaxConnLifetime. 可以使用 WithHealthChecker 自定义健康检查策略。 130 除周期性的检查空闲连接,在每次从 idle list 获取空闲连接是都会检查,此时将 isFast 设为 true, 只进行连接存活确认: 131 ```go 132 func (p *ConnectionPool) defaultChecker(pc *PoolConn, isFast bool) bool { 133 if pc.isRemoteError(isFast) { 134 return false 135 } 136 if isFast { 137 return true 138 } 139 if p.IdleTimeout > 0 && pc.t.Add(p.IdleTimeout).Before(time.Now()) { 140 return false 141 } 142 if p.MaxConnLifetime > 0 && pc.created.Add(p.MaxConnLifetime).Before(time.Now()) { 143 return false 144 } 145 return true 146 } 147 ``` 148 连接池检测连接空闲的时间,通常也要做成可配置化的,目的是为了与 server 端配合(尤其要考虑不同框架的场景),如果配合的不好,也会出问题。比如 pool 空闲连接检测时间是 1min,server 也是 1min,可能会存在这样的情景,就是 server 端密集关闭空闲连接的时候,client 端还没检测到,发送数据的时候发现大量失败,而不得不通过上层重试解决。比较好的做法是,server 空闲连接检测时长设置为 pool 空闲连接检测时长大一些,尽量让 client 端主动关闭连接,避免取出的连接被 server 关闭而不自知。 149 150 > 这里其实也有种优化的思路,就是在每次取出一个连接的时候,通过系统调用非阻塞 read 一下,其实是可以判断出连接是否已经对端关闭的,在 Unix/Linux 平台下可用,但是在 windows 平台下遇到点问题,所以 tRPC-Go 中删除了这一个优化点。 151 152 - 空闲连接数量检查 153 同 KeepMinIdles, 周期性的将空闲连接数补充到 MinIdle 个。 154 - ConnectionPool 空闲检查 155 transport 不会主动关闭 ConnectionPool, 会导致后台检查协程空转。通过设置 poolIdleTimeout, 周期性检查在此时间内用户使用连接数为 0, 来保证长时间未使用的 ConnectionPool 自动关闭。 156 157 ## 连接的生命周期 158 159 MinIdle 是 ConnectionPool 维持的最小空闲连接,在初始化和周期检查中进行补充。 160 用户获取连接时,首先从空闲连接中获取,若没有空闲连接才会重新创建。当用户完成请求后,将连接归还给 ConnectionPool, 此时有三种可能: 161 - 当空闲连接超过 MaxIdle 时,根据淘汰策略关闭一个空闲连接; 162 - 当连接池的 forceClose 设置为 true 时,不归还 ConnectionPool, 直接关闭; 163 - 加入空闲连接链表。 164 165 用户使用连接发生读写错误时,将直接关闭连接。检查连接存活失败后,也会直接关闭: 166 ![life_cycle](/.resources/pool/connpool/life_cycle.png) 167 168 ## 空闲连接管理策略 169 170 连接池有 FIFO 和 LIFO 两种策略进行空闲连接的选择和淘汰,通过 PushIdleConnToTail 控制,应该根据业务的实际特点选择合适的管理策略。 171 172 - fifo,保证各个连接均匀使用,但是当调用方请求频率不高,但是恰巧每次能在连接空闲条件命中之前来一个请求,就会导致各个连接无法被释放,此时维持这么多的连接数是多余的。 173 - lifo, 优先采用栈顶连接,栈底连接不频繁使用会优先淘汰。 174 175 ```go 176 func (p *ConnectionPool) addIdleConn(ctx context.Context) error { 177 c, _ := p.dial(ctx) 178 pc := p.newPoolConn(c) 179 if !p.PushIdleConnToTail { 180 p.idle.pushHead(pc) 181 } else { 182 p.idle.pushTail(pc) 183 } 184 } 185 186 func (p *ConnectionPool) getIdleConn() *PoolConn { 187 for p.idle.head != nil { 188 pc := p.idle.head 189 p.idle.popHead() 190 // ... 191 } 192 } 193 194 func (p *ConnectionPool) put(pc *PoolConn, forceClose bool) error { 195 if !p.closed && !forceClose { 196 if !p.PushIdleConnToTail { 197 p.idle.pushHead(pc) 198 } else { 199 p.idle.pushTail(pc) 200 } 201 if p.idleSize >= p.MaxIdle { 202 pc = p.idle.tail 203 p.idle.popTail() 204 } 205 } 206 } 207 ```