go-mssqldb 中的错误处理与重试模式

生产 Go 应用需要结构化错误处理,以区分可以重试的暂时性故障和需要人工干预的永久性错误。 本文将介绍错误分类、重试模式以及驾驶员的 go-mssqldb 韧性策略。

SQL Server 错误结构

当 SQL Server 返回错误时,go-mssqldb 驱动程序会将其封装在 mssql.Error 结构体中。 使用类型断言来访问结构化错误字段:

import (
    "database/sql"
    "errors"
    "fmt"

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

func handleError(err error) {
    var mssqlErr mssql.Error
    if errors.As(err, &mssqlErr) {
        fmt.Printf("Number:  %d\n", mssqlErr.Number)
        fmt.Printf("State:   %d\n", mssqlErr.State)
        fmt.Printf("Class:   %d\n", mssqlErr.Class)
        fmt.Printf("Message: %s\n", mssqlErr.Message)
        fmt.Printf("Server:  %s\n", mssqlErr.ServerName)
        fmt.Printf("Proc:    %s\n", mssqlErr.ProcName)
        fmt.Printf("Line:    %d\n", mssqlErr.LineNo)
    }
}

错误字段

领域 类型 Description
Number int32 SQL Server 错误编号。 映射到 sys.messages
State uint8 错误状态。 为同一错误数字提供了额外的背景信息。
Class uint8 严重程度(0-25)。 严重程度11-16可由用户纠正。 严重度17+表示资源或系统存在问题。
Message string 来自服务器的人类可读的错误文本。
ServerName string 引发错误的 SQL Server 实例名称。
ProcName string 错误发生的存储过程或函数名称。 空的用于临时查询。
LineNo int32 Transact-SQL(T-SQL)批处理或存储过程中的行号。

严重性级别

严重性范围 Meaning Action
0-10 信息消息 无错误。 如果有用,记下日志。
11-16 用户可纠正的错误 修正查询、参数或权限。
17-19 资源错误 请重试。 服务器可能负载过重或资源不足。
20-25 严重错误 连接中断。 重新连接并重新尝试。

将错误分类为暂时错误或永久错误

瞬态错误是会自行解决的临时状态,如网络故障、连接限速或短暂的资源争用。 永久性错误需要代码或配置更改。

常见的瞬态误差数

请使用以下共享目录作为暂时连接建立和请求路径传输错误的规范列表:

在建立连接期间发生或向服务器发送请求时,以下错误是暂时性的。 在短的、受限的退避时重试。 在几次重试之后保留的错误通常表示配置问题(服务器错误、权限缺失、配额耗尽),重试无法解决。

Error Message Troubleshooting
64 A connection was successfully established with the server, but then an error occurred during the login process. (provider: TCP Provider, error: 0 - The specified network name is no longer available.) TCP 连接在握手过程中中断。 并非凭据错误。 如果问题仍然存在,请检查是否存在客户端网络不稳定的情况,或是否有会丢弃半建立连接的中间设备。
233 The client was unable to establish a connection because of an error during connection initialization process before login. 预登录传输或 TLS 失败。 服务器通常会在无法接受该连接时返回该响应(例如资源耗尽、已达到最大连接数,或客户端不受支持)。 并非凭据错误。 验证服务器运行状况,然后检查客户端登录超时、TLS 设置和客户端/服务器 TLS 版本兼容性。
4060 Cannot open database "%.*ls" requested by the login. The login failed. 登录已通过身份验证,但无法打开所请求的数据库。 暂时原因包括数据库处于转换过程中(故障转移、还原、扩缩容)或已自动暂停。 永久性原因(数据库不存在,登录缺少访问权限)不会通过重试来修复;检查数据库名称、登录映射和数据库状态。
4221 Login to read-secondary failed due to long wait on 'HADR_DATABASE_WAIT_FOR_TRANSITION_TO_VERSIONING'. 该副本无法用于登录访问,因为在回收该副本时,当时正在进行中的事务缺少行版本。 回滚或提交主节点上的活动事务以解决该问题。 通过避免主数据库上的长时间写入事务来缓解此问题。
10053 A transport-level error has occurred when sending the request to the server. (provider: TCP Provider, error: 0 - An established connection was aborted by the software in your host machine.) 本地端中止连接。 检查客户端网络运行状况以及任何本地防火墙或 VPN 客户端。
10054 A transport-level error has occurred when sending the request to the server. (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.) 远程端发送 TCP 重置。 常见原因包括:对端进程崩溃、防火墙注入了重置包,或 Azure SQL 网关关闭了空闲连接。 对于空闲重置这类情况,请在客户端启用 TCP 保活,或缩短连接池的空闲超时时间。
10928 Resource ID: %d. The %s limit for the database is %d and has been reached. See 'http://go.microsoft.com/fwlink/?LinkId=267637' for assistance. 数据库超过Azure SQL资源治理限制。 资源 ID 1 表示工作线程限制;资源 ID 2 表示会话限制。 从消息中识别限制类型,然后降低并发、扩容数据库,或缩短长时间占用该资源的操作的执行时间。
10929 Resource ID: %d. The %s minimum guarantee is %d, maximum limit is %d, and the current usage for the database is %d. However, the server is currently too busy to support requests greater than %d for this database. 数据库已超过其最低保障值,底层服务器正在对其进行限流。 当邻居负载下降时,重试通常成功。 持续出现表示需要更高的服务层级或不太干扰的环境。
40020401434016640540 在故障转移期间,于错误 40197 的 Error code %d 槽中报告。 嵌入在 40197 故障转移消息中的子代码,在某些路径中会显示为顶层错误代码。 将它们视为 40197。
40197 The service has encountered an error processing your request. Please try again. Error code %d. Azure SQL 中的一次软件升级、硬件故障或其他故障转移事件。 重新连接会将你路由到一个健康的副本。 嵌入的错误代码标识故障转移类型。 如果错误仍然存在,请捕获会话跟踪 ID 并联系支持人员。
40501 The service is currently busy. Retry the request after 10 seconds. Incident ID: %ls. Code: %d. Azure SQL 引擎限流。 建议的最低退避时间为 10 秒。 持续受限流表明工作负载已超过数据库的资源分配;请提升服务层级或减少并发。
40613 Database '%.*ls' on server '%.*ls' is not currently available. Please retry the connection later. If the problem persists, contact customer support, and provide them with the session tracing ID of '%.*ls'. 数据库不可用,通常发生在故障转移过程中,或在缩放操作期间短暂出现。 按退避策略重试;如果问题持续几分钟以上,请记录会话跟踪 ID 并创建支持工单。
42108 Can not connect to the SQL pool since it is paused. Please resume the SQL pool and try again. 专用 SQL 池(Synapse)处于暂停状态。 只有在池恢复后,重试才会成功。 显式地恢复池,或将工作负载安排在池恢复后运行。
42109 The SQL pool is warming up. Please try again. 专用 SQL 池正在恢复。 按退避策略重试,直到池上线;预热通常需要几分钟。
49918 Cannot process request. Not enough resources to process request. The service is currently busy. Please retry the request later. 服务器当前无法分配足够的资源来满足请求。 在退避时重试。 如果错误仍然存在,请纵向扩展数据库或弹性池。
49919 Cannot process create or update request. Too many create or update operations in progress for subscription "%ld". 管理操作的订阅级并发限制。 减少并行创建/更新调用或将它们错开。
49920 Cannot process request. Too many operations in progress for subscription "%ld". 针对正在进行的操作的订阅级并发限制。 降低并行度,或等待当前正在进行的操作完成。

语句级错误不在此列表中,因为它们发生在连接建立之后,而且即使失败,会话仍可继续使用。 最常见的可重试语句错误是 1205(死锁受害者)和 1222(锁请求超时)。 重试整个事务,而不是单个失败语句。

错误消息文本来自Azure SQL暂时性连接错误。 各个驱动程序维护各自的内置重试列表;此目录说明了在 SQL Server、Azure SQL 数据库、Azure SQL 托管实例、Microsoft Fabric 中的 SQL 数据库以及 Azure Synapse Analytics 中的专用 SQL 池中,哪些错误符合重试条件。

以下 isTransient 函数展示了一种 Go 实现模式用于重试分类。 将上面的共享目录视为唯一可信来源,并使你的代码查询与之保持一致。

// isTransient returns true if the error is a transient SQL Server error
// that is likely to succeed on retry.
func isTransient(err error) bool {
    var mssqlErr mssql.Error
    if !errors.As(err, &mssqlErr) {
        // Network errors, context deadlines, and connection resets
        // are also transient.
        return isNetworkError(err)
    }

    if isTransientSQLNumber(mssqlErr.Number) {
        return true
    }

    // Severity 17-19 indicates resource issues that are typically transient.
    return mssqlErr.Class >= 17 && mssqlErr.Class <= 19
}

// Keep this lookup synchronized with the shared transient catalog above.
var transientSQLNumbers = map[int32]struct{}{
    64:    {}, // Transport/connection error.
    1205:  {}, // Deadlock victim.
    40197: {}, // Service error processing request.
    40501: {}, // Service is currently busy.
    40613: {}, // Database is currently unavailable.
    49918: {}, // Cannot process request: not enough resources.
    49919: {}, // Cannot process create/update request.
    49920: {}, // Cannot process request: too many operations.
}

func isTransientSQLNumber(number int32) bool {
    _, ok := transientSQLNumbers[number]
    return ok
}

如果遇到配置和配额错误,请先修复底层容量、数据库或网络配置,再重新尝试。 示例包括:

  • 40544 (数据库规模配额)
  • 4060 (无法打开数据库)
  • 40615 (防火墙规则)

检测网络错误

网络层面的错误不会产生 mssql.Error 数值。 检查常见的Go网络错误类型:

import (
    "context"
    "errors"
    "net"
    "io"
)

func isNetworkError(err error) bool {
    if err == nil {
        return false
    }

    // Context deadline exceeded or canceled
    if errors.Is(err, context.DeadlineExceeded) {
        return true
    }

    // Connection reset or broken pipe
    var netErr *net.OpError
    if errors.As(err, &netErr) {
        return true
    }

    // Unexpected EOF (server dropped the connection)
    if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
        return true
    }

    return false
}

实现采用指数退避的重试机制

发生瞬态错误时,以逐渐增加每次尝试之间延迟的方式进行重试。 这种方式给服务器时间恢复,避免快速重试导致服务器不堪重负。

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "log"
    "math"
    "math/rand"
    "time"

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

// RetryConfig controls retry behavior.
type RetryConfig struct {
    MaxAttempts int           // Maximum number of attempts (including the first).
    BaseDelay   time.Duration // Initial delay before the first retry.
    MaxDelay    time.Duration // Upper bound on delay between retries.
}

// DefaultRetryConfig provides sensible defaults for SQL Server workloads.
var DefaultRetryConfig = RetryConfig{
    MaxAttempts: 5,
    BaseDelay:   100 * time.Millisecond,
    MaxDelay:    10 * time.Second,
}

// RetryFunc executes fn with retries for transient errors.
func RetryFunc(ctx context.Context, cfg RetryConfig, fn func(ctx context.Context) error) error {
    var lastErr error
    for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
        lastErr = fn(ctx)
        if lastErr == nil {
            return nil
        }

        if !isTransient(lastErr) {
            return lastErr // Permanent error, don't retry.
        }

        if attempt == cfg.MaxAttempts-1 {
            break // Last attempt, don't sleep.
        }

        delay := calculateDelay(attempt, cfg.BaseDelay, cfg.MaxDelay)

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return lastErr
}

func calculateDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
    // Exponential backoff: base * 2^attempt
    delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt)))
    if delay > maxDelay {
        delay = maxDelay
    }
    // Add jitter: +/- 25% to avoid thundering herd
    jitter := time.Duration(rand.Int63n(int64(delay) / 2))
    return delay/2 + jitter
}

// isTransient classifies retryable SQL Server errors.
// For a fuller example, see "Classify errors as transient or permanent" earlier in this article.
func isTransient(err error) bool {
    var mssqlErr mssql.Error
    if !errors.As(err, &mssqlErr) {
        return errors.Is(err, context.DeadlineExceeded)
    }

    switch mssqlErr.Number {
    case 1205, 40197, 40501, 40613, 49918, 49919, 49920:
        return true
    }

    return mssqlErr.Class >= 17 && mssqlErr.Class <= 19
}

// getEmployeeCount wraps a query with automatic retry.
func getEmployeeCount(ctx context.Context, db *sql.DB) (int, error) {
    var count int
    err := RetryFunc(ctx, DefaultRetryConfig, func(ctx context.Context) error {
        // This query executes on every retry attempt until success or exhaustion.
        return db.QueryRowContext(ctx, "SELECT COUNT(*) FROM HumanResources.Employee").Scan(&count)
    })
    return count, err
}

// Example call site (assumes db is already initialized).
func example(ctx context.Context, db *sql.DB) {
    queryCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
    defer cancel()

    count, err := getEmployeeCount(queryCtx, db)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Employee count: %d\n", count)
}

处理死锁

死锁(错误1205)是多用户应用中最常见的瞬态错误。 SQL Server 会自动终止其中一个竞争会话,并向受害者返回错误 1205。

检测死锁

检查SQL Server错误是否为死锁(错误1205)。

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

死锁后重试交易

当事务内部发生死锁时,服务器会回滚整个事务。 您必须重试整个交易,而不仅仅是失败的账单:

func transferInventory(ctx context.Context, db *sql.DB, productID, fromLocationID, toLocationID int, qty int) error {
    return RetryFunc(ctx, DefaultRetryConfig, func(ctx context.Context) error {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{
            Isolation: sql.LevelReadCommitted,
        })
        if err != nil {
            return err
        }
        defer tx.Rollback()

        _, err = tx.ExecContext(ctx,
            "UPDATE Production.ProductInventory SET Quantity = Quantity - @qty WHERE ProductID = @pid AND LocationID = @lid",
            sql.Named("qty", qty),
            sql.Named("pid", productID),
            sql.Named("lid", fromLocationID))
        if err != nil {
            return err
        }

        _, err = tx.ExecContext(ctx,
            "UPDATE Production.ProductInventory SET Quantity = Quantity + @qty WHERE ProductID = @pid AND LocationID = @lid",
            sql.Named("qty", qty),
            sql.Named("pid", productID),
            sql.Named("lid", toLocationID))
        if err != nil {
            return err
        }

        return tx.Commit()
    })
}

Tip

通过在所有事务中按一致的顺序访问表,并保持事务简短,来减少死锁。

在应用代码中,重试是正确的响应,但同一查询上出现反复死锁则说明存在设计问题。 使用SQL Server死锁图(通过扩展事件或系统健康会话捕获)来识别竞争的语句和锁类型。 关于死锁分析和预防的完整攻略,请参见 死锁指南。 关于针对交易的死锁处理策略,请参见 死锁处理

处理连接池耗尽问题

当池中的所有连接都在使用中并且达到 MaxOpenConns 时,新的调用方将被阻塞,直到有连接可用或上下文截止时间到期。 这种情况会表现为请求响应缓慢或上下文截止期限错误,而不是显式的池耗尽错误。

检测泳池压力

监控池内统计数据,等待人数增加时及时提醒。

func monitorPool(ctx context.Context, db *sql.DB) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    var lastWaitCount int64
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            stats := db.Stats()
            newWaits := stats.WaitCount - lastWaitCount
            lastWaitCount = stats.WaitCount

            if newWaits > 0 {
                log.Printf("Pool pressure: open=%d inUse=%d idle=%d newWaits=%d waitDuration=%v",
                    stats.OpenConnections, stats.InUse, stats.Idle,
                    newWaits, stats.WaitDuration)
            }
        }
    }
}

常见原因和解决方案

症状 原因 解决方案
WaitCount 持续增加 MaxOpenConns 太低了 增加 MaxOpenConns 以与你的并发数相匹配。
InUseMaxOpenConns长时间保持相等 连接不会被归还到池中 及时关闭 *sql.Rows、提交或回滚 *sql.Tx,并关闭 *sql.Conn
OpenConnections 持续增长 连接泄漏的速度比 MaxIdleConns 回收得更快 设置 ConnMaxLifetimeConnMaxIdleTime 以限制连接年龄。
查询期间上下文截止时间已超过 池子已经饱和,来电者等待时间过长 增加池大小、缩短查询执行时间或增加查询超时。

处理特定的 SQL Server 错误

违反约束

唯一密钥和外键违规是永久性错误,表明应用存在逻辑问题:

func isUniqueViolation(err error) bool {
    var mssqlErr mssql.Error
    if errors.As(err, &mssqlErr) {
        return mssqlErr.Number == 2627 || // Unique constraint violation
            mssqlErr.Number == 2601       // Unique index violation
    }
    return false
}

func isForeignKeyViolation(err error) bool {
    var mssqlErr mssql.Error
    if errors.As(err, &mssqlErr) {
        return mssqlErr.Number == 547 // FK constraint violation
    }
    return false
}

带冲突检测的 Upsert 模式

使用 MERGE 语句以原子方式插入或更新一行:

func upsertDepartment(ctx context.Context, db *sql.DB, id int, name, groupName string) error {
    _, err := db.ExecContext(ctx, `
        MERGE INTO HumanResources.Department AS target
        USING (SELECT @id AS DepartmentID, @name AS Name, @grp AS GroupName) AS source
        ON target.DepartmentID = source.DepartmentID
        WHEN MATCHED THEN
            UPDATE SET Name = source.Name, GroupName = source.GroupName
        WHEN NOT MATCHED THEN
            INSERT (Name, GroupName) VALUES (source.Name, source.GroupName);`,
        sql.Named("id", id),
        sql.Named("name", name),
        sql.Named("grp", groupName))
    return err
}

权限错误

检测常见的权限拒绝错误号码,向呼叫者提供清晰的信息:

func isPermissionError(err error) bool {
    var mssqlErr mssql.Error
    if errors.As(err, &mssqlErr) {
        return mssqlErr.Number == 229 ||   // SELECT permission denied
            mssqlErr.Number == 230 ||       // Column permission denied
            mssqlErr.Number == 262 ||       // CREATE permission denied
            mssqlErr.Number == 300 ||       // VIEW permission denied
            mssqlErr.Number == 15247        // User doesn't have permission
    }
    return false
}

处理 sql.ErrNoRows

sql.ErrNoRows不是SQL Server错误。 当查询未返回任何行时,QueryRowContext.Scan 方法会返回它。 明确处理以区分“未找到”和实际错误:

func getEmployee(ctx context.Context, db *sql.DB, id int) (*Employee, error) {
    var emp Employee
    err := db.QueryRowContext(ctx,
        "SELECT TOP (1) BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location FROM Sales.vSalesPerson WHERE BusinessEntityID = @p1",
        sql.Named("p1", id)).Scan(&emp.Id, &emp.Name, &emp.Location)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, nil // Not found, not an error.
    }
    if err != nil {
        return nil, fmt.Errorf("query employee %d: %w", id, err)
    }
    return &emp, nil
}

为错误添加上下文信息

为错误补充背景,以便来电者了解故障发生的位置:

func getEmployeesByLocation(ctx context.Context, db *sql.DB, location string) ([]Employee, error) {
    rows, err := db.QueryContext(ctx,
        "SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location FROM Sales.vSalesPerson WHERE CountryRegionName = @p1",
        sql.Named("p1", location))
    if err != nil {
        return nil, fmt.Errorf("query employees by location %q: %w", location, err)
    }
    defer rows.Close()

    var employees []Employee
    for rows.Next() {
        var emp Employee
        if err := rows.Scan(&emp.Id, &emp.Name, &emp.Location); err != nil {
            return nil, fmt.Errorf("scan employee row: %w", err)
        }
        employees = append(employees, emp)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterate employee rows: %w", err)
    }
    return employees, nil
}

使用 %w 可以保留错误链,使呼叫者仍能使用 errors.Aserrors.Is 检查底层错误。

错误处理检查表

Area Recommendation
类型断言 声明 var mssqlErr mssql.Error,然后使用 errors.As(err, &mssqlErr) 访问 SQL Server 错误字段。
瞬态检测 在决定是否重试之前,先按错误数量和严重程度分类。
重试逻辑 将指数退避与抖动一起使用。 通过上下文设置最大尝试次数和总体超时时间。
死锁 重新尝试整个交易,而不是单个账单。 通过以一致的顺序访问表来减少死锁。
泳池耗尽 监控 db.Stats() 并设定所有数据库调用的上下文截止日期。
ErrNoRows QueryRowContext 显式处理 sql.ErrNoRows。 这不是服务器错误。
错误包装 fmt.Errorf%w 结合使用,以添加上下文,同时保留错误链。
违反约束 检查错误编号 2627、2601(唯一)和 547(外键),以妥善处理冲突。