30 Jun 2020

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 を起動して処理する設計になっている。

よって、リクエストごとにデータベースを操作するようなふつうの Web アプリケーションでは、並行した goroutine がデータベースへのコネクションプールを操作することになるため、レースコンディションへの対策が必須となる。

(例えば、リクエストを処理するのが goroutine ではなくプロセスやスレッドでも、あるいはリクエストごとに起動するのではなく pre-fork だったとしても、データベースのコネクションプールがスレッドセーフであるという要件は満たす必要があるはずだと思われる)

sql.DB の実装の概要

データベースへの接続は driverConn という構造体 で表現されている。接続は利用中 (inUse)・未使用 (idle) いずれかの状態になる。この接続を sql.DB が保持・管理している。

ある操作を実行しようとすると、sql.DB はまず idle な接続が使おうとし、そうでなければ新しく接続を確立する (初期化時に必要数だけコネクションを確立しておいて、それを使い回す形ではない)。また idle な接続を使い回すかどうか、何本までなら新規接続をするかといった挙動を制御するパラメータもある。

sql.DB は次のようなデータを保持している。

  • freeConn
    • 型は []*driverConn で、idle 状態の接続を保持している
    • 空き接続があればここから使い、使い終わったらここに返す
  • connRequestsnextRequest
    • 接続の割り当て待ちを表す map で、いわゆる待ち行列
    • connRequestsmap[uint64]chan connRequest 型で、これが待ち行列の本体
    • nextRequestuint64 型で待ち行列のエントリを識別する通し番号の採番用
    • クエリを実行する際、接続がすべて inUse で、かつ上限に達していて新規接続が作れない場合、この待ち行列に入り接続が空くのをブロックして待つ
  • numOpen
    • 型は int で、現在の総接続数
    • 接続数の上限を確認する際などに使う
    • freeConn とは違い総数のカウントだけを持っている。inUse な接続の実態を sql.DB は直接保持していない

これらのデータを、sync.Mutex をつかって地道に保護している。データの操作前にロックを取り、操作後に開放するというのを逐次やっている。

それを操作する主要なメソッドには以下がある。

その他のトピック。

  • 当然だが、sql.DB のインスタンスごとにコネクションプールを保持しているし、ロックをとっている
    • 別のインスタンスを作れば、当然別の接続がされるし、インスタンス間でロックをとったりはしない
    • そのため Web サーバで使う場合は、初期化時に sql.Open などで sql.DB インスタンスを作り、各リクエストハンドラの中でそのインスタンスをつかってクエリを実行するとよい
      • これを安全に行うために sync.Mutex で頑張ってデータをレースから保護している
  • 接続は sql.DB の初期化時ではなく、利用時に必要になってから確立される
    • よって sql.Open をした時点では接続ははられておらず、DB.PingContext などで接続チェックをしたほうがよい
  • freeConn で idle 接続は保持しているが、inUse 接続を直接持っていない
    • そのため DB.Close した際に、DB が inUse の接続を直接閉じることはない
    • inUse な接続はそのクエリが終わるなり、context でタイムアウトするなりして止まる
      • 各メソッドには XXContext という context を渡せるインタフェースがあるので、ユーザー側でしっかりとタイムアウトを設定したほうが安全そう
  • DB.Stats で各種統計情報を取得できる
    • 接続の総数と inUse / idle の内訳
    • 割当待ちの累計数・累計待ち時間や、SetConnMaxLifetime でクローズされた累計など

sql.DB の挙動を制御するパラメータ

接続の挙動を制御するパラメータが 4 つある。

  • SetConnMaxLifetime
  • SetConnMaxIdleTime
    • 接続が idle になってから一定期間経つと expire とみなされ使えなくなる
      • SetConnMaxLifetime は接続確立時点からのトータルの経過時間だが、こちらは idle になってからの経過時間
    • Go 1.15 で入る予定
    • SetConnMaxLifetime よりも細かく動作制御できるが、ほとんどのケースでは SetConnMaxLifetime で十分そうで、使い所がちょっとわからなかった
      • idle にならずに定期的に使われている接続を再接続せずにずっと使い回せるようにはなるが、SetConnMaxLifetime で一定時間で切ってしまうのがシンプルで必要十分だと思う
      • issue で そのようなコメントがされている が、特に議論はなくマージされているようだ

これらのパラメータをどう設定すればよいのか。

  • 一般論として、より多くの接続をプールしより長期間使いまわしたほうがスループットは向上するが、あまりに増やしすぎるとメモリ使用量が増加したり、一部セッションのレスポンスタイムが極端に劣化したりする恐れがある
  • アプリケーションとデータストアの性質によるので、基本は負荷試験をしながら適正値を探るしかなさそう
    • こちらの記事 では MaxOpenConn 25, MaxIdleConn 25, MaxLifetime 5 分という仮値からチューニングを始めると書かれていた
  • MaxOpenConns は必ず設定したほうがよい
    • MySQL の max_connections など、データベース側の接続数上限もあるので、少なくともこれよりは少なくしないといけない
  • こちらの記事 によると、idle 接続の管理に SetMaxIdleConns は利用せず (= MaxOpenConn よりも大きい値にしておき)、SetConnMaxLifetime にまかせてしまうのが良い
    • 確かにそのほうが理解しやすい

参考

改訂2版 みんなのGo言語
松木 雅幸 (著), mattn (著), 藤原 俊一郎 (著), 中島 大一 (著), 上田 拓也 (著), 牧 大輔 (著), 鈴木 健太 (著) 形式: Kindle版