Help us understand the problem. What is going on with this article?

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

テックタッチアドベントカレンダー20日目担当する@smith-30です。
19日目は@kosy による Vueで日本全国ダーツの旅的なものを作ってみた でした。遊んでみたら僕は福島に行けと言われました。
弊社はまだエンジニア/デザイナが少数なので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が返されます。

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

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 |
+------+------+------------------+---------------+---------+------+-----------+----------------------------------------------+
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.

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

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でした。)

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)のケースで実行してみます。
ベンチマークの各指標についてはこちらの記事がわかりやすいのでご覧ください

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の生成コストもかかってしまっています。

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の設定紹介 です。

今回検証したコードはこちら

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした