事务将多个操作组合成一个原子单元。 要么所有操作成功(提交),要么全部操作都不生效(回滚)。 本文将介绍如何使用驱动程序 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 |
否 | 是的 | 是的 | 违约。 适用于大多数工作负载。 | 一般的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: 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()
}
注释
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()会导致连接永久泄漏。 - 如果事务的上下文被取消,事务将回滚,并将该连接返回到连接池。
// 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 以检测未提交交易中的连接泄漏。 |