使用 go-mssqldb 進行交易

交易將多個操作組織為一個原子單元。 要麼所有操作成功(提交),要麼全部都不生效(回滾)。 本文將介紹如何與 go-mssqldb 驅動程式使用交易,包括隔離等級、錯誤處理,以及生產應用的模式。

本文範例與 AdventureWorks2025 範例資料庫對比。 以寫入為導向的範例以 HumanResources.DepartmentProduction.ProductCategoryProduction.ProductSubcategoryProduction.ProductInventory 為目標。

啟動交易

使用 db.BeginTx 來開始交易。 回傳的 *sql.Tx 會在整個交易生命週期內,從連線集區中固定保留單一連線:

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 No 是的 是的 違約。 適合大多數工作負載。 一般的 OLTP 工作量。 預設且推薦的起點。
sql.LevelRepeatableRead No No 是的 溫和。 能留髮更久。 必須在同一交易中對相同資料列看到一致值的讀取作業。
sql.LevelSerializable No No No 最高。 範圍鎖定會封鎖並行插入。 財務交易、庫存管理,以及任何不容許發生幻讀的情境。
sql.LevelSnapshot No No No 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
})

減少死結

Strategy 這功能如何幫助
以一致的順序存取資料表 當所有交易都先鎖定表 A,再鎖定表 B 時,就不會發生循環等待。
保持交易簡短 較短的交易持有鎖的時間較短,因此可降低發生衝突的機會。
使用足夠的最低隔離層級 ReadCommitted 持有的鎖數比 Serializable少。
新增適當的索引 針對索引的更新鎖定的列數比資料表掃描少。
避免交易過程中的使用者互動 切勿在 BeginTxCommit 之間等待使用者輸入。

在應用程式程式碼中,重試是正確的回應,但同一查詢重複死結表示設計有問題。 使用 SQL Server 死鎖圖(透過擴展事件或系統健康會話擷取)來識別競爭的語句與鎖類型,然後套用前述表格中的策略。 欲了解死結分析與預防的完整流程,請參閱 死結指南

交易與連線釘選

交易會從池中固定一個連線,直到Commit()Rollback()被呼叫為止。 在這段期間,其他 goroutine 無法使用該連線。

影響:

  • 長期交易會減少有效池大小。 如果你有 MaxOpenConns=25 和 20 筆未結交易,則只有 5 個連線可供其他工作使用。
  • 忘了處理的 Rollback() 會導致連線永久洩漏。
  • 若交易的 context 被取消,交易會回滾,並將連線返回連線池。
// 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()

並行交易

每個 goroutine 應該建立自己的交易。 切勿在 goroutine 之間共享*sql.Tx,因為*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 等效物)。 如果你需要跨多個資料庫協調工作:

  • 使用具有補償動作的 Saga 模式。
  • 盡可能將作業整合到單一資料庫中。
  • 如果兩個資料庫都是 SQL Server,請從 Transact-SQL(T-SQL)搭配 BEGIN DISTRIBUTED TRANSACTION 使用連結伺服器。

交易清單

Area Recommendation
回滾安全 總是緊接著defer tx.Rollback()BeginTx
隔離等級 從(預設)開始 ReadCommitted 。 僅在必要時才升級處理。
死結 將交易程式碼包裝成重試迴圈。 以一致的順序存取資料表。
持續時間 保持交易時間盡可能簡短。 設定情境截止期限。
Concurrency 千萬不要在多個 goroutine 之間共用 *sql.Tx
儲存點 使用 SAVE TRANSACTIONROLLBACK TRANSACTION <name> 進行部分回滾。
泳池影響 監控 db.Stats().InUse 以偵測未承諾交易的連線洩漏。