every Tech Blog

株式会社エブリーのTech Blogです。

Go の database/sql における MySQL セッション変数の挙動とコネクション固定

はじめに

こんにちは、デリッシュキッチンのバックエンドエンジニアの鈴木です。

先日、プロダクトのGoのバージョンを 1.25.4 から 1.26.0 へアップデートしたところ、CI上の自動テストが一部落ちる(失敗する)問題に直面しました。 原因を調べてみると、テストデータの初期化で使っている TRUNCATE 処理において、これまで発生していなかった外部キー制約(Foreign Key Constraint)のエラーが頻発していることがわかりました。

コード自体はいじっていないにもかかわらず、なぜGoのバージョンを上げただけでデータベース操作が失敗するようになったのか。本記事では、このエラーの調査を通して改めて気付かされた、Goの database/sql パッケージにおけるコネクションプールの仕様と、安全なコネクション管理について共有します。

概要

  • Goの database/sql は内部でコネクションプーリングを行っており、db.Exec() などのクエリ実行ごとに、プールからアイドル状態のコネクションを動的に取得・返却する仕組みになっている。
  • 一方、MySQLの SET foreign_key_checks = 0 のような設定は、同一セッション(コネクション)内でのみ有効。
  • そのため、db.Exec() を連続して呼んでも、同じコネクションで実行される保証はなく、別のコネクションが割り当てられると設定が反映されずにエラーになる。
  • 解決策として、db.Conn() を使ってコネクションを明示的に取得(占有)し、一連の処理が終わるまで同じコネクションを使い回す必要がある。

Go 1.26.0 へのアップデートと TRUNCATE の失敗

Goを 1.26.0 に上げたタイミングで、テストのクリーンアップ処理(テーブルのTRUNCATE)において、MySQLから以下のエラーが返るようになりました。

Error 1701: Cannot truncate a table referenced in a foreign key constraint ...

MySQLで外部キー制約が張られているテーブルを TRUNCATE する場合、一時的に SET foreign_key_checks = 0 を実行して制約を無効化するのが一般的です。私たちのコードでもこの処理を入れていたはずですが、なぜか制約違反のエラーが発生していました。

問題となった実装

エラーが発生していた箇所のコードです。標準の *sql.DB を使って、3つのクエリを順番に実行していました。

// 外部キー制約を一時的に無効化してTRUNCATEを実行する実装
func (e *DBEngine) TruncateTable(tableName string) error {
    // 1. 制約チェックの無効化
    if _, err := e.db.Exec("SET foreign_key_checks = 0"); err != nil {
        return err
    }

    // 2. TRUNCATEの実行(ここで Error 1701 が発生)
    if _, err := e.db.Exec("TRUNCATE TABLE " + tableName); err != nil {
        return err
    }

    // 3. 制約チェックの有効化
    if _, err := e.db.Exec("SET foreign_key_checks = 1"); err != nil {
        return err
    }

    return nil
}

一見すると上から順番に実行されるため問題なさそうに見えますが、この実装は各クエリが別々のコネクションで実行される可能性を考慮できていませんでした。

原因:コネクションプールとセッション変数の仕様の違い

今回の問題は、MySQLのセッション変数の仕様と、Goのコネクションプールの挙動のミスマッチが原因でした。

MySQLのセッション変数

MySQLの SET foreign_key_checks はセッション変数であり、その設定は現在のセッション(コネクション)内でのみ有効です。別のコネクションから繋ぎ直した場合、デフォルトの設定(通常は有効)に戻ってしまいます。

Goの database/sql の挙動

Goの db.Exec() は、実行されるたびにコネクションプールから空いているコネクションを1つ取得し、クエリを実行し終えるとすぐにプールへ返却します。 つまり、コード上で連続して db.Exec() を書いても、同じコネクションで実行される保証はどこにもありません。

内部では以下のように、コネクションのすれ違いが発生していました。

fig.1: コネクションが切り替わることで設定が引き継がれずエラーになるフロー

なぜ今までエラーにならなかったのか?

これまでの環境(Go 1.25.4)では、このコードでも特にエラーは起きていませんでした。しかしこれは仕様として保証されていたわけではなく、単なる実行タイミングの偶然でした。

これまでは、以下の流れがたまたま成立していました。

  1. SET foreign_key_checks = 0 を実行。
  2. 使い終わったコネクションが即座にプールへ返却される。
  3. 直後の TRUNCATE でプールからコネクションを取得する際、たった今返却されたばかりのコネクション(設定変更済み)がそのまま使い回される。

このように、他に並行して走っているクエリがない限り、実質的に同じコネクションが連続して割り当てられやすい状態になっていたに過ぎません。

なぜ Go 1.26.0 で顕在化したのか?

Go 1.26.0 では、ランタイムのパフォーマンスが大きく向上しています。特に、デフォルトで有効化された新しいガベージコレクタ Green Tea GCによるスキャン待ち時間の削減や、メモリアロケーションの高速化などが含まれています。

こうしたランタイムの最適化によって、プログラム全体の実行速度やゴルーチンの切り替わりといった、マイクロ秒単位のスケジュールタイミングが微妙に変化しました。

その結果、SET クエリを実行してコネクションが完全にプールへ戻る前に、次の TRUNCATE の処理が走り出し、プール側がいま空いている別のコネクション(設定変更されていないもの)を割り当ててしまうケースが増加したと考えられます。

つまり、Goのバージョンアップによるバグではなく、Goのランタイムが高速化・効率化されたことで、アプリケーション側に潜んでいた実装上の不備が表面化したというのが真相です。

解決策:sql.Conn を使ってコネクションを固定する

同じコネクションを使って一連のクエリを確実に実行するには、Go 1.9から導入された db.Conn(ctx) を使用します。これを使うことで、プールから特定のコネクションを明示的に取得(占有)できます。

修正後の実装

func (e *DBEngine) TruncateTable(ctx context.Context, tableName string) error {
    // 1. コネクションを明示的に取得(チェックアウト)する
    conn, err := e.db.Conn(ctx)
    if err != nil {
        return err
    }
    // 使い終わったら必ずプールへ返却する
    defer conn.Close()

    // 2. 確保した同一のコネクション(conn)に対してクエリを実行する
    if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 0"); err != nil {
        return err
    }

    // 同じコネクションなので、設定が反映された状態で実行できる
    if _, err := conn.ExecContext(ctx, "TRUNCATE TABLE " + tableName); err != nil {
        return err
    }

    if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 1"); err != nil {
        return err
    }

    return nil
}

sql.Conn オブジェクトに対してメソッドを呼び出し、処理が終わった後に Close() を呼ぶことで、セッション変数の設定を維持したまま安全にクエリを実行できるようになります。

まとめ

MySQLの SET 構文のようなセッション依存の設定を行う場合、単純な db.Exec() の連続呼び出し(コネクションプール任せ)にしてはいけません。必ず sql.Conn などを使い、明示的にコネクションを占有して処理を行う必要があります。

今回のケースのように、言語やランタイムのパフォーマンスが向上した結果、これまでたまたま動いていたコードの潜在的なバグが顕在化することがあるため、仕様を正しく理解して実装することの重要性を再認識しました。