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)
    }
}

誤差欄位

Field 類型 說明
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 致命錯誤 連線斷了。 重新連線並重試。

將錯誤分類為短暫錯誤或永久錯誤

暫時性錯誤是暫時性狀況,會自行解決,例如網路異常、連線限速或短暫的資源爭用。 永久錯誤則需要程式碼或設定變更。

常見的瞬態錯誤數

請使用以下共享目錄作為暫態連線建立與請求路徑傳輸錯誤的典範清單:

以下錯誤發生在連線建立或向伺服器發送請求時,屬於暫時性。 以短暫且有上限的退避間隔進行重試。 重複幾次後仍持續的錯誤通常表示設定有問題(伺服器錯誤、權限遺失、配額用盡),重試無法解決。

錯誤 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 keepalive,或縮短連線集區的閒置逾時時間。
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 Database、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 時,新的呼叫者會遭到阻塞,直到有連線可用或 context 的截止期限到期。 這種情況會呈現為請求變慢或上下文逾時錯誤,而不是明確的池耗盡錯誤。

偵測水池壓力

監控泳池統計數據,並在等待人數增加時提醒。

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(外鍵),以妥善處理衝突。