💁‍♂️

【Golang】コネクションプーリングの設定をテストしてみる

2023/03/18に公開

概要

本記事では、Golangにおけるコネクションプーリングの設定について解説します。コネクションプーリングは、データベースとの接続を確立するためのリソースを事前に確保しておき、必要なときにはそのリソースを利用することで、接続時間を短縮することができたり、Mysqlに過剰な負荷がかからず、システムの応答性が改善されるようになります。

環境

本記事のコードは、以下の環境で動作確認を行っています。

  • Golang v1.18.6
  • Mysql Ver 8.0.32
  • Docker version 20.10.17

コネクションプーリングとは

コネクションプーリングは、データベースとの接続を確立するためのリソースを事前に確保しておき、必要なときにはそのリソースを利用することで、接続時間を短縮することができます。

Golangでは4種類の設定に対応している

Golangでは、SetMaxOpenConnsSetMaxIdleConnsSetConnMaxLifetimeSetConnMaxIdleTimeの4種類の設定があります。それぞれについて説明します。

SetMaxOpenConns

SetMaxOpenConns は、同時に使用できる最大コネクション数を設定するための関数です。これにより、同時に実行できるクエリの数を制限することができ、Mysqlに過剰な負荷がかからず、システムの応答性が改善されます。

db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database")
if err != nil {
    panic(err.Error())
}
db.SetMaxOpenConns(10)

この例では、10個のコネクションを同時に開くことができます。この数が大きすぎると、Mysqlに過剰な負荷がかかり、パフォーマンスが低下する可能性があるため、この数は適切に設定する必要があります。

SetMaxIdleConns

SetMaxIdleConns は、コネクションプール内に保持する最大アイドルコネクション数を設定します。アイドルコネクションとは、現在何もクエリを実行していないコネクションのことです。プール内に保持されるため、新しい接続を確立するために必要な時間とリソースを節約できます。
アイドルコネクションの数が少なすぎると、新しい接続を作成するために無駄な時間がかかり、パフォーマンスに悪影響を与える可能性があります。一方、アイドル接続数が多すぎると、メモリの消費量が増え、サーバーのパフォーマンスが低下する可能性があります。適切なアイドルコネクション数は負荷テストやベンチマークを実施して決定する必要がありそうです。

db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database")
if err != nil {
    panic(err.Error())
}
db.SetMaxIdleConns(5)

この例では、5個のアイドルコネクションをプール内に保持できます。

SetConnMaxLifetime

SetConnMaxLifetime は、コネクションが再利用される前に許容される最長時間を設定します。この時間が経過した場合、コネクションはプールから削除され、新たなコネクションが作成されます。

db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database")
if err != nil {
    panic(err.Error())
}
db.SetConnMaxLifetime(5 * time.Minute)

この例では、5分を超えるコネクションはプールから削除されます。

SetConnMaxIdleTime

SetConnMaxIdleTime は、コネクションがアイドル状態(何も処理をしていない状態)で保持される時間を指定します。指定した時間が経過したコネクションはクローズされ、プールから削除されます。これにより、プール内に存在するアイドルコネクション数が最適化され、リソースの浪費を防止することができます。

db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/database")
if err != nil {
    panic(err.Error())
}
db.SetConnMaxIdleTime(5 * time.Minute)

注意点として、SetConnMaxLifetime と併用して設定することが重要になってきます。 SetConnMaxLifetime はコネクションがプール内に存在できる最大時間を設定し、この期間を超えるとコネクションは破棄されます。一方、SetConnMaxIdleTime はコネクションがアイドル状態でプール内に存在できる最大時間を設定します。つまり、コネクションがアイドル状態であっても、SetConnMaxLifetime で設定した最大時間を超えると、そのコネクションはプールから破棄されます。

つまり、どちらかの設定値を超えるとコネクションがプールから破棄されますが、SetConnMaxLifetime の方が優先度が高いということになります。したがって、アプリケーションで長時間実行されるコネクションを設定する場合は、SetConnMaxLifetime を設定することが重要です。

コネクションプーリングの設定をテストする

以下のコマンドで Docker を立ち上げておきます。

docker run -p 3306:3306 --name test-mysql -e MYSQL_ROOT_PASSWORD=pass -d mysql:8.0

Mysql のコネクションの上限を超えるリクエストしてみる

実際に Mysql のコネクションの上限を超えるリクエストをすることでエラーになることを確認してみます。

Mysql のコネクションの上限を確認する方法

最大同時接続数を確認するには、以下のコマンドを実行します。

mysql> show variables like 'max_connections';
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 151   |
+-----------------+-------+
1 row in set (0.04 sec)

こちらは私のMysql環境でコマンドを実行した結果になります。

テスト

このテストでは、Mysqlのコネクションの上限を超える200のゴルーチンを生成して、それぞれのゴルーチンがクエリを実行するようになっています。
一定数のゴルーチンが起動された時点でMysqlへの接続数が最大値を超え、その後の接続要求がすべて失敗することになります。 そして失敗したゴルーチンから送信されたエラーを受け取り、期待通りにエラーが発生していることを確認しています。

func TestMaxConnections(t *testing.T) {

	db, err := sql.Open("mysql", "root:pass@(localhost:3306)/testdb")
	assert.Nil(t, err)
	assert.NotNil(t, db)

	defer db.Close()

	var wg sync.WaitGroup
	var errCh = make(chan error)

	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, err := db.Exec("SELECT 1")
			if err != nil {
				errCh <- err
			}
		}()
	}

	go func() {
		wg.Wait()
		close(errCh)
	}()

	for err := range errCh {
		// エラーが発生した場合、期待通りのエラーであることを確認する
		assert.EqualError(t, err, "Error 1040: Too many connections")
	}
}

以下はテスト実行結果です。

=== RUN   TestMaxConnections
[mysql] 2023/03/13 18:40:06 packets.go:37: read tcp [::1]:51852->[::1]:3306: read: connection reset by peer
...
--- PASS: TestMaxConnections (0.13s)

テストが通りました。 read: connection reset by peer というのはMysqlが接続を切断しているときに表示されます。このことからも接続数が多すぎることにより接続が切断されたことがわかります。

コネクションプーリングを設定したテストを実施する

先程のテストと同様に200のゴルーチンを生成して、それぞれのゴルーチンがクエリを実行するようになっています。先程のテストと違うのはコネクションプーリングを設定しているという点です。コネクションプーリングを設定することにより、エラーが発生すること無くテストが終了することを確認します。

func TestConnectionPooling(t *testing.T) {
	db, err := sql.Open("mysql", "root:pass@(localhost:3306)/testdb")
	assert.Nil(t, err)
	assert.NotNil(t, db)
	defer db.Close()

	db.SetMaxOpenConns(10)                  // DBへの最大接続数を10に設定
	db.SetMaxIdleConns(5)                   // アイドル状態のコネクション数を5に設定
	db.SetConnMaxLifetime(10 * time.Minute) // コネクションの最大ライフタイムを10分に設定
	db.SetConnMaxIdleTime(5 * time.Minute)  // コネクションの最大アイドル時間を5分に設定

	var wg sync.WaitGroup
	var errCh = make(chan error)
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, err := db.Exec("SELECT 1")
			if err != nil {
				errCh <- err
			}
		}()
	}

	go func() {
		wg.Wait()
		close(errCh)
	}()

	// エラーが発生しないことを確認する
	for err := range errCh {
		assert.Nil(t, err)
	}
}

以下はテストの実行結果です。

=== RUN   TestConnectionPooling
--- PASS: TestConnectionPooling (0.03s)

テストが通ることが確認できました。

まとめ

本記事では、Golangにおけるコネクションプーリングの設定について説明しました。Golangでは、4つの設定に対応しています。それぞれの設定値の詳細については理解できましたが、適切な設定には負荷テストやベンチマークを実施して決定する必要があるため、今度はその点についても深堀りしていきたいと思います。

参考文献

https://please-sleep.cou929.nu/go-sql-db-connection-pool.html

Discussion