使用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 是的 是的 违约。 适用于大多数工作负载。 一般的OLTP工作量。 默认且推荐的起点。
sql.LevelRepeatableRead 是的 适中。 锁得更久。 必须在事务中看到相同行的一致值的读取。
sql.LevelSerializable 最高。 范围锁会阻止并发插入。 财务交易、库存管理、任何幻影读取都不可接受。
sql.LevelSnapshot tempdb 中使用行版本控制。 没有挡板。 需要时间点一致性且不会阻塞写操作的读密集型工作负载。

注释

sql.LevelSnapshot 需要在数据库上启用快照隔离: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON

示例:读取已提交与可序列化

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: trueBeginTx 会返回错误。

对于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()
}

注释

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() 会导致连接永久泄漏。
  • 如果事务的上下文被取消,事务将回滚,并将该连接返回到连接池。
// 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 以检测未提交交易中的连接泄漏。