连接池在开发中很常见,顾名思义连接池用于管理连接,避免应用程序自身反复从数据库获取又很快释放连接以提升程序效率。典型的连接池包括 http 连接池,db 连接池以及 redis 连接池,不同连接池的实现策略会略有区别。本文记录一次测试环境的服务容器中产生大量异常 TIME_WAIT 连接的排查和分析过程。

问题背景

在联调测试中发现向服务 A 下发的任务都执行超时,查询任务详情发现节点一直处于 running 状态,但实际上节点里的任务比较简单且应该很快就可执行完毕。进一步查看 B 的日志发现,服务 B 在将任务执行结果上报给服务 A 时出现了大量的连接超时的错误。该错误之前遇到过,但由于出现频率较低就没有过多注意,未想到这次每个任务回调都出现这个错误。

net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting head

问题根因

经排查发现:

  • 一方面连接池的 maxIdleConn 设置过小,且同 maxOpenConn 相差过大。
  • 另一方面,程序中会默认创建 50 个 worker 用于执行(调度)节点任务,每个 worker 在执行完操作后,会尝试归还连接,但由于此时程序打开的连接数据远大于允许闲置的连接数 maxIdleConn,导致几乎所有 worker 都不会将连接放入 idle 队列,而是直接主动关闭连接。
  • 考虑到大量连接几乎同时关闭,且 worker 也不断申请连接,然后又很快关闭连接,导致 mysql 来不及响应连接的关闭,就导致堆积了大量的 TIME_WAIT 状态的连接。
  • 当处于 TIME_WAIT 连接数目过大,会影响进程创建新的 http 连接,因此就出现大量的 http 连接超时。

最佳实践

在配置 golang 的数据库连接池的参数时,需注意 maxOpenConn 和 maxIdleConn 的取值。一般而言:

  • 不要采用默认值,默认取值并不适用于一般应用;
  • maxIdleConn 的数量和程序中创建的涉及同数据库交互的常驻的 goroutine 数目大致保持一致;
  • 且 maxOpenConn 和 maxIdleConn 取值不要相差太大。

排查过程

当在服务 B 的日志发现 http 连接超时的日志时,首先确认服务 B 的 http client 的超时参数是否设置过小,但检查发现参数设置的是 20s,20s 是一个足够长的超时设置。若服务 A 在 20s 内都未响应,是否是服务 A 的容器过载了?随即观察服务 A 容器的 cpu 和内存使用情况,虽然 cpu 偶尔会出现较大波动,但总体远未达到 limit 设置,因此也排除了容器过载的情况。

想到之前有出现过因为机器上 CLOSE_WAIT 连接过多导致进程无法响应的情况,随即检查容器连接情况,结果发现大量处于 TIME_WAIT 状态的连接(1000多个),且这些连接的对端接口刚好是 DB 端口,因此可初步判定程序同数据库交互出了问题。对于 TIME_WAIT 连接,肯定是当前程序主动关闭了连接,并等待 2MSL(60s)以让服务器收到连接最后的关闭确认。那为什么程序会主动关闭这么多数据库连接呢?程序同数据库交互是通过连接池来管理连接,一般而言用完的连接会归还连接池,不会直接关闭而是由连接池来管理连接何时回收。这是我们对连接池原理的经验认知,但后来发现 golang database/sql 实现的连接池原理并非如此,我们忽略了连接池的最大允许打开的连接数 maxOpenConn 和 最大允许闲置的连接数 maxIdleConn 的真正含义。

我们的程序 model 层使用 gorm 作为 orm 工具,我们先看获取连接的过程。

连接获取过程

每一次的同数据库的交互的事务操作,都是通过 gorm.DB.Begin()来开启事务的。

func (m *manager) UpdateXxxx(x *model.Xxxx) error {
  tx := m.mysqlClient.Begin()
  var err error
  defer func() {
    if err != nil {
      tx.Rollback()
    }
  }()
  // ...
  return tx.Commit().Error
}

跟踪下去,它通过传递一个 context 和一个 TxOptions 来调用gorm.DB.BeginTx():

func (s *DB) Begin() *DB {
  return s.BeginTx(context.Background(), &sql.TxOptions{})
}

接下来继续调用 gorm.sqlDb.BeginTx(),而 sqlDb 的底层实现是 golang 标准库中的 database 包中的 sql.DB。

func (s *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) *DB {
  c := s.clone()
  if db, ok := c.db.(sqlDb); ok && db != nil {
    tx, err := db.BeginTx(ctx, opts)
    c.db = interface{}(tx).(SQLCommon)
​
    c.dialect.SetDB(c.db)
    c.AddError(err)
  } else {
    c.AddError(ErrCantStartTransaction)
  }
  return c
}
driver.ErrBadConndrvier.ErrBadConn
func (db *DB) BeginTx(ctx context.Context, opts *TxOptions) (*Tx, error) {
  var tx *Tx
  var err error
  for i := 0; i < maxBadConnRetries; i++ {
    tx, err = db.begin(ctx, opts, cachedOrNewConn)
    if err != driver.ErrBadConn {
      break
    }
  }
  if err == driver.ErrBadConn {
    return db.begin(ctx, opts, alwaysNewConn)
  }
  return tx, err
}
​
func (db *DB) begin(ctx context.Context, opts *TxOptions, strategy connReuseStrategy) (tx *Tx, err error) {
  dc, err := db.conn(ctx, strategy)
  if err != nil {
    return nil, err
  }
  return db.beginDC(ctx, dc, dc.releaseConn, opts)
}

获取连接的逻辑在方法sql.DB.conn中,根据当前连接获取策略、空闲连接队列以及当前已打开连接数来决定连接如何获取。简单而言,三种途径可获取连接:

sql.DB.connRequests
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
  // 保证数据库未关闭
  db.mu.Lock()
  if db.closed {
    db.mu.Unlock()
    return nil, errDBClosed
  }
  // 设置连接的最大生存时间
  lifetime := db.maxLifetime
​
  // 1. 若获取连接策略为 cachedOrNewConn,并且当前空闲连接队列不为空,则从空闲连接队列中获取连接。注意连接的有效性。
  numFree := len(db.freeConn)
  if strategy == cachedOrNewConn && numFree > 0 {
    conn := db.freeConn[0]
    copy(db.freeConn, db.freeConn[1:])
    db.freeConn = db.freeConn[:numFree-1]
    conn.inUse = true
    if conn.expired(lifetime) {
      db.maxLifetimeClosed++
      db.mu.Unlock()
      conn.Close()
      return nil, driver.ErrBadConn
    }
    db.mu.Unlock()
  // ...
    return conn, nil
  }
​
  // 2. 若当前打开的连接数已超 maxOpenConn,需等待直至别人归还连接
  if db.maxOpen > 0 && db.numOpen >= db.maxOpen {
    // Make the connRequest channel. It's buffered so that the
    // connectionOpener doesn't block while waiting for the req to be read.
    req := make(chan connRequest, 1)
    reqKey := db.nextRequestKeyLocked()
    db.connRequests[reqKey] = req
    db.waitCount++
    db.mu.Unlock()
​
    waitStart := nowFunc()
​
    // Timeout the connection request with the context.
    select {
    case <-ctx.Done():
      // ...
      select {
      default:
      case ret, ok := <-req: // 别人连接已使用完毕
        if ok && ret.conn != nil {
          db.putConn(ret.conn, ret.err, false)
        }
      }
      return nil, ctx.Err()
    case ret, ok := <-req:  // 别人连接已使用完毕
      atomic.AddInt64(&db.waitDuration, int64(time.Since(waitStart)))
​
      if !ok {
        return nil, errDBClosed
      }
      // 连接失效
      if strategy == cachedOrNewConn && ret.err == nil && ret.conn.expired(lifetime) {
        db.mu.Lock()
        db.maxLifetimeClosed++
        db.mu.Unlock()
        ret.conn.Close()
        return nil, driver.ErrBadConn
      }
      if ret.conn == nil {
        return nil, ret.err
      }
​
      // Reset the session if required.
      if err := ret.conn.resetSession(ctx); err == driver.ErrBadConn {
        ret.conn.Close()
        return nil, driver.ErrBadConn
      }
      // 返回连接
      return ret.conn, ret.err
    }
  }
​
  // 3. 只能创建新的连接,注意记录目前打开的连接数。
  db.numOpen++ 
  db.mu.Unlock()
  ci, err := db.connector.Connect(ctx)
  if err != nil {
    db.mu.Lock()
    db.numOpen-- // correct for earlier optimism
    db.maybeOpenNewConnections()
    db.mu.Unlock()
    return nil, err
  }
  db.mu.Lock()
  dc := &driverConn{
    db:         db,
    createdAt:  nowFunc(),
    returnedAt: nowFunc(),
    ci:         ci,
    inUse:      true,
  }
  db.addDepLocked(dc, dc)
  db.mu.Unlock()
  return dc, nil
}
release func(error)
func (db *DB) beginDC(ctx context.Context, dc *driverConn, release func(error), opts *TxOptions) (tx *Tx, err error) {
  var txi driver.Tx
  // ...
  ctx, cancel := context.WithCancel(ctx)
  tx = &Tx{
    db:                 db,
    dc:                 dc,
    releaseConn:        release,
    txi:                txi,
    cancel:             cancel,
    keepConnOnRollback: keepConnOnRollback,
    ctx:                ctx,
  }
  go tx.awaitDone()
  return tx, nil
}

连接释放过程

我们再看释放连接过程。释放连接会调用 sql.dc.releaseConn方法,它通过调用sql.DB.putConn方法来返回连接。

func (dc *driverConn) releaseConn(err error) {
  dc.db.putConn(dc, err, true)
}

在sql.DB.putConn方法中会调用sql.DB.putConnDBLocked方法以尝试把连接归还给连接池。

func (db *DB) putConn(dc *driverConn, err error, resetSession bool) {
  // ...
  // 尝试把连接归还给连接池
  added := db.putConnDBLocked(dc, nil)
  db.mu.Unlock()
  if !added { // 不应该归还连接,则直接关闭连接
    dc.Close()
    return
  }
}

在尝试归还连接到连接池时,归还逻辑主要包含四种情况:

  • 其一,若发现当前打开连接数已大于配置的 maxOpenConn,则直接关闭连接;
  • 其次,再观察是否有人正等待获取连接,若确实如此,则直接将连接通过等待者创建的 channel 传递过去,此时连接不需要关闭;
  • 再次,若发现当前空闲连接数小于允许的最大空闲连接数,则将连接放回至空闲队列,此时连接也不需要关闭;
  • 最后,即不满足上述三种情况,则需要将连接直接关闭。
func (db *DB) putConnDBLocked(dc *driverConn, err error) bool {
  if db.closed { // 数据库已关闭,则需要直接关闭连接
    return false
  }
  if db.maxOpen > 0 && db.numOpen > db.maxOpen { // 当前打开的连接数大于允许打开的最大连接数,则需要直接关闭连接
    return false
  }
  if c := len(db.connRequests); c > 0 { // 当前有人等待获取连接,则直接将连接传递给它,因此该连接不需要关闭
    var req chan connRequest
    var reqKey uint64
    for reqKey, req = range db.connRequests {
      break
    }
    delete(db.connRequests, reqKey) // Remove from pending requests.
    if err == nil {
      dc.inUse = true
    }
    req <- connRequest{
      conn: dc,
      err:  err,
    }
    return true
  } else if err == nil && !db.closed {
     // 当前的空闲连接数小于允许的最大空闲连接数,则将连接放回至空闲队列,因此不需要关闭连接
    if db.maxIdleConnsLocked() > len(db.freeConn) {
      db.freeConn = append(db.freeConn, dc)
      db.startCleanerLocked()
      return true
    }
    db.maxIdleClosed++
  }
  // 否则,当前连接需要被直接关闭
  return false
}

至此,我们可以发现,当连接使用完成后,且若我们设置的最大空闲连接数 maxIdleConn 相对于设置的最大打开连接数 maxOpenConn 较小时,该连接会被直接关闭,而不会再被连接池管理缓存起来。更糟糕的是,若二者取值相差过大,则每次连接被全量申请(打开)然后使用完毕后,会造成大量连接被关闭,此时,实际上连接池已经失去了缓存连接的作用。值得一提的是,golang database/sql 实现的连接池同 go-redis 包中实现的 redis 连接池所采用的策略不同,go-redis 设计的连接池在连接池初始化的时候就会往连接池填满空闲的连接,然后开一个协程在后台定时回收过期不可用的空闲连接。而且其是通过 poolSize 参数来统一控制连接池的容量,并没有设计所谓的 maxOpenConn 和 maxIdleConn 参数。