LoginSignup
12
7

More than 3 years have passed since last update.

Go database/sql(コネクションプール/タイムアウト)

Last updated at Posted at 2020-04-04

はじめに

本記事は、前回の続きで、database/sqlに関するメモです。

  • コネクションプール
  • クエリーのタイムアウト

DBMSにはpostgreSQLを使っています。

コネクションプール sql.DB

コネクションプールってどうやるんだっけと調べてみました。
ドキュメントを確認していると、sql.Openに該当する記述を見つけました。

The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB.

「sql.Openで得たDBはコネクションプールなので、頻繁にOpen/Closeする必要は無い。」ということのようです。
なるほどと思い、DBでコネクションプールの設定関連調べたところ、下表のメソッドが用意されていました。

メソッド名 概要
func (db *DB) SetMaxOpenConns(n int) 接続の最大数を設定。 nに0以下の値を設定で、接続数は無制限。
func (db *DB) SetMaxIdleConns(n int) コネクションプールの最大接続数を設定。
func (db *DB) SetConnMaxLifetime(d time.Duration) 接続の再利用が可能な時間を設定。dに0以下の値を設定で、ずっと再利用可能。

それぞれどのように設定するのが、良いのかと色々調べてて、以下のサイトを見つけました。

DSAS開発者の部屋 Re: Configuring sql.DB for Better Performance

こちらのサイトにとても分かりやすく解説してあります。結論の部分だけ引用させて頂きますが、「SetConnMaxLifetime を使う他の理由」の部分も必読なので、興味のある方は是非ご一読ください。

・SetMaxOpenConns() は必ず設定する。負荷が高くなってDBの応答が遅くなったとき、新規接続してさらにクエリを投げないようにするため。できれば負荷試験をして最大のスループットを発揮する最低限のコネクション数を設定するのが良いが、負荷試験をできない場合も max_connection やコア数からある程度妥当な値を判断するべき。
・SetMaxIdleConns() は SetMaxOpenConns() 以上に設定する。アイドルな接続の解放は SetConnMaxLifetime に任せる。
・SetConnMaxLifetime() は最大接続数 × 1秒 程度に設定する。多くの環境で1秒に1回接続する程度の負荷は問題にならない。1時間以上に設定したい場合はインフラ/ネットワークエンジニアによく相談すること。

というわけで、以下のように実装してみた。

func setupDB(dbDriver string, dsn string) (*sql.DB, error) {
    db, err := sql.Open(dbDriver, dsn)
    if err != nil {
        return nil, err
    }
    db.SetMaxIdleConns(10)
    db.SetMaxOpenConns(10)
    db.SetConnMaxLifetime(10 * time.Second)

    return db, err
}

SetMaxIdleConnsとSetMaxOpenConnsに設定する値の関係ですが、SetMaxOpenConnsの実装が以下のようになっていることを考えると、コネクションプールを使う場合は、同じ値を設定しておくと無難なのかなと思います。

func (db *DB) SetMaxOpenConns(n int) {
    db.mu.Lock()
    db.maxOpen = n
    if n < 0 {
        db.maxOpen = 0
    }
    syncMaxIdle := db.maxOpen > 0 && db.maxIdleConnsLocked() > db.maxOpen
    db.mu.Unlock()
    if syncMaxIdle {
        db.SetMaxIdleConns(n)
    }
}

タイムアウト context.Context

contextを使うと、WithTimeoutで指定した時間を経過した場合に、クエリーの実行を中断させることが可能です。

// 10秒でタイムアウト
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

以下は実際の呼び出し例です。
タイムアウトの設定は、タイムアウトが発生しやすいように、1ナノ秒を設定してます。

func selectUserByName(tx *sql.Tx, ctx context.Context, name string) (*User, error) {
    u := &User{}
    if err := tx.QueryRowContext(ctx, "select * from t_user where name=$1", name).Scan(&u.ID, &u.Name, &u.profile, &u.Created, &u.Updated); err != nil {
        log.Fatal(err)
    }
    return u, nil
}

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
fmt.Println(selectUserByName(tx, ctx, "taka499999"))

QueryRowContextがタイムアウトによってエラーとなると、postgreSQLの場合であれば以下のようなメッセージが表示されます。

2020/04/02 10:17:25 pq: ユーザからの要求により文をキャンセルしています

ちなみに、WithTimeoutの設定をQueryContextの結果が正しく返却されるに十分な100ミリ秒とし、その直後に1秒のスリープをいれてQueryContextを呼び出した場合、「context deadline exceeded」というメッセージと共にエラーになりました。

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    time.Sleep(1 * time.Second)

期待する動作としては、QueryContextを呼び出してからWithTimeoutで設定した時間経過したらタイムアウトエラーになってほしいのですが、実際はそうではなさそうです。

タイムアウトはどこを起点としているかってことになるので、WithTimeoutを調べてみました。
結論として、起点となるのは、WithTimeoutを呼び出した日時とパラメータのtimeoutを加算した日時です。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

ひとまずQueryContextを呼び出す直前にWithTimeoutを呼び出すようにしたとしても、クエリーごとにその実装を行うのは何か違うような気もしますし、QueryContextのエラーの原因がタイムアウト以外にあった場合に、エラーハンドリングが面倒なので、Goらしい実装ってどんなんだろっていうのが気になってます。

参考文献

この記事は以下の情報を参考にして執筆しました。

-https://golang.org/pkg/database/sql/
-DSAS開発者の部屋 Re: Configuring sql.DB for Better Performance

12
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7