こんにちは、Control Plane部認証認可グループの平岩です。私たち認証認可グループではバックエンドAPIにGoを多く採用しています。共通基盤である特性上高RPSに耐えられる必要があり、また安定して低いレイテンシでリクエストを処理することが求められます。本記事ではGoの標準パッケージである sql.DB の内部実装を、 ソースコード (Go 1.26時点)を読みながら解説します。 はじめに sql.DB とは何か 内部実装の詳解 DB 構造体の主要フィールド クエリ実行の全体像 BadConn リトライの仕組み コネクションの取得: conn メソッド フェーズ1: idleコネクションがあれば再利用する フェーズ2: 最大接続数に達している場合の待機 フェーズ3: 新規接続の作成 コネクションの返却: putConn メソッド 非同期なコネクション作成: connectionOpener トランザクションとコネクション コネクションの定期削除: connectionCleaner DBStats: プールの状態を観測する コネクションプールの設定 4つの設定パラメータ デフォルト値の問題 各パラメータの解説 SetMaxOpenConns SetMaxIdleConns SetConnMaxLifetime SetConnMaxIdleTime まとめ はじめに GoでRDBを使うとき、何かしらのO/R mapperやライブラリを使うことが多いかと思います。ですが、内部的には標準パッケージの sql.DB が使われています。この sql.DB はコネクションプールの役割を持ち、4つの設定パラメータが存在します。これらの値をデフォルトのまま使うと本番環境でエラーやパフォーマンス問題を引き起こすことがあります。また、内部でリトライが行われるケースがあるなど隠蔽されている挙動も存在します。 本記事では前半で sql.DB の内部実装をソースコードを追いながら解説し、後半でそれを踏まえた設定の考え方を述べます。設定についてだけ知りたい方は コネクションプールの設定 まで読み飛ばしてください。 sql.DB とは何か sql.DB の実態はコネクションプールであり、複数のgoroutineから安全に使えます(goroutine safe)。 sql.Open は引数の検証のみを行い、この時点ではデータベースへのコネクションを作成しません。実際にコネクションが作れることを確認するには (*sql.DB).PingContext を使います(詳細は Opening a database handle - The Go Programming Language を参照してください)。 内部実装の詳解 DB 構造体の主要フィールド ここからは sql.DB の実装を見ていきます。以降のコードを読むにあたって、 DB 構造体 の以下の4つのフィールドを把握しておけば十分です。 freeConn []*driverConn : idleコネクションのスライス。プールに戻った時刻 returnedAt の古い順になる connRequests connRequestSet : コネクションを待っているgoroutineの集合 connRequestSet はSetと言っているが実体はindexでの参照を付けたスライス numOpen int : 使用中とidleの両方を含む、現在openなコネクション数 cleanerCh chan struct{} : プールにあるコネクションを閉じるgoroutineへの通知用のchannel すべてのフィールドへのアクセスは mu sync.Mutex で保護されています。 クエリ実行の全体像 まず、 QueryContext を例として全体像を示します。他の QueryRowContext ExecContext PingContext も基本的に同じ流れですが、 QueryContext のみが Rows.Close() を明示的に呼んでコネクションを返却する責任がuser側にあります。 flowchart TD A["(*sql.DB).QueryContext"] subgraph retryScope ["retryの範囲(ErrBadConnの場合、最大3回リトライされる)"] B["(*sql.DB).retry"] Q["(*sql.DB).query"] C["(*sql.DB).conn"] E["(*sql.DB).queryDC"] end A --> B B --> Q Q --> C C -- エラー --> ERR[error を返す] C -- 成功 --> E E -- ErrBadConn --> B E -- エラー --> ERR E -- 成功 --> F[Rows を返す<br>コネクション所有権が Rows に移動] subgraph userCode ["user"] K["(*sql.Rows).Next() で最後まで読むか、(*sql.Rows).Close()を呼ぶ"] end F --> K K --> G["(*sql.DB).putConn"] BadConn リトライの仕組み sql.DB には無効な接続に当たった場合に自動でリトライする仕組みがあります。 このリトライのトリガーとなるのが driver.ErrBadConn です。 godoc には次のように書かれています。 ErrBadConn should be returned by a driver to signal to the sql package that a driver.Conn is in a bad state (such as the server having earlier closed the connection) and the sql package should retry on a new connection. To prevent duplicate operations, ErrBadConn should NOT be returned if there's a possibility that the database server might have performed the operation. つまり、 driver.ErrBadConn はコネクションがDBサーバ側から切断された場合などに返り、 sql package側でリトライすべきエラーであるということです。 実際にリトライを制御する DB.retry を見ていきます。 database/sql/sql.go L1572-1584 : const maxBadConnRetries = 2 func (db *DB) retry(fn func (strategy connReuseStrategy) error ) error { for i := int64 ( 0 ); i < maxBadConnRetries; i++ { err := fn(cachedOrNewConn) // 👈 (1) // retry if err is driver.ErrBadConn if err == nil || !errors.Is(err, driver.ErrBadConn) { return err } } return fn(alwaysNewConn) // 👈 (2) } (1) 最大2回、 cachedOrNewConn のstrategyで試行します。このstrategyではまずidleコネクションを再利用しようとします。 (2) 2回とも driver.ErrBadConn だった場合、3回目は alwaysNewConn strategyで新規のコネクション作成を強制します。 コネクションの取得: conn メソッド retry から呼ばれる conn メソッドが、プール内のidleコネクションか新規の接続を返します。このメソッドはかなり長いため、全体を3つのフェーズに分けて読んでいきます。 flowchart TD A[connメソッド呼び出し] --> B{freeConnに<br>idleコネクションがある} B -- Yes --> C{コネクションが期限切れ} C -- No --> D[コネクションを再利用] C -- Yes --> E[driver.ErrBadConnを返す → リトライ] B -- No --> F{コネクション数が最大(maxOpen)に<br>達している} F -- Yes --> G[connRequestsで待機] F -- No --> H[新規接続を作成] フェーズ1: idleコネクションがあれば再利用する database/sql/sql.go L1331-1354 : // Prefer a free connection, if possible. last := len (db.freeConn) - 1 if strategy == cachedOrNewConn && last >= 0 { // Reuse the lowest idle time connection so we can close // connections which remain idle as soon as possible. conn := db.freeConn[last] // 👈 (1) db.freeConn = db.freeConn[:last] conn.inUse = true if conn.expired(lifetime) { // 👈 (2) db.maxLifetimeClosed++ db.mu.Unlock() conn.Close() return nil , driver.ErrBadConn } db.mu.Unlock() // Reset the session if required. if err := conn.resetSession(ctx); errors.Is(err, driver.ErrBadConn) { conn.Close() return nil , err } return conn, nil } (1) freeConn の末尾を取得しています(LIFO)。コメントにある通り、同時に必要とする数よりも多いコネクションがあった場合、余分なコネクションはidle状態のままcloseされていきます。 (2) 取得した接続が maxLifetime を超えていた場合は driver.ErrBadConn を返し、リトライの対象になります。 フェーズ2: 最大接続数に達している場合の待機 database/sql/sql.go L1358-1426 : if db.maxOpen > 0 && db.numOpen >= db.maxOpen { req := make ( chan connRequest, 1 ) delHandle := db.connRequests.Add(req) // 👈 (1) db.waitCount++ db.mu.Unlock() waitStart := nowFunc() // 👈 (2) select { case <-ctx.Done(): // 👈 (3) db.mu.Lock() deleted := db.connRequests.Delete(delHandle) db.mu.Unlock() db.waitDuration.Add( int64 (time.Since(waitStart))) if !deleted { 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: // 👈 (4) db.waitDuration.Add( 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 } if err := ret.conn.resetSession(ctx); errors.Is(err, driver.ErrBadConn) { ret.conn.Close() return nil , err } return ret.conn, ret.err } } (1) maxOpen に達している場合、 connRequests に待機リクエストを追加します。 (2) コネクション作成の待ち時間を計測するために現在時刻を変数に入れています。この nowFunc は var nowFunc = time.Now と定義されており、テスト時に現在時刻を取得する関数を上書きするためのパターンが使われています。 (3) context がキャンセルされた場合はリークを避けるため connRequests から削除して ctx.Err() を返しています。 (4) 他のgoroutineがコネクションを返却した場合、ここでコネクションを受け取ります。 フェーズ3: 新規接続の作成 database/sql/sql.go L1429-1449 : db.numOpen++ // optimistically // 👈 (1) db.mu.Unlock() ci, err := db.connector.Connect(ctx) if err != nil { db.mu.Lock() db.numOpen-- // correct for earlier optimism // 👈 (2) 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 (1) numOpen を楽観的に(optimistically)インクリメントしてからロックを解放し、接続を作成します。 (2) 接続作成に失敗した場合は numOpen を戻し、 maybeOpenNewConnections で待機中になっている数だけコネクションを作成しようとします。 コネクションの返却: putConn メソッド 接続の使用後は putConn を通じてプールに返却されます。このメソッドには戻り値がなく、引数で渡る dc *driverConn err error を直接変更しています。 database/sql/sql.go L1481-1531 : func (db *DB) putConn(dc *driverConn, err error , resetSession bool ) { if !errors.Is(err, driver.ErrBadConn) { if !dc.validateConnection(resetSession) { // 👈 (1) err = driver.ErrBadConn } } db.mu.Lock() if !dc.inUse { db.mu.Unlock() panic ( "sql: connection returned that was never out" ) } if !errors.Is(err, driver.ErrBadConn) && dc.expired(db.maxLifetime) { // 👈 (2) db.maxLifetimeClosed++ err = driver.ErrBadConn } dc.inUse = false dc.returnedAt = nowFunc() for _, fn := range dc.onPut { fn() } dc.onPut = nil if errors.Is(err, driver.ErrBadConn) { // 👈 (3) db.maybeOpenNewConnections() db.mu.Unlock() dc.Close() return } added := db.putConnDBLocked(dc, nil ) // 👈 (4) db.mu.Unlock() if !added { dc.Close() return } } (1) validateConnection は、ドライバが driver.Validator インタフェースを実装している場合に IsValid() を呼びます。 false が返ればコネクションは破棄されます。ドライバはクエリ結果のエラーをそのまま返しつつ、コネクション自体は破棄したい場合にこのインタフェースを使います。 (2) コネクションが maxLifetime を超過していたら err = driver.ErrBadConn とします。ここでreturnはしていませんが、 (3) の処理によってcloseされることになります。 (3) driver.ErrBadConn の場合、 maybeOpenNewConnections で待機リクエストに新規接続を用意してからcloseします。 (4) 正常なコネクションの場合、 putConnDBLocked で待機リクエストへの割り当てまたはidleコネクションのプールへの追加を試みます。 putConnDBLocked の実装も見てみます。 database/sql/sql.go L1542-1567 : 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 req, ok := db.connRequests.TakeRandom(); ok { // 👈 (1) 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) // 👈 (2) db.startCleanerLocked() return true } db.maxIdleClosed++ } return false // 👈 (3) } (1) 待機中の connRequest が1つでもあれば freeConn には追加せず、ランダムに1つ選んでコネクションを直接渡しています。 (2) 誰も待機中でなければidleコネクション( freeConn )の末尾に追加します。 (3) idleコネクション数が上限を超えている場合は false を返し、 putConn 側でコネクションがcloseされます。 非同期なコネクション作成: connectionOpener putConn や conn のコードに maybeOpenNewConnections の呼び出しがありました。この関数は待機中の connRequest の数だけ openerCh に通知します。 database/sql/sql.go L1230-1245 : func (db *DB) maybeOpenNewConnections() { numRequests := db.connRequests.Len() if db.maxOpen > 0 { numCanOpen := db.maxOpen - db.numOpen if numRequests > numCanOpen { numRequests = numCanOpen } } for numRequests > 0 { db.numOpen++ // optimistically // 👈 (1) numRequests-- if db.closed { return } db.openerCh <- struct {}{} } } (1) conn メソッドのフェーズ3と同じく、 numOpen を楽観的にインクリメントしてからシグナルを送ります。 openerCh を受け取るのは別goroutineで動いている connectionOpener です。このgoroutineは OpenDB の時点で起動されています。 database/sql/sql.go L1031-1046 : func OpenDB(c driver.Connector) *DB { ctx, cancel := context.WithCancel(context.Background()) db := &DB{ connector: c, openerCh: make ( chan struct {}, connectionRequestQueueSize), lastPut: make ( map [*driverConn] string ), stop: cancel, } go db.connectionOpener(ctx) // 👈 (1) return db } (1) connectionOpener は別のgoroutineで動いています。 openerCh からシグナルを受け取るたびに openNewConnection で接続を作成し、 putConnDBLocked で待機中の connRequest に渡します。 conn メソッドのフェーズ3では呼び出し元goroutineが直接 connector.Connect を呼んでいましたが、 maybeOpenNewConnections はこの専用goroutineが非同期に接続を作成します。 ErrBadConn で接続が壊れた場合や接続作成に失敗した場合に、待機中のgoroutineをブロックせずに新しい接続を用意するための仕組みです。 トランザクションとコネクション BeginTx はプールからコネクションを1つ取得し、 Tx に固定します。 トランザクション中の全操作はこの固定されたコネクションを使います。 flowchart TD A["(*sql.DB).BeginTx"] --> B["(*sql.DB).retry"] B --> C["(*sql.DB).conn: コネクション取得"] C --> D["BEGIN を発行し Tx を生成<br>コネクションを Tx.dc に固定"] D --> E["Tx 上の操作<br>tx.QueryContext / tx.ExecContext"] E --> F["grabConn: 固定されたコネクションを返す<br>(プールには行かない)"] F --> G["Commit() または Rollback()"] G --> H["tx.close → putConn"] 通常のクエリでは conn がプールからコネクションを取得しますが、 Tx 上の操作では grabConn が固定されたコネクション( Tx.dc )をそのまま返します。 プールへの問い合わせは発生しません。 Commit() または Rollback() が呼ばれると、内部の tx.close が releaseConn → putConn を呼び、コネクションをプールに返却します。 BeginTx で渡した context がキャンセルされた場合は、バックグラウンドの awaitDone goroutineが自動でロールバックしコネクションを返却します。 コネクションの定期削除: connectionCleaner 期限切れのidle接続を定期的にクリーンアップするためにバックグラウンドで動きます。 maxLifetime または maxIdleTime が設定されている場合のみ起動します。 database/sql/sql.go L1095-1136 : func (db *DB) connectionCleaner(d time.Duration) { const minInterval = time.Second // 👈 (1) if d < minInterval { d = minInterval } t := time.NewTimer(d) for { select { case <-t.C: case <-db.cleanerCh: // 👈 (2) } db.mu.Lock() d = db.shortestIdleTimeLocked() if db.closed || db.numOpen == 0 || d <= 0 { db.cleanerCh = nil db.mu.Unlock() return } d, closing := db.connectionCleanerRunLocked(d) // 👈 (3) db.mu.Unlock() for _, c := range closing { c.Close() } if d < minInterval { d = minInterval } if !t.Stop() { select { case <-t.C: default : } } t.Reset(d) } } (1) connectionCleaner は最短でも1秒間隔で動作します。 (2) 1秒経つか cleanerCh への通知でコネクション削除の処理が実行されます。 cleanerCh への送信は SetConnMaxLifetime SetConnMaxIdleTime が短くなるかDBがcloseされたときに発生します。 (3) この connectionCleanerRunLocked がcloseすべきコネクションのスライスを返します。実際の Close() はロック解放後に行っています。 database/sql/sql.go L1138-1189 : func (db *DB) connectionCleanerRunLocked(d time.Duration) (time.Duration, []*driverConn) { var idleClosing int64 var closing []*driverConn if db.maxIdleTime > 0 { // As freeConn is ordered by returnedAt process // in reverse order to minimise the work needed. idleSince := nowFunc().Add(-db.maxIdleTime) last := len (db.freeConn) - 1 for i := last; i >= 0 ; i-- { // 👈 (1) c := db.freeConn[i] if c.returnedAt.Before(idleSince) { i++ closing = db.freeConn[:i:i] db.freeConn = db.freeConn[i:] idleClosing = int64 ( len (closing)) db.maxIdleTimeClosed += idleClosing break } } // ... 次回チェック時刻の計算(省略) } if db.maxLifetime > 0 { expiredSince := nowFunc().Add(-db.maxLifetime) for i := 0 ; i < len (db.freeConn); i++ { // 👈 (2) c := db.freeConn[i] if c.createdAt.Before(expiredSince) { closing = append (closing, c) last := len (db.freeConn) - 1 // Use slow delete as order is required to ensure // connections are reused least idle time first. copy (db.freeConn[i:], db.freeConn[i+ 1 :]) // 👈 (3) db.freeConn[last] = nil db.freeConn = db.freeConn[:last] i-- } } db.maxLifetimeClosed += int64 ( len (closing)) - idleClosing } return d, closing } (1) 期限切れの(= maxIdleTime を超過した)コネクションを探します。 freeConn は returnedAt の古い順に並んでいるため、末尾(最新)から逆順に走査します。期限切れのコネクションを見つけたらそれより前のコネクションはすべて期限切れなので、すべてcloseの対象として closing に取り出しています。それ以降の残りが有効な freeConn となります。 (2) 次に maxLifetime を超過したコネクションを探します。 freeConn は createdAt の順では並んでいないため、全件走査する必要があります。 (3) 直前のコメントにある通り、 freeConn の順序を維持するために copy と nil の代入で要素を削除しています。LIFOでの再利用が正しく機能するには returnedAt 順で並んでいることが必要だからです。 この関数はcloseすべきコネクションを closing スライスに集めて返すだけで、 Close() 自体は呼びません。呼び出し元の connectionCleaner がロックを解放してから Close() を実行します。 ここまでで sql.DB によるコネクションプールの実装を確認しました。 DBStats: プールの状態を観測する db.Stats() はプールの現在の状態とカウンターを DBStats として返します。 各フィールドはそれぞれ sql.DB の対応するフィールドの値を返しています。ただし使用中のコネクション数を示す InUse のみ直接保持していないため (openコネクション数 - idleコネクション数) で算出されます。 フィールド 内部実装での算出方法 MaxOpenConnections maxOpen OpenConnections numOpen InUse numOpen - len(freeConn) Idle len(freeConn) WaitCount waitCount WaitDuration waitDuration MaxIdleClosed maxIdleClosed MaxIdleTimeClosed maxIdleTimeClosed MaxLifetimeClosed maxLifetimeClosed 上4つはスナップショットで、 Stats() を呼んだ瞬間の値です。 下5つは累積カウンターで、 DB の生存期間を通じて単調増加します。 コネクションプールの設定 4つの設定パラメータ sql.DB には4つの設定パラメータがあります。 各メソッドの詳細は Managing connections - The Go Programming Language を参照してください。 設定 デフォルト値 内部実装での役割 SetMaxOpenConns 0(無制限) conn で numOpen >= maxOpen なら connRequests で待機 SetMaxIdleConns 2 putConnDBLocked で freeConn に入れるかの上限 SetConnMaxLifetime 0(無制限) conn / putConn で createdAt からの経過をチェック SetConnMaxIdleTime 0(無制限) connectionCleaner で returnedAt からの経過をチェック デフォルト値の問題 Go公式ドキュメント( Managing connections - The Go Programming Language )にはこう書いてあります。 For the vast majority of programs, you needn't adjust the sql.DB connection pool defaults. ですが、本番環境で複数インスタンスからRDBに接続するWebアプリケーションでは事情が異なるのではと思います。 アプリケーションのインスタンスがスケールする一方でDB側には接続数の上限があり、デフォルトの設定では接続数が上限を超えてエラーになるリスクがあるからです。 各パラメータの解説 SetMaxOpenConns numOpen >= maxOpen になると、以降の conn は connRequests のチャネルで待たされます。 Go公式ドキュメント はデッドロックのリスクに言及しています。 Setting the limit makes database usage similar to acquiring a lock or semaphore, with your application queuing up to wait for an available database connection. デフォルト値は0であり、無制限を意味します。これではリクエストがスパイクした際に大量にコネクションが作られ、リソースを大量に消費してしまう可能性があります。そのためデフォルトのままは避けるべきで、デッドロックが起こらない程度に余裕を持ったある程度大きな値でも設定しておくべきと考えています。また、前述の通りDB側にも接続数の上限があるため、複数インスタンスから接続する構成ではDB側の上限をインスタンスが最大までスケールしても超えない値に設定しておく必要があります。 SetMaxIdleConns デフォルトは2( defaultMaxIdleConns )です。 公式ドキュメント にも "raise the limit to avoid frequent reconnects in programs with significant parallelism" とあります。 先ほど見た putConnDBLocked の実装から、デフォルトのままだと何が起きるかがわかります。 コネクションの返却時にすでに MaxIdleConns 以上idleコネクションがあった場合、即closeされるということです。 デフォルトの2のように少ない値だとコネクションの破棄→作成が繰り返されて非効率な可能性があります。 また、実際のところ SetMaxIdleConns は時間帯によって負荷が異なる中で常に適切な値を決めるのが難しいと感じます。そこで筆者らはシンプルに MaxOpenConns と同じ値に設定しています。長時間idleのコネクションが残ってしまうのを避けるためには SetConnMaxLifetime か SetConnMaxIdleTime を設定すれば十分と考えています。 SetConnMaxLifetime godoc の定義では「コネクションが再利用可能な最大時間」です。 内部的には、コネクションの createdAt からの経過で判定され、 conn の取得時と putConn の返却時にチェックされます。 設定しない場合、フェイルオーバーでプライマリが切り替わった際やDNSで接続先が変わった際に古いコネクションが残り続けるリスクがあります。ただ筆者らは ConnMaxLifetime は現時点では設定していません。今のところこれによる問題は発生していませんが、リスクはあるため今後設定を検討しています。 SetConnMaxIdleTime godoc の定義では「コネクションがidle状態でいられる最大時間」です。 内部的には returnedAt からの経過で判定されます。 公式ドキュメント では SetMaxIdleConns との併用が想定されており、バースト時に増やしたidle接続を負荷が減ったときに解放する用途です。 筆者らの環境ではKubernetes上でIstioを利用しており、Envoy sidecarのidle timeoutでDBサーバへのコネクションが切断されます。 そのためidle timeoutよりも短い ConnMaxIdleTime を設定し、Envoy側で切断される前にアプリケーション側からcloseするようにしています。 これにより切断済みのコネクションを使ってしまい ErrBadConn が発生するのを防いでいます。 まとめ 普段はO/R mapperの裏側に隠れていることも多い sql.DB ですが、内部実装を知っておくとDB関連の障害調査で「なぜこの接続が使われたのか」「なぜリトライが起きたのか」を説明できるようになります。また、goroutineやchannelを使った並行処理、mutexのロック範囲の最小化など、パフォーマンスを意識した実装パターンが随所に見られるので、Go自体の学びになる点も多いと感じました。この記事が sql.DB の設定を見直すきっかけや、その判断の参考になれば幸いです。 最後に、筆者ら認証認可グループでは以下の記事も公開しています。今後も発信していく予定ですので、あわせてご覧ください。 Go本格採用から1年──CADDi Control Planeの技術選定と振り返り CADDi の Control Plane を支えるシステムたちの紹介 Auth0を使って1年かけてSSOをサポートした話