Go の sql.DB がコネクションプールを管理する仕組み
Go の database/sql パッケージ の DB 構造体 は、データベースへのコネクションプールを管理し、かつスレッドセーフ (goroutine セーフと言ったほうが良いのだろうか…?) にそれらの接続を使用できることを保証している。
ドキュメント にも次のように書かれている。
DB is a database handle representing a pool of zero or more underlying connections. It’s safe for concurrent use by multiple goroutines.
こちらの基本的な実装内容と、動作を制御するパラメータについて調べてみた。
基礎知識のおさらい
database/sql
パッケージはデータストアの実装によらない一般的な SQL のインタフェースを提供している。具体的なデータストアへの接続やクエリの発行、そのデータストア特有の機能などは SQL Database Driver として分離されている。
その中の sql.DB
構造体はスレッドセーフなコネクションプールの管理を担当している。sql と sql/driver パッケージの設計上の目的を書いた文章 にも、sql
パッケージの責務として、複数の goroutine から簡単・安全にプールを利用できることが記載されていた。
Handle concurrency well. Users shouldn’t need to care about the database’s per-connection thread safety issues (or lack thereof), and shouldn’t have to maintain their own free pools of connections. The ‘sql’ package should deal with that bookkeeping as needed. Given an *sql.DB, it should be possible to share that instance between multiple goroutines, without any extra synchronization.
ユーザーは sql.Open で DB 構造体のインスタンスを取得し、あとはそれを通じてクエリの発行メソッドを呼ぶだけで、プールの詳細やレースコンディションなどを気にしなくてよい。使う側からは非常に簡単な作りになっている。(その分 sql パッケージ自身はそこそこ複雑化している印象もあるが…)
db, err = sql.Open("driver-name", *dsn) // db が connection pool
...
rows, err := db.QueryContext(ctx, "SELECT ...") // これを goroutine 内から呼んでも安全
...
スレッドセーフが大事なのは、一般的な Web アプリケーションでそのようなケースが頻出するから。例えば net/http パッケージ の Server は、クライアントからのリクエストをうけるごとに goroutine を起動して処理する設計になっている。
- サーバに到着したリクエストを accept で受け取り、それをハンドリングする処理を goroutine で起動している
- イメージとしては net.Listener の example に近い
- こっちは http サーバではないが、リクエストを受けるごとに goroutine で処理しているという骨格は近い
よって、リクエストごとにデータベースを操作するようなふつうの Web アプリケーションでは、並行した goroutine がデータベースへのコネクションプールを操作することになるため、レースコンディションへの対策が必須となる。
(例えば、リクエストを処理するのが goroutine ではなくプロセスやスレッドでも、あるいはリクエストごとに起動するのではなく pre-fork だったとしても、データベースのコネクションプールがスレッドセーフであるという要件は満たす必要があるはずだと思われる)
sql.DB の実装の概要
データベースへの接続は driverConn という構造体 で表現されている。接続は利用中 (inUse)・未使用 (idle) いずれかの状態になる。この接続を sql.DB が保持・管理している。
ある操作を実行しようとすると、sql.DB はまず idle な接続が使おうとし、そうでなければ新しく接続を確立する (初期化時に必要数だけコネクションを確立しておいて、それを使い回す形ではない)。また idle な接続を使い回すかどうか、何本までなら新規接続をするかといった挙動を制御するパラメータもある。
sql.DB は次のようなデータを保持している。
- freeConn
- 型は
[]*driverConn
で、idle 状態の接続を保持している - 空き接続があればここから使い、使い終わったらここに返す
- 型は
- connRequests と nextRequest
- 接続の割り当て待ちを表す map で、いわゆる待ち行列
connRequests
はmap[uint64]chan connRequest
型で、これが待ち行列の本体nextRequest
はuint64
型で待ち行列のエントリを識別する通し番号の採番用- クエリを実行する際、接続がすべて inUse で、かつ上限に達していて新規接続が作れない場合、この待ち行列に入り接続が空くのをブロックして待つ
- numOpen
- 型は
int
で、現在の総接続数 - 接続数の上限を確認する際などに使う
- freeConn とは違い総数のカウントだけを持っている。inUse な接続の実態を sql.DB は直接保持していない
- 型は
これらのデータを、sync.Mutex をつかって地道に保護している。データの操作前にロックを取り、操作後に開放するというのを逐次やっている。
それを操作する主要なメソッドには以下がある。
- DB.conn
- プールからよしなに接続を取得する処理。begin, prepare, query, exec といった一連のクエリ系メソッドは、内部で必ずこの処理を通じて接続を取得している
- 可能なら freeConn から idle な接続を探してそれを使う
- idle 接続がなかったり、あっても一定時間経過している場合は使われない (後者は SetConnMaxLifetime が設定されている場合)
- idle 接続がない場合は 新規接続を試みる
- 接続数の上限に達していて新規接続ができない場合 は、接続割当の待ち行列 connRequest に並ぶ
- 接続に空きが出るまで ブロックして待っている
- context でタイムアウト もするので、無限にブロックするわけではない
- driverConn.releaseConn (DB.putConn)
- 使い終わった接続をプールに返す処理。各クエリ系のメソッドで利用後にこれが呼ばれる
- inUse フラグを偽にし idle 状態とマークし、freeConn に接続を戻す
- このときに、connRequests に待ちがあればそちらに先に回したり、
SetMaxIdleConns
で idle 接続数の上限が設定されていればそのチェックをしたりしている
- このときに、connRequests に待ちがあればそちらに先に回したり、
- connRequests への追加と受け取り
- 識別用の番号を 取得 し、それをキーにして connRequests に登録する
- 登録するのは connRequest 型のチャネル で、接続に空きができたらここに通知が来る
- 上記の releaseConn メソッドがここに 通知している
- DB.connectionCleaner
- idle 接続プールのお掃除処理
- 別 goroutine で 定期起動 し、使えなくなった idle 接続を close していく
- 具体的には SetConnMaxLifetime や SetConnMaxIdleTime が設定されている場合はその条件をチェック している
- 定期実行時だけでなく、cleanerCh チャネルに通知 すると、そのタイミングでも掃除をしてくれる
その他のトピック。
- 当然だが、sql.DB のインスタンスごとにコネクションプールを保持しているし、ロックをとっている
- 別のインスタンスを作れば、当然別の接続がされるし、インスタンス間でロックをとったりはしない
- そのため Web サーバで使う場合は、初期化時に sql.Open などで sql.DB インスタンスを作り、各リクエストハンドラの中でそのインスタンスをつかってクエリを実行するとよい
- これを安全に行うために sync.Mutex で頑張ってデータをレースから保護している
- 接続は sql.DB の初期化時ではなく、利用時に必要になってから確立される
- よって sql.Open をした時点では接続ははられておらず、DB.PingContext などで接続チェックをしたほうがよい
- freeConn で idle 接続は保持しているが、inUse 接続を直接持っていない
- そのため DB.Close した際に、DB が inUse の接続を直接閉じることはない
- DB.Close は freeConn と connRequests を閉じる
- DB.Close を呼ぶと 新しいクエリは発行できなくなる が、すでに実行中のクエリには sql.DB は関与しない
- inUse な接続はそのクエリが終わるなり、context でタイムアウトするなりして止まる
- 各メソッドには XXContext という context を渡せるインタフェースがあるので、ユーザー側でしっかりとタイムアウトを設定したほうが安全そう
- そのため DB.Close した際に、DB が inUse の接続を直接閉じることはない
- DB.Stats で各種統計情報を取得できる
- 接続の総数と inUse / idle の内訳
- 割当待ちの累計数・累計待ち時間や、
SetConnMaxLifetime
でクローズされた累計など
sql.DB の挙動を制御するパラメータ
接続の挙動を制御するパラメータが 4 つある。
- SetMaxOpenConns
- 最大何本の接続をできるかの上限
- デフォルトは 0 で、無限に接続できる
- SetMaxIdleConns
- idle 接続を最大何本保持できるかの上限
- つまり freeConn の長さの上限とも言える
- 必ず MaxOpenConns 以下の数値になる (それより大きい値を設定すると切り詰める)
- SetConnMaxLifetime
- ある接続を利用できる期間
- 接続が 確立された時点 から lifetime 時間経過すると、その接続は expire とみなされ使えなくなる
- 一般的にデータベースへの接続を長期間使い回すのは危険なので、適宜切って再接続するのがよい
- 例えば MySQL の wait_timeout はデフォルトで 8 時間経過した接続を MySQL サーバ側から切断する
- それ以外にも長い TCP 接続が OS レベルで切られることもあるかもしれない
- こうしたケースが起こると、アプリケーションはクエリの送信を試みてから使えないことに気づき再接続をすることになるので、オーバーヘッドが大きくなる
- デフォルトは無制限に利用可能
- 以下の記事に詳しい
- もし接続が lifetime を expire していたら、driver.ErrBadConn を返す
- 呼び出し側は ErrBadConn の場合は二回までリトライする 作りになっている
- ある接続を利用できる期間
- SetConnMaxIdleTime
- 接続が idle になってから一定期間経つと expire とみなされ使えなくなる
- SetConnMaxLifetime は接続確立時点からのトータルの経過時間だが、こちらは idle になってからの経過時間
- Go 1.15 で入る予定
- SetConnMaxLifetime よりも細かく動作制御できるが、ほとんどのケースでは SetConnMaxLifetime で十分そうで、使い所がちょっとわからなかった
- idle にならずに定期的に使われている接続を再接続せずにずっと使い回せるようにはなるが、SetConnMaxLifetime で一定時間で切ってしまうのがシンプルで必要十分だと思う
- issue で そのようなコメントがされている が、特に議論はなくマージされているようだ
- 接続が idle になってから一定期間経つと expire とみなされ使えなくなる
これらのパラメータをどう設定すればよいのか。
- 一般論として、より多くの接続をプールしより長期間使いまわしたほうがスループットは向上するが、あまりに増やしすぎるとメモリ使用量が増加したり、一部セッションのレスポンスタイムが極端に劣化したりする恐れがある
- アプリケーションとデータストアの性質によるので、基本は負荷試験をしながら適正値を探るしかなさそう
- こちらの記事 では MaxOpenConn 25, MaxIdleConn 25, MaxLifetime 5 分という仮値からチューニングを始めると書かれていた
- MaxOpenConns は必ず設定したほうがよい
- MySQL の max_connections など、データベース側の接続数上限もあるので、少なくともこれよりは少なくしないといけない
- こちらの記事 によると、idle 接続の管理に SetMaxIdleConns は利用せず (= MaxOpenConn よりも大きい値にしておき)、SetConnMaxLifetime にまかせてしまうのが良い
- 確かにそのほうが理解しやすい
参考
- src/database/sql/doc.txt - The Go Programming Language
database/sql
パッケージの設計上のゴール
- Configuring sql.DB for Better Performance - Alex Edwards
- 各制御パラメータの概要のわかりやすい整理
- DSAS開発者の部屋:Re: Configuring sql.DB for Better Performance
SetConnMaxLifetime
の意義と推奨設定について
- database/sql: add `lastUseTime` or similar to driverConn, add SetConnMaxIdleLefttime to DB · Issue #25232 · golang/go
SetConnMaxIdleTime
が導入された issue