🔐

Goのsql.DBは、いつプールに戻しているのか

2024/09/10に公開

はじめに

鍵を閉め忘れたような気がして心配になるように、リソースを後始末していたか心配になることはありませんか。
「習慣的にdeferでCloseしてるからリークしないはずたけど。あれ?トランザクションはしてなかったかも。ん?Closeないの!?」
Goの標準ライブラリの database/sql で中心となる sql.DB ではデフォルトでコネクションプールが使われるようになっていて明示的な操作は不要です。
ではいったい、どのタイミングでプールに返却されているのか、パターンを整理してみました。

準備

挙動の確認でINSERTやSELECTを実行するため、PostgreSQLに簡易なテーブルを作成しました。

CREATE TABLE shop (
  id serial PRIMARY KEY,
  name text NOT NULL,
  created_at timestamp with time zone NOT NULL
);

*sql.Conn

最初は直感的に把握しやすそうな *sql.Conn の Close() で返却されるパターンです。
トランザクションありで連続してINSERTを2回実行する実装です。

package main

import (
	"context"
	"database/sql"
	"errors"
	"log"
	"log/slog"
	"os"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

func Example() error {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	db, err := sql.Open("pgx", "postgres://postgres:passwd@localhost:5432/postgres?sslmode=disable&TimeZone=Asia/Tokyo")
	if err != nil {
		return err
	}
	defer func() {
		if err := db.Close(); err != nil {
			slog.WarnContext(ctx, "Close", "err", err)
		}
	}()

	now := time.Now()

	conn, err := db.Conn(ctx)
	if err != nil {
		return err
	}
	defer func() {
		// ここでプールに返却
		if err := conn.Close(); err != nil {
			slog.WarnContext(ctx, "Close", "err", err)
		}
	}()

	// トランザクション開始
	tx, err := conn.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() {
		// ロールバック
		if err := tx.Rollback(); !errors.Is(err, sql.ErrTxDone) {
			slog.WarnContext(ctx, "Rollback", "err", err)
		}
	}()

	// INSERT
	if _, err = tx.ExecContext(ctx,
		"INSERT INTO shop (name, created_at) VALUES ($1, $2)",
		"shop1", now,
	); err != nil {
		return err
	}

	// INSERT
	if _, err = tx.ExecContext(ctx,
		"INSERT INTO shop (name, created_at) VALUES ($1, $2)",
		"shop2", now,
	); err != nil {
		return err
	}

	// コミット
	if err = tx.Commit(); err != nil {
		return err
	}

	return nil
}

func main() {
	if err := Example(); err != nil {
		log.Fatal(err)
	}
}

実際にコネクションプールの状態をログ出力させて確認してみたいと思います。
ここからは処理の流れを追いやすくするため、関心事だけに絞って実装します。

func Example() error {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	db, err := sql.Open("pgx", "postgres://postgres:passwd@localhost:5432/postgres?sslmode=disable&TimeZone=Asia/Tokyo")
	if err != nil {
		return err
	}
	defer db.Close()

	now := time.Now()

	conn, _ := db.Conn(ctx)
	tx, _ := conn.BeginTx(ctx, nil)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop2", now)
	tx.Commit()
	tx.Rollback() // sql.ErrTxDoneを無視。Commit後にdeferでの実行を想定

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	conn.Close() // ここでプールに返却。ここもdeferでの実行を想定

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	return nil
}

確認するプールの各ステータスです。

https://github.com/golang/go/blob/go1.23.1/src/database/sql/sql.go#L1193-L1196

それでは実行してみます。

{"time":"2024-09-10T12:12:36.181796186+09:00","level":"INFO","msg":"before","Open":1,"InUse":1,"Idle":0}
{"time":"2024-09-10T12:12:36.181937016+09:00","level":"INFO","msg":"after ","Open":1,"InUse":0,"Idle":1}

出力されたログを見ると conn.Close() の前後でInUseからIdleに移っています。
つまりプールに返却されていることが確認できました。

*sql.Tx

つぎは *sql.DB で直接 BeginTx() した場合をみていきます。*sql.Tx には Close() がありません。
だからといって db.Close() してしまったら、コネクションが破棄されしまい元も子もありません。
では、いつ戻されるのでしょうか。そのタイミングは Commit() もしくは Rollback() になっていました。
ここについては実装のみでログ出力は割愛します。

Commit
	tx, _ := db.BeginTx(ctx, nil)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop2", now)
	tx.Commit()   // ここで返却
	tx.Rollback() // sql.ErrTxDoneを無視。Commit後にdeferでの実行を想定
Rollback
	tx, _ := db.BeginTx(ctx, nil)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop2", now)
	tx.Rollback() // ここで返却

ところで、一つの疑問がわいてきます。最初のパターンの中でも tx.Commit()tx.Rollback() を実行していました。
もしかすると、そのときも返却されてしまっていたのでしょうか。ログを出力させて、もう一度確認してみます。

	conn, _ := db.Conn(ctx)
	tx, _ := conn.BeginTx(ctx, nil)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)
	tx.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop2", now)

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	errCommit := tx.Commit() // ここでは返却されない

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle, "err", errCommit)

	tx.Rollback() // sql.ErrTxDoneを無視。ここでも返却されない

	conn.Close() // ここで返却
{"time":"2024-09-10T12:13:18.021321377+09:00","level":"INFO","msg":"before","Open":1,"InUse":1,"Idle":0}
{"time":"2024-09-10T12:13:18.022770877+09:00","level":"INFO","msg":"after ","Open":1,"InUse":1,"Idle":0,"err":null}

ログを見ると InUse に残ったままで返却されていません。もちろんエラーも発生していません。
*sql.Conn から BeginTx() したときは Commit()Rollback() では返却されないという違いがあることがわかりました。
プールへ返却するタイミングを制御したいときは db.Conn() で *sql.Conn を取得することになりそうです。

*sql.DB

コネクションもトランザクションも使わない、残りのパターンについても、どうなるか要点を記載していきます。

ExecContext

*sql.DB の ExecContext() では、そのつど返却されていました。

	// 1回目
	db.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	// 2回目
	db.ExecContext(ctx, "INSERT INTO shop (name, created_at) VALUES ($1, $2)", "shop1", now)

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)
{"time":"2024-09-10T12:13:49.153025371+09:00","level":"INFO","msg":"before","Open":1,"InUse":0,"Idle":1}
{"time":"2024-09-10T12:13:49.156507279+09:00","level":"INFO","msg":"after ","Open":1,"InUse":0,"Idle":1}

QueryRowContext

*sql.DB の QueryRowContext()row.Scan() のタイミングで返却されました。

	// 1レコードだけ取得
	row := db.QueryRowContext(ctx, "SELECT id, name FROM shop ORDER BY id LIMIT 1")

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	var id int32
	var name string
	row.Scan(&id, &name) // ここで返却

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle, "id", id, "name", name)
{"time":"2024-09-10T12:14:18.114116303+09:00","level":"INFO","msg":"before","Open":1,"InUse":1,"Idle":0}
{"time":"2024-09-10T12:14:18.114284424+09:00","level":"INFO","msg":"after ","Open":1,"InUse":0,"Idle":1,"id":1,"name":"shop1"}

QueryContext

*sql.DB の QueryContext()rows.Next() が false になったタイミングで返却されました。
処理の流れを追いやすくするため、for文を使わずにループを展開して実装します。

	// 2レコード取得
	rows, _ := db.QueryContext(ctx, "SELECT id, name FROM shop ORDER BY id LIMIT 2")

	var id int32
	var name string

	// 1レコード目
	rows.Next() // (trueなので)ここでは返却されない
	rows.Scan(&id, &name)

	// 2レコード目
	rows.Next() // (trueなので)ここでは返却されない
	rows.Scan(&id, &name)

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle, "id", id, "name", name)

	rows.Next() // (falseなので)ここで返却

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)

	rows.Close() // ここではない
{"time":"2024-09-10T12:14:48.961659094+09:00","level":"INFO","msg":"before","Open":1,"InUse":1,"Idle":0,"id":2,"name":"shop2"}
{"time":"2024-09-10T12:14:48.961880508+09:00","level":"INFO","msg":"after ","Open":1,"InUse":0,"Idle":1}

もし仮に false になるまで rows.Next() を呼ばなかった場合ですが、そのときは rows.Close() のタイミングで返却されました。

	// 2レコード取得
	rows, _ := db.QueryContext(ctx, "SELECT id, name FROM shop ORDER BY id LIMIT 2")

	var id int32
	var name string

	// 1レコード目
	rows.Next() // (trueなので)ここでは返却されない
	rows.Scan(&id, &name)

	stats := db.Stats()
	slog.Info("before", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle, "id", id, "name", name)

	rows.Close() // ここで返却

	stats = db.Stats()
	slog.Info("after ", "Open", stats.OpenConnections, "InUse", stats.InUse, "Idle", stats.Idle)
{"time":"2024-09-10T12:15:09.62954833+09:00","level":"INFO","msg":"before","Open":1,"InUse":1,"Idle":0,"id":1,"name":"shop1"}
{"time":"2024-09-10T12:15:09.62968931+09:00","level":"INFO","msg":"after ","Open":1,"InUse":0,"Idle":1}

通常は for rows.Next() にして、そのループ内でエラーがあればアーリーリターンするのが慣用的になっているかと思います。
いつもどおり db.QueryContext() したら deferrows.Close() しておけば問題はなさそうです。

関連ドキュメント

さいごにGoのドキュメントから抜粋して参照してみます。

https://go.dev/doc/database/manage-connections

But for some advanced programs, you might need to tune the connection pool parameters or work with connections explicitly.

The package returns the connection to the pool when it’s no longer needed.

DB.Conn obtains a dedicated connection, an sql.Conn.

When finished with the dedicated connection, your code must release it using Conn.Close.

不要になったらプールに戻すという点だけで、管理している sql.DB が何をもって不要と判断するまでは明記されていないようですが、いずれにしても sql.Conn は高度な用途向けに用意されたもののようです。そして、 Conn.Close でプールに戻すが正しい使い方のようです。
パッケージのドキュメントにも「プールに戻す」と真っ先に明記されています。

https://github.com/golang/go/blob/go1.23.1/src/database/sql/sql.go#L2139-L2146

自明なことではありますが、取得した時点で使用後に戻す責務が生じていることになります。

https://github.com/golang/go/blob/go1.23.1/src/database/sql/sql.go#L1935-L1937

まとめ

大半のケースでは明示的にコネクションを取得せず sql.DB に任せておけば、効率的なプールの管理の恩恵を受けられるように考えつくされたデザインになっているようです。

  • *sql.DB と *sql.Conn はプールへの返却を明示的に操作したいかどうかという基準で使い分ける
  • どちらでトランザクションを開始( BeginTx() )したかは *sql.Tx のCommit()Rollback() でプールに返却されるかにも波及する

Discussion