交易將多個操作組織為一個原子單元。 要麼所有操作成功(提交),要麼全部都不生效(回滾)。 本文將介紹如何與 go-mssqldb 驅動程式使用交易,包括隔離等級、錯誤處理,以及生產應用的模式。
本文範例與 AdventureWorks2025 範例資料庫對比。 以寫入為導向的範例以 HumanResources.Department、Production.ProductCategory、Production.ProductSubcategory 和 Production.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少。 |
| 新增適當的索引 | 針對索引的更新鎖定的列數比資料表掃描少。 |
| 避免交易過程中的使用者互動 | 切勿在 BeginTx 和 Commit 之間等待使用者輸入。 |
在應用程式程式碼中,重試是正確的回應,但同一查詢重複死結表示設計有問題。 使用 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 TRANSACTION 和 ROLLBACK TRANSACTION <name> 進行部分回滾。 |
| 泳池影響 | 監控 db.Stats().InUse 以偵測未承諾交易的連線洩漏。 |