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  ```