go-mssqldb を用いたトランザクション

トランザクションは複数の操作を原子単位にまとめます。 すべての操作が成功する(コミット)か、いずれも効果を発揮しない(ロールバック)かのどちらかです。 この記事では、 go-mssqldb ドライバでのトランザクションの使い方、分離レベル、エラー処理、本番アプリケーション向けのパターンについて説明します。

この記事の例は AdventureWorks2025 のサンプルデータベースと比較しています。 書き込み指向の例は HumanResources.DepartmentProduction.ProductCategoryProduction.ProductSubcategoryProduction.ProductInventoryを対象としています。

取引開始

db.BeginTxを使って取引を開始しましょう。 返された *sql.Tx は、トランザクションの有効期間中、プール内の 1 つの接続に固定されます:

tx, err := db.BeginTx(ctx, nil) // nil uses the default isolation level
if err != nil {
    return err
}
defer tx.Rollback() // No-op if tx.Commit() succeeds first.

_, err = tx.ExecContext(ctx, "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
    sql.Named("p1", name),
    sql.Named("p2", groupName))
if err != nil {
    return err
}

return tx.Commit()

Important

必ずdefer tx.Rollback()の直後にBeginTxを呼び出してください。 Commit()が成功した場合、遅延されたRollback()は何も行いません。 Commit()より前に何らかのエラーが発生しても、遅延されたRollback()によって、トランザクションが開いたままにならず、接続が未コミット状態のままプールに戻されてしまうのを防げます。

分離レベル

SQL Serverは、同時トランザクションの相互作用を制御する複数の分離レベルをサポートしています。 隔離レベルを sql.TxOptionsで設定します:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

絶縁レベル比較

分離レベル ダーティ リード 反復不可能な読み取り ファントム読み取り パフォーマンスへの影響 次の場合に使用します。
sql.LevelReadUncommitted はい はい はい 最低オーバーヘッド おおよそのカウント、ダッシュボードの監視。 データの正確さは重要ではありません。
sql.LevelReadCommitted いいえ はい はい Default. ほとんどの作業負荷には適しています。 一般的なOLTPの業務量。 デフォルトで推奨される出発点です。
sql.LevelRepeatableRead いいえ いいえ はい 中程度。 髪を長く保つことができます。 トランザクション内の同じ行に対して一貫した値が必要なリードです。
sql.LevelSerializable いいえ いいえ いいえ 最高。 レンジロックは同時挿入をブロックします。 金融取引や在庫管理など、ファントムリードが許容されないあらゆる場面で。
sql.LevelSnapshot いいえ いいえ いいえ tempdbで行バージョン管理を使用しています。 ブロックは禁止。 書き込み処理をブロックすることなく特定時点の一貫性を必要とする、読み取り負荷の高いワークロード。

Note

sql.LevelSnapshot データベース上でスナップショット隔離を有効にする必要があります: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON

例:Read committedとserializableの比較

アイソレーションレベルを sql.TxOptionsで指定します:

// Read Committed (default) - suitable for most operations.
tx1, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

// Serializable - prevents phantom reads in financial calculations.
tx2, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})

読み取り専用ルーティング

ドライバーは sql.TxOptions.ReadOnlyをサポートしていません。 ReadOnly: trueパスすると、BeginTxエラーを返します。

AlwaysOnの読み取り専用ルーティングの場合、接続を開く際に接続文字列でapplicationintent=ReadOnlyを設定します:

sqlserver://listener.example.com?database=AdventureWorks2025&applicationintent=ReadOnly

これはコネクションレベルの設定です。 対象となるセッションを読み取れるセカンダリにルーティングすることはできますが、既存のトランザクションを読み専用にはしません。 データを書き込んではいけないワークロードには、専用の読み取り専用接続や最小権限認証情報を使用してください。

トランザクションにおけるエラー処理

取引内のエラーを慎重に扱いましょう。 いずれかのステートメントが失敗した場合、トランザクション全体をロールバックしなければなりません。 エラーの後に他の発言を続けようとしないでください:

func createCategory(ctx context.Context, db *sql.DB, category Category) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback()

    var categoryId int64
    err = tx.QueryRowContext(ctx, `
        INSERT INTO Production.ProductCategory (Name)
        OUTPUT INSERTED.ProductCategoryID
        VALUES (@name)`,
        sql.Named("name", category.Name)).Scan(&categoryId)
    if err != nil {
        return fmt.Errorf("insert category: %w", err)
    }

    // Insert subcategories under the new category.
    for _, sub := range category.Subcategories {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name)
            VALUES (@catId, @name)`,
            sql.Named("catId", categoryId),
            sql.Named("name", sub.Name))
        if err != nil {
            return fmt.Errorf("insert subcategory %s: %w", sub.Name, err)
        }
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit category: %w", err)
    }
    return nil
}

バッチやストアドプロシージャが SET XACT_ABORT ONで実行されている場合、すべての文エラーをトランザクションのターミナルとして扱います。 すぐに巻き戻し、これ以上発言や Commit()を試みないようにしましょう。 現在のドライバリリースでは、サーバー中止されたトランザクションを検出し、サイレント部分コミットを許可する代わりにエラーを返します。

セーブポイント

セーブポイントはトランザクション内で中間ロールバックポイントを作成します。 SQL Serverはセーブポイントをネイティブにサポートしています。 Goの database/sql パッケージはセーブポイントを直接公開しないため、トランザクションを通じて生のSQLとして実行してください:

func createCategoryWithOptionalSubcategory(ctx context.Context, db *sql.DB, category Category) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    err = tx.QueryRowContext(ctx,
        "INSERT INTO Production.ProductCategory (Name) OUTPUT INSERTED.ProductCategoryID VALUES (@p1)",
        sql.Named("p1", category.Name)).Scan(&category.Id)
    if err != nil {
        return err
    }

    // Try to add a subcategory. If it fails, roll back only the subcategory part.
    _, err = tx.ExecContext(ctx, "SAVE TRANSACTION AddSubcategory")
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx,
        "INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name) VALUES (@p1, @p2)",
        sql.Named("p1", category.Id),
        sql.Named("p2", category.DefaultSubcategory))
    if err != nil {
        // Roll back only the subcategory; the category insert is preserved.
        _, rbErr := tx.ExecContext(ctx, "ROLLBACK TRANSACTION AddSubcategory")
        if rbErr != nil {
            return fmt.Errorf("rollback savepoint: %w (original: %w)", rbErr, err)
        }
        log.Printf("Subcategory %q failed, proceeding without it: %v",
            category.DefaultSubcategory, err)
    }

    return tx.Commit()
}

Note

SAVE TRANSACTION <name> セーブポイントを作成します。 ROLLBACK TRANSACTION <name> は、外側のトランザクションを終了せずに、そのセーブポイントにロールバックします。 名前のないROLLBACK は、トランザクション全体をロールバックします。

デッドロック処理

SQL Serverは、競合するトランザクションの一つ(デッドロック被害者)を終了し、エラー1205を返すことでデッドロックを解決します。 終了したトランザクションはサーバーによって自動的にロールバックされます。

デッドロックの検出と再試行

エラー1205を確認し、短い遅延を置いて取引全体を再試行してください:

import (
    "errors"
    "fmt"
    "time"

    "github.com/microsoft/go-mssqldb"
)

func isDeadlock(err error) bool {
    var mssqlErr mssql.Error
    return errors.As(err, &mssqlErr) && mssqlErr.Number == 1205
}

func withDeadlockRetry(ctx context.Context, db *sql.DB, maxRetries int,
    fn func(ctx context.Context, tx *sql.Tx) error) error {

    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }

        err = fn(ctx, tx)
        if err != nil {
            tx.Rollback()
            if isDeadlock(err) && attempt < maxRetries-1 {
                // Wait briefly before retrying.
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }

        if err = tx.Commit(); err != nil {
            if isDeadlock(err) && attempt < maxRetries-1 {
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }
        return nil
    }
    return fmt.Errorf("transaction failed after %d deadlock retries", maxRetries)
}

デッドロック再試行ラッパーを使用する

トランザクション関数をリトライラッパーに渡します:

err := withDeadlockRetry(ctx, db, 3, func(ctx context.Context, tx *sql.Tx) error {
    _, err := tx.ExecContext(ctx,
        "UPDATE Production.ProductInventory SET Quantity = Quantity - @qty WHERE ProductID = @pid AND LocationID = @lid",
        sql.Named("qty", orderQty),
        sql.Named("pid", productId),
        sql.Named("lid", locationId))
    return err
})

デッドロックの削減

戦略 それがどのように役立つか
テーブルへのアクセスは一貫した順序で行う すべてのトランザクションがテーブルAをテーブルBより先にロックした場合、循環待ちは起こりません。
取引は短くまとめましょう 取引時間が短いほどロックを保持する時間が短くなり、競合の発生リスクが短くなります。
最低の十分隔離レベルを使いましょう ReadCommitted Serializableよりも鍵の数が少ない。
適切なインデックスを追加してください インデックスターゲットの更新はテーブルスキャンよりもロック行数が少なくなります。
取引中はユーザー操作を避けてください BeginTxCommitの間でユーザーの入力を待つことは決してありません。

アプリケーションコードではリトライが正しい応答ですが、同じクエリで繰り返しデッドロックが起こる場合は設計上の問題を示します。 SQL Serverのデッドロックグラフ(拡張イベントやシステムヘルスセッションでキャプチャ)を使って競合する文やロックタイプを特定し、前のテーブルの戦略を適用します。 デッドロック分析と予防の詳細な解説については、 Deadlocksガイドをご覧ください。

トランザクションと接続の固定

トランザクションはプールから単一の接続をピン留めし、 Commit() または Rollback() が呼び出されるまで続きます。 この間、他のどのGoroutineもその接続を利用できません。

示唆:

  • 長期取引は実質的なプールサイズを縮小します。 MaxOpenConns=25 と 20 個のオープンなトランザクションがある場合、他の処理に利用できる接続は 5 つだけです。
  • Rollback() を解放し忘れると、接続が恒久的にリークしたままになります。
  • トランザクションのコンテキスト上のコンテキストキャンセルはトランザクションをロールバックし、プールへの接続を返します。
// Set a deadline to prevent transactions from running indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

同時取引

各ゴルーチンは独自のトランザクションを作成するべきです。 *sql.Tx を goroutine 間で決して共有しないでください。*sql.Tx は並行して使用しても安全ではないためです:

// CORRECT: Each goroutine gets its own transaction.
var g errgroup.Group
for _, item := range items {
    item := item
    g.Go(func() error {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
        defer tx.Rollback()

        _, err = tx.ExecContext(ctx,
            "UPDATE Production.ProductInventory SET Quantity = Quantity - 1 WHERE ProductID = @p1 AND LocationID = @p2",
            sql.Named("p1", item.ProductId),
            sql.Named("p2", item.LocationId))
        if err != nil {
            return err
        }
        return tx.Commit()
    })
}
return g.Wait()

分散トランザクション

go-mssqldbドライバーは分散トランザクション(XAトランザクションやSystem.Transactions同等物)をサポートしていません。 複数のデータベースで作業を調整する必要がある場合:

  • 補正アクションを組み合わせたサガパターンを使いましょう。
  • 可能な限り、業務を単一のデータベースに統合しましょう。
  • 両方のデータベースがSQL Serverなら、Transact-SQL のBEGIN DISTRIBUTED TRANSACTION(T-SQL)でリンクサーバーを使いましょう。

取引チェックリスト

Area レコメンデーション
ロールバック安全装置 必ずdefer tx.Rollback()の直後にBeginTx
分離レベル まずは ReadCommitted (デフォルト)から始めましょう。 必要な時だけエスカレートしてください。
デッドロック トランザクションコードをリトライループでラップします。 テーブルには一貫した順序でアクセスできます。
期間 取引はできるだけ短くしましょう。 文脈の締め切りを設定しましょう。
Concurrency *sql.TxをGoroutine間で共有してはいけません。
セーブポイント 部分的なロールバックには SAVE TRANSACTIONROLLBACK TRANSACTION <name> を使いましょう。
プールの影響 未コミット取引からの接続漏れを検出するために db.Stats().InUse を監視しましょう。