Goのsql.DBは、いつプールに戻しているのか
はじめに
鍵を閉め忘れたような気がして心配になるように、リソースを後始末していたか心配になることはありませんか。
「習慣的に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
}
確認するプールの各ステータスです。
それでは実行してみます。
{"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()
になっていました。
ここについては実装のみでログ出力は割愛します。
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での実行を想定
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()
したら defer
で rows.Close()
しておけば問題はなさそうです。
関連ドキュメント
さいごにGoのドキュメントから抜粋して参照してみます。
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 でプールに戻すが正しい使い方のようです。
パッケージのドキュメントにも「プールに戻す」と真っ先に明記されています。
自明なことではありますが、取得した時点で使用後に戻す責務が生じていることになります。
まとめ
大半のケースでは明示的にコネクションを取得せず sql.DB に任せておけば、効率的なプールの管理の恩恵を受けられるように考えつくされたデザインになっているようです。
- *sql.DB と *sql.Conn はプールへの返却を明示的に操作したいかどうかという基準で使い分ける
- どちらでトランザクションを開始(
BeginTx()
)したかは *sql.Tx のCommit()
とRollback()
でプールに返却されるかにも波及する
Discussion