技術メモ

技術メモ

ラフなメモ

パフォーマンス向上のためのsql.DBの設定

こちらは Configuring sql.DB for Better Performance の日本語訳です。


パフォーマンス向上のためのsql.DBの設定

Go の sql.DB 型や SQL データベースのクエリやステートメントを実行するための使い方を説明した良いチュートリアルがたくさんあります。しかし、ほとんどのチュートリアルでは、SetMaxOpenConns()SetMaxIdleConns()SetConnMaxLifetime() メソッドを軽視しています。

この記事では、これらの設定が何をするのかを正確に説明し、(ポジティブ/ネガティブな)影響を与えることを示したいと思います。

Openな接続とアイドルな接続

少し背景を説明します。

sql.DB オブジェクトは多くのデータベース接続のプールで 'open' と 'idle' の両方の接続を含んでいます。SQL 文の実行や行の問い合わせなど、データベースのタスクを実行するために接続を使用している場合、接続はオープンとマークされます。タスクが完了すると、接続はアイドルになります。

データベースタスクを実行するように sql.DB に指示すると、まず、プール内のアイドル接続が利用可能かどうかをチェックします。利用可能な接続がある場合、Go は既存の接続を再利用し、タスクの間はオープンとしてマークします。必要なときにプール内にアイドル接続がない場合は、Go は新しく接続を作成し、それを「オープン」します。

SetMaxOpenConnsメソッド

デフォルトでは、同時に開くことのできる接続数に制限はありません。しかし、以下のように SetMaxOpenConns() メソッドを使用して独自の制限を実装することができます。

// Initialise a new connection pool
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
    log.Fatal(err)
}

// Set the maximum number of concurrently open connections to 5. Setting this
// to less than or equal to 0 will mean there is no maximum limit (which
// is also the default setting).
db.SetMaxOpenConns(5)

この例のコードでは、プールは同時に開いている接続の最大数を 5 つに制限しています。5つの接続がすでに開いていて、別の新しい接続が必要な場合、アプリケーションは 5 つの開いている接続のうちの 1 つが解放されてアイドル状態になるまで待機することを余儀なくされます。

MaxOpenConns を変更した場合の影響を説明するために、最大オープン接続数を1、2、5、10、無制限に設定してベンチマークテストを実行しました。ベンチマークPostgreSQL データベース上で並列 INSERT 文を実行しています。ベンチマークのコードは gist で参照できます。結果は以下の通りです。

BenchmarkMaxOpenConns1-8                 500       3129633 ns/op         478 B/op         10 allocs/op
BenchmarkMaxOpenConns2-8                1000       2181641 ns/op         470 B/op         10 allocs/op
BenchmarkMaxOpenConns5-8                2000        859654 ns/op         493 B/op         10 allocs/op
BenchmarkMaxOpenConns10-8               2000        545394 ns/op         510 B/op         10 allocs/op
BenchmarkMaxOpenConnsUnlimited-8        2000        531030 ns/op         479 B/op          9 allocs/op
PASS

編集: はっきりさせるために、このベンチマークの目的はアプリケーションの'現実の'動作をシミュレートすることではありません。sql.DB が舞台裏でどのように振る舞っているか、MaxOpenConnsを変更した場合の影響を説明するためだけです。

このベンチマークでは、許可されているオープン接続が多ければ多いほど、データベース上で INSERT を実行するのにかかる時間が短くなることがわかります(無制限接続の場合は 531030ns/op - 約 6 倍速いのに対し、オープン接続が1つの場合は 3129633ns/op)。これは、オープン接続が多ければ多いほど、ベンチマークコードがオープン接続を解放して再びアイドル状態にするのを待つ時間が(平均的に)短くなるためです。

SetMaxIdleConnsメソッド

デフォルトでは、sql.DB は接続プールに最大 2 つのアイドル接続を保持することができます。これは SetMaxIdleConns() メソッドで変更することができます。

// Initialise a new connection pool
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
    log.Fatal(err)
}

// Set the maximum number of concurrently idle connections to 5. Setting this
// to less than or equal to 0 will mean that no idle connections are retained.
db.SetMaxIdleConns(5)

理論的には、プール内のアイドル接続数を増やすことで、新規に接続を確立する必要がなくなるため、パフォーマンスが向上し、リソースの節約につながります。

アイドル接続の最大数を「なし」「1」「2」「5」「10」に設定した場合の同じベンチマークを見てみましょう(オープン接続の数は無制限)。

BenchmarkMaxIdleConnsNone-8          300       4567245 ns/op       58174 B/op        625 allocs/op
BenchmarkMaxIdleConns1-8            2000        568765 ns/op        2596 B/op         32 allocs/op
BenchmarkMaxIdleConns2-8            2000        529359 ns/op         596 B/op         11 allocs/op
BenchmarkMaxIdleConns5-8            2000        506207 ns/op         451 B/op          9 allocs/op
BenchmarkMaxIdleConns10-8           2000        501639 ns/op         450 B/op          9 allocs/op
PASS

MaxIdleConns が「なし」に設定されている場合、INSERT ごとに新しい接続を新規に作成する必要があり、平均実行時間とメモリ使用量が比較的高いことがベンチマークからわかります。

ちょうど 1 つのアイドル接続を保持して再利用できるようにすることで、この特定のベンチマークに大きな違いが生まれます - それは、平均実行時間を約8倍削減し、メモリ使用量を約20倍削減します。アイドル接続プールのサイズを大きくすると、改善はあまり顕著ではありませんが、パフォーマンスはさらに向上します。

では、大きなアイドル接続プールを維持する必要があるのでしょうか?答えはアプリケーションによります。

アイドル接続を維持することにはコストがかかることを認識することが重要です。アプリケーションとデータベースの両方に使用できるメモリを占有します。

また、接続があまりにも長い間アイドル状態になっていると、使用できなくなる可能性があります。例えば、MySQL の wait_timeout 設定は、8 時間(デフォルトでは)使用されていない接続を自動的に閉じます。

これが起こると、sql.DB はそれを Graceful に処理します。悪い接続は自動的に 2 回再試行されてから諦め、その時点で Go はプールから接続を削除して新しい接続を作成します。そのため、MaxIdleConns を高く設定しすぎると、(頻繁に使用されることがあまりない)アイドル接続プールを小さくした場合よりも、実際には接続が使用不能になり、より多くのリソースが使用されるようになるかもしれません。ですから、実際には接続をアイドル状態にしておきたいのは、すぐに再利用する可能性がある場合だけです。

最後に指摘しておきたいのは、MaxIdleConns は常に MaxOpenConns 以下でなければならないということです。Go はこれを強制しており、必要に応じて MaxIdleConns を自動的に減らします。この Stack Overflow の投稿にその理由がうまく説明されています。

許可されているすべてのオープン接続を瞬時に取得できる場合、アイドル状態の接続は常にアイドル状態のままになるため、アイドル状態の接続が最大接続数を超えることはありません。それは、4車線の橋を持っているようなものですが、一度に3台の車が渡ることを許可しているだけです。

SetConnMaxLifetimeメソッド

ここで、SetConnMaxLifetime() メソッドを見てみましょう。これは、接続を再利用できる最大の期間を設定するものです。これは、SQL データベースが接続の生存期間の上限を実装している場合や、例えばロードバランサの後ろでデータベースを Graceful にスワップしたい場合などに便利です。

以下のように使います。

// Initialise a new connection pool
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
    log.Fatal(err)
}

// Set the maximum lifetime of a connection to 1 hour. Setting it to 0
// means that there is no maximum lifetime and the connection is reused
// forever (which is the default behavior).
db.SetConnMaxLifetime(time.Hour)

この例では、すべての接続は最初に作成されてから 1 時間後に「期限切れ」となり、期限切れ後は再利用できません。しかし、注意してください。

  • これは、接続がプール内に丸一時間存在することを保証するものではありません。何らかの理由で接続が使えなくなり、時間経過前に自動的に閉じられてしまう可能性は十分にあります。
  • 接続は、作成されてから1時間以上経過しても使用することができます。ただ、それ以降に再利用として使い始めることはできません。
  • これはアイドルタイムアウトではありません。接続は最初に作成されてから1時間後に失効します。最後にアイドル状態になってから1時間ではありません。
  • 一秒に一度、プールから「期限切れ」の接続を削除するためのクリーンアップ操作が自動的に実行されます。

理論的には、ConnMaxLifetime が短ければ短いほど、接続の有効期限が切れる頻度が高くなり、その結果、新規に接続を作成する必要がある頻度が高くなります。

これを説明するために、ConnMaxLifetime を 100ms、200ms、500ms、1000ms、無制限(永遠に再利用)に設定し、デフォルト設定は無制限のオープン接続と 2 つのアイドル接続でベンチマークを実行しました。これらの期間は、ほとんどのアプリケーションで使用するよりも明らかにずっと短いですが、動作をうまく説明するのに役立ちます。

BenchmarkConnMaxLifetime100-8               2000        637902 ns/op        2770 B/op         34 allocs/op
BenchmarkConnMaxLifetime200-8               2000        576053 ns/op        1612 B/op         21 allocs/op
BenchmarkConnMaxLifetime500-8               2000        558297 ns/op         913 B/op         14 allocs/op
BenchmarkConnMaxLifetime1000-8              2000        543601 ns/op         740 B/op         12 allocs/op
BenchmarkConnMaxLifetimeUnlimited-8         3000        532789 ns/op         412 B/op          9 allocs/op
PASS

これらの特定のベンチマークでは、無制限の生存期間と比較して、100ms の生存期間ではメモリ使用量が 3 倍以上になり、各 INSERT の平均実行時間もわずかに長くなっていることがわかります。

コードで ConnMaxLifetime を設定する場合、接続が期限切れになる(そしてその後に再作成される)頻度を念頭に置くことが重要です。例えば、総接続数が 100 で ConnMaxLifetime が 1 分の場合、アプリケーションは 1 秒ごとに最大 1.67 個の接続を kill して再作成する可能性があります (平均して)。この頻度は、パフォーマンスを向上させるどころか、最終的にはパフォーマンスの妨げになるほど大きくなることは避けたいものです。

接続制限の超過

最後に、この記事はデータベース接続数のハードリミットを超えた場合に何が起こるかについて言及しなければ完全ではありません。

例として、postgresql.confファイルを変更して、合計5つの接続しか許可されないようにしてみます(デフォルトは100です)。

max_connections = 5

そして、オープン接続は無制限にしてベンチマークテストを再実行...

BenchmarkMaxOpenConnsUnlimited-8    --- FAIL: BenchmarkMaxOpenConnsUnlimited-8
    main_test.go:14: pq: sorry, too many clients already
    main_test.go:14: pq: sorry, too many clients already
    main_test.go:14: pq: sorry, too many clients already
FAIL

5 つの接続のハードリミットに達するとすぐに、データベースドライバ(pq)は INSERT を完了させる代わりに「sorry, too many clients already」というエラーメッセージを返します。

このエラーを防ぐためには、sql.DB で開いている接続とアイドル接続の合計の最大値を 5 以下に快適に設定する必要があります。このようにします。

// Initialise a new connection pool
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
    log.Fatal(err)
}

// Set the number of open and idle connection to a maximum total of 3.
db.SetMaxOpenConns(2)
db.SetMaxIdleConns(1)

これで、sql.DB によって作成される接続は最大 3 つまでになり、ベンチマークはエラーなしで実行されるようになりました。

しかし、これを行うには大きな注意点があります: オープン接続の制限に達すると、アプリケーションが実行する必要のある新しいデータベースタスクは、接続が空になるまで待たされることになります。例えば、Web アプリケーションのコンテキストでは、ユーザの HTTP リクエストは「ハング」しているように見え、データベースタスクが実行されるのを待っている間にタイムアウトする可能性があります。

これを緩和するには、データベースを呼び出す際には、 ExecContext() のようなコンテキストを有効にするメソッドを使用して、常に固定の高速なタイムアウトを持つ context.Context オブジェクトを渡す必要があります。例は、gist で見ることができます。

サマリ

  1. 経験則として、MaxOpenConns と MaxIdleConns の値を明示的に設定する必要があります。これらの値の合計は、データベースやインフラストラクチャによって課せられる接続数のハードリミットを快適に下回るべきであり、MaxIdleConns は常に MaxOpenConns 以下でなければなりません。
  2. 一般的に、MaxOpenConns と MaxIdleConns の値を高くするとパフォーマンスが向上します。しかし、向上の効果は減少しており、あまりにも大きなアイドル接続プール (再利用されず、最終的には不良になる接続) を持つと、実際にはパフォーマンスの低下につながることに注意しなければなりません。
  3. 上記のポイント 2 のリスクを軽減するために、ConnMaxLifetime を比較的短く設定するとよいでしょう。しかし、接続が不必要に頻繁に殺されたり再作成されたりするような短い設定にしないようにしましょう。

小規模から中規模のウェブアプリケーションでは、私は通常、以下の設定を出発点として使用し、実際のスループットレベルでの負荷テストの結果に応じて、そこから最適化します。

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5*time.Minute)

このブログ記事を楽しんでいただけたなら、Goを使ったプロフェッショナルなWebアプリケーションの構築方法についての私の新刊をチェックするのをお忘れなく!

ツイッター@ajmedwards をフォローしてください。

この記事のコードスニペットはすべて MIT ライセンスの下で自由に使用できます。