gorm のコネクションプールを検証してみた

[テックタッチアドベントカレンダー](https://qiita.com/advent-calendar/2019/techtouch)20日目担当する@smith-30です。
19日目は@kosy による [Vueで日本全国ダーツの旅的なものを作ってみた](https://qiita.com/kosy/items/4014b8a6bd673919b170) でした。遊んでみたら僕は福島に行けと言われました。
弊社はまだエンジニア/デザイナが少数なので1人2回記事を書くスケジュールでしたが、毎日誰かの投稿がみれて楽しかったです。

## このページについて

gorm のコネクションプール周りの挙動を理解するためにパフォーマンス等色々実験したときのメモです(2019-12)
なんとなく設定して使っていましたが、ちゃんと検証はしたことがなかったので動かしてみました。
といっても gorm は、 `database/sql` のラッパーなので実質その挙動の調査です。

## 内容

### 環境

**mysql**

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

**mac**

```
$ system_profiler SPHardwareDataType
Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: MacBookPro15,2
      Processor Name: Intel Core i5
      Processor Speed: 2.3 GHz
      Number of Processors: 1
      Total Number of Cores: 4
      L2 Cache (per Core): 256 KB
      L3 Cache: 6 MB
      Hyper-Threading Technology: Enabled
      Memory: 16 GB
```

### 接続上限の設定

gorm は DB() で *sql.DB を呼び出し、 SetMaxOpenConns(num) で *sql.DB に最大接続数を設定できます。この値を設定していないと上限なくdbへ接続しにいくため、上記の設定値の100以上クエリを同時に発行するとerrorが返されます。

**エラーを出力するサンプル**

```go:main.go
package conn_pool

import (
	"fmt"

	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/mysql"
)

const slowQuery = "select sleep(5)"

func doQuery(db *gorm.DB) error {
	return db.Exec(slowQuery).Error
}

func genSetting(username, password, host, port, dbName string) string {
	address := host + ":" + port
	setting := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local", username, password, address, dbName)
	return setting
}

func openDB(setting string) *gorm.DB {
	db, err := gorm.Open("mysql", setting)
	if err != nil {
		panic(err)
	}
	return db
}
```

下記がテストコードです。クエリによる接続数を維持するために goroutine でconnCount数分並列にselect sleep(5) で5秒間待ちのクエリを投げています。**期待される挙動としては**connCountが100のときは接続上限なのでエラーなく実行されるが、次の101は接続上限を超えるのでエラーが起こる想定です。テストケースごとに `Close()` を呼んでいるのは、queryのコネクションがSleepで残ってしまい、後続のテストに影響がでてしまうからです。Closeを呼ぶとその接続はCleanされます。下記のような形で残ってしまっていたため。

```
mysql> SELECT * FROM information_schema.PROCESSLIST;
+------+------+------------------+---------------+---------+------+-----------+----------------------------------------------+
| ID   | USER | HOST             | DB            | COMMAND | TIME | STATE     | INFO                                         |
+------+------+------------------+---------------+---------+------+-----------+----------------------------------------------+
| 8116 | root | 172.21.0.1:42058 | test_database | Sleep   |    7 |           | NULL                                         |
| 8095 | root | 172.21.0.1:42016 | test_database | Sleep   |    7 |           | NULL                                         |
|  380 | root | localhost        | test_database | Query   |    0 | executing | SELECT * FROM information_schema.PROCESSLIST |
+------+------+------------------+---------------+---------+------+-----------+----------------------------------------------+
```

```go:main_test.go
package conn_pool

import (
	"sync"
	"testing"
	"time"

	_ "github.com/jinzhu/gorm/dialects/mysql"
)

func Test_maxConn(t *testing.T) {
	setting := genSetting("root", "root", "localhost", "13306", "test_database")
	type args struct {
		connCount int
	}
	tests := []struct {
		name string
		args args
	}{
		{
			args: args{
				connCount: 100,
			},
		},
		{
			args: args{
				connCount: 101,
			},
		},
	}
	for _, tt := range tests {
		connPoolDB := openDB(setting)
		//if tt.args.connCount > 100 {
		//	connPoolDB.DB().SetMaxOpenConns(100)
		//}

		t.Run(tt.name, func(t *testing.T) {
			db := connPoolDB
			wg := &sync.WaitGroup{}
			for index := 0; index < tt.args.connCount; index++ {
				go func() {
					wg.Add(1)
					defer wg.Done()
					if err := doQuery(db); err != nil {
						t.Errorf("%v\n", err)
					}
				}()
			}
			wg.Wait()
			connPoolDB.Close()
		})
	}
}

```

**実行結果**

```
=== RUN   Test_maxConn
=== RUN   Test_maxConn/接続数が上限のとき
=== RUN   Test_maxConn/接続数が上限を超えているとき
--- FAIL: Test_maxConn (10.13s)
    --- PASS: Test_maxConn/接続数が上限のとき (5.06s)
    --- FAIL: Test_maxConn/接続数が上限を超えているとき (5.07s)
        /go/src/github.com/smith-30/goparco/gorm/conn_pool/main_test.go:110: Error 1040: Too many connections
FAIL
FAIL	github.com/smith-30/goparco/gorm/conn_pool	10.154s
FAIL
Error: Tests failed.
```

想定どおりの結果が出ていますね。次はコメントアウトしていた下記の部分をテストの処理に加えてみます。

```go
if tt.args.connCount > 100 {
	connPoolDB.DB().SetMaxOpenConns(100)
}
```

**期待する動作としては** `接続数が上限を超えているとき` のケースのときにエラーが出ないかつ、実行時間が10秒であること。(一つのクエリが接続上限の設定により待たされるため。)

**実行結果**

```
=== RUN   Test_maxConn
=== RUN   Test_maxConn/接続数が上限のとき
=== RUN   Test_maxConn/接続数が上限を超えているとき
--- PASS: Test_maxConn (15.11s)
    --- PASS: Test_maxConn/接続数が上限のとき (5.06s)
    --- PASS: Test_maxConn/接続数が上限を超えているとき (10.04s)
PASS
ok  	github.com/smith-30/goparco/gorm/conn_pool	15.125s
```

期待してた結果になりました。アプリケーションの想定同時接続数を意識しつつ設定していきたいですね。

### コネクションプール

次は、コネクションプールの設定を試してみます。これにより、内部で接続状態を持つことができるのでDBへの接続回数が減ります（はず）。その分処理のコストも減ると考えられます。以下のようにテストコードを書いて実験してみました。クエリを打てるgoroutineを5つセマフォで管理し実行。終わったgoroutineからセマフォが解放され、順次処理が行われていきます。とりあえず、1msec ~ 30msecかかるクエリを10個作り、処理を行わせてみました。コネクションプールの設定は、`SetMaxIdleConns` メソッドです。今回は `0` にしているので、dbへの接続は10回増える想定です。(ちなみに現状、sql.DBのデフォルトのコネクションプールの数は2でした。)

```go:main_test.go
func Test_useIdleConn(t *testing.T) {
	setting := genSetting("root", "root", "localhost", "13306", "test_database")
	connPoolDB := openDB(setting)
	connPoolDB.DB().SetMaxOpenConns(100)
	connPoolDB.DB().SetMaxIdleConns(0)
	connPoolDB.DB().SetConnMaxLifetime(time.Hour)

	sem := make(chan struct{}, 5)
	qs := getQueries(10)
	for _, item := range qs {
		sem <- struct{}{}
		go func(db *gorm.DB, item string) {
			defer func() {
				<-sem
			}()
			if err := fetch(db, item); err != nil {
				panic(err)
			}
		}(connPoolDB, item)
	}
}

func getQueries(num int) []string {
	qs := make([]string, 0, num)
	for index := 0; index < num; index++ {
		qs = append(qs, fmt.Sprintf("select sleep(%v)", random(0.001, 0.03)))
	}
	return qs
}
```

**実行前**

```
mysql> SHOW GLOBAL STATUS LIKE 'connections';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Connections   | 14177 |
+---------------+-------+
1 row in set (0.00 sec)
```


**実行後**

```
mysql> SHOW GLOBAL STATUS LIKE 'connections';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Connections   | 14187 |
+---------------+-------+
```

10回接続を試みたようですね。想定通り。
**次は、SetMaxIdleConns(5) としてコネクションプールの数を明示的に設定してみます。**期待する動作としては、goroutineの実行ごとに接続情報はプールに移り、それが使い回されるため、接続回数は5回以下になるはずですね。

**実行前**

```
mysql> SHOW GLOBAL STATUS LIKE 'connections';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Connections   | 14187 |
+---------------+-------+
```

**実行後**

```
mysql> SHOW GLOBAL STATUS LIKE 'connections';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Connections   | 14192 |
+---------------+-------+
1 row in set (0.00 sec)
```

接続回数は **5回増えただけでした。**標準パッケージなので当たり前ですが、うまく機能しているようで安心です。

### コネクションプールをつかったときのパフォーマンス比較

では、コネクションプールをつかったときと使わなかったときでどのくらいのパフォーマンスがでるのかということが気になったのでベンチマークを取りました。やっていることは上記のものと変わりません。SetMaxIdleConns(0)とSetMaxIdleConns(5)のケースで実行してみます。
ベンチマークの各指標については[こちらの記事](https://qiita.com/syossan27/items/148e33dd9da4ee3dc89b)がわかりやすいのでご覧ください

```go:main.go
func BenchmarkUseIdleConn(b *testing.B) {
	setting := genSetting("root", "root", "localhost", "13306", "test_database")
	connPoolDB := openDB(setting)
	connPoolDB.DB().SetMaxOpenConns(100)
	connPoolDB.DB().SetMaxIdleConns(0)
	connPoolDB.DB().SetConnMaxLifetime(time.Hour)

	b.Run("", func(b *testing.B) {
		sem := make(chan struct{}, 5)
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			sem <- struct{}{}
			go func(db *gorm.DB) {
				defer func() {
					<-sem
				}()
				if err := fetch(db, "select sleep(0.01)"); err != nil {
					panic(err)
				}
			}(connPoolDB)
		}

	})
	connPoolDB.Close()
}
```

**SetMaxIdleConns(0)のケース**

```
goos: darwin
goarch: amd64
pkg: github.com/smith-30/goparco/gorm/conn_pool
BenchmarkUseIdleConn/#00-8         	     296	   4292555 ns/op	   10379 B/op	      90 allocs/op
PASS
ok  	github.com/smith-30/goparco/gorm/conn_pool	1.700s
```

接続回数増分: 400

**SetMaxIdleConns(5)のケース**

```
goos: darwin
goarch: amd64
pkg: github.com/smith-30/goparco/gorm/conn_pool
BenchmarkUseIdleConn/#00-8         	     500	   2502588 ns/op	    2173 B/op	      23 allocs/op
PASS
ok  	github.com/smith-30/goparco/gorm/conn_pool	1.515s
```

接続回数増分: 11

処理速度や、メモリ使用量、メモリ割り当て回数に優位な改善が見られました。設定しておいて損はなさそうですね。DBの接続回数においては圧倒的に減っているのでDBにも優しい処理になるのではないでしょうか。
ちなみに、都度gorm.DBを作ってしまっている場合はどうなんでしょうか。ついでに検証してみます。goroutineの中で都度DBへの接続を開くように変更しました。*gorm.DBの生成コストもかかってしまっています。

```go:main_test.go
func BenchmarkUseIdleConn(b *testing.B) {
	setting := genSetting("root", "root", "localhost", "13306", "test_database")
	b.Run("", func(b *testing.B) {
		sem := make(chan struct{}, 5)
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			sem <- struct{}{}
			go func() {
				defer func() {
					<-sem
				}()
				connPoolDB := openDB(setting)
				connPoolDB.DB().SetMaxOpenConns(100)
				connPoolDB.DB().SetMaxIdleConns(0)
				connPoolDB.DB().SetConnMaxLifetime(time.Hour)
				defer connPoolDB.Close()
				if err := fetch(connPoolDB, "select sleep(0.01)"); err != nil {
					panic(err)
				}
			}()
		}
	})
}
```

**実行結果**

```
goos: darwin
goarch: amd64
pkg: github.com/smith-30/goparco/gorm/conn_pool
BenchmarkUseIdleConn/#00-8         	     206	   5247975 ns/op	   20418 B/op	     177 allocs/op
PASS
ok  	github.com/smith-30/goparco/gorm/conn_pool	1.683s
Success: Benchmarks passed.
```

接続回数増分: 606

さらにパフォーマンスが落ちてしまいました。*gorm.DBでオブジェクトを手にしたあとは、使い回すのが適切なようですね。

## おわりに

mysql の global status や process list は今まで打つことがなかったので新しく勉強になりました。ベンチマークはどうすればうまく計測できるかなと結構試行錯誤しましたが、結果的にはうまく動いてくれてよかったです。goはテストやベンチマークが簡単にとれるので色々検証しやすくて助かってます。小さなことでも計測すれば何かしら気づきがあるので今後も続けていきたいです。
21日目は @mochibuta  による [golandの設定紹介](https://qiita.com/mochibuta/items/f767efcacc186923d1e0) です。

今回検証したコードは[こちら](https://github.com/smith-30/goparco/tree/master/gorm)