なぜこのエラーが発生するのか
Goにおいて、トランザクションハンドル(*sql.Tx)は使い捨てのオブジェクトです。一度Commit()またはRollback()を呼び出すと、トランザクションは終了し、基盤となるデータベース接続はプールに戻されます。同じハンドルを再度Exec、Query、あるいは別のCommitに使用しようとすると、Goはこの特定のエラーを発生させます。
これは物理的な契約書のようなものだと考えてください。一度署名されるか破棄されたら、そこに新しい条項を追加することはできません。その後の作業には、新しいトランザクションを開始する必要があります。
主な原因
このエラーのデバッグには、通常tx変数のライフサイクルを追跡することが含まれます。ほとんどの開発者は、次の3つのパターンのいずれかでこの問題に直面します。
1. deferの実行順序による競合
これが最も頻繁な原因です。関数の最後で正常にtx.Commit()を呼び出している一方で、冒頭にdefer tx.Rollback()を記述している場合があります。database/sqlはコミット後にRollback()を呼び出しても何もしない(no-op)ように設計されていますが、deferブロック内の特定の例外チェックロジックがsql.ErrTxDoneシグナルをキャッチし、それを失敗として扱ってしまうことがあります。
2. 条件分岐ロジックのミス
複雑なif-else構造では、二重終了のバグが隠れがちです。例えば、ifブロック内でコミットしたのに、直後にreturnするのを忘れた場合などです。コードはそのまま進み、2回目のデータベース呼び出しや関数末尾の最終的なCommit()を実行しようとして、エラーを引き起こします。
3. ループ内での失敗
1つのトランザクション内で100行の処理を行っており、5行目でRollback()が発生した場合、6行目の処理は失敗します。これはデータの問題ではなく、使用しようとしているトランザクションがすでに終了しているために発生します。
実績のある解決策
解決策1:標準的な慣用的パターン
トランザクションを処理する最も堅牢な方法は、安全のためにdeferを使用し、成功時の最後の1行でのみコミットすることです。途中でエラーが発生した場合、関数はリターンされ、deferによってデータベースの接続が放置されるのを防ぎます。
func adjustBalance(db *sql.DB, userID int, amount float64) error {
tx, err := db.Begin()
if err != nil {
return err
}
// これはセーフティネットです。tx.Commit()が先に呼ばれた場合は何もしません。
defer tx.Rollback()
// 例:ユーザーアカウントから50.00ドルを差し引く
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, userID)
if err != nil {
return err
}
// 処理を確定させます。この行の後、トランザクションは閉じられます。
return tx.Commit()
}
解決策2:クロージャによるサイレントなロールバック
ロールバック中のエラーをログに記録したいが、「すでにコミット済み」というノイズを避けたい場合は、クロージャを使用します。このパターンでは、「すでにコミットまたはロールバック済み」を意味する内部エラー名であるsql.ErrTxDoneを無視します。
defer func() {
err := tx.Rollback()
if err != nil && err != sql.ErrTxDone {
log.Printf("実際のロールバックエラー: %v", err)
}
}()
解決策3:分岐ロジックのガード
Commit()の呼び出しには必ずreturnステートメントをセットにしてください。関数が長くなりすぎる場合(例:50行以上)、ビジネスロジックをより小さな関数に分割しましょう。それらのサブ関数にtxオブジェクトを渡すことで、メイン関数がトランザクションのライフサイクルを管理する唯一の「オーナー」であり続けるようにします。
修正の確認方法
修正したと思い込まず、特に以下の2つのシナリオをテストしてください。
- **正常系(Happy Path):** 操作を正常に実行し、データベースを確認します。`Commit()`が実行され、コンソールにエラーが表示されていないことを確認してください。
- **異常系(Failure Path):** 意図的にデータベースエラー(一意制約違反など)を発生させます。`Rollback()`が実行され、ログに「transaction already committed」という二次的なエラーではなく、*元の*データベースエラーが表示されていることを確認してください。
重要なポイント
- **使い切り:** すべての`*sql.Tx`を使い捨てのツールとして扱います。終了したら破棄してください。
- **早めにdeferする:** パニック時の接続リークを防ぐため、`db.Begin()`の直後に`defer tx.Rollback()`を配置します。
- **ErrTxDoneのチェック:** 本番環境のログをきれいに保つため、この特定のエラーをロギングから除外します。
- **短く保つ:** 行のロックやロジックの罠を避けるため、トランザクションは数秒ではなく数ミリ秒で終わるように設計してください。

