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 アクション
0-10 情報メッセージ エラーはありません。 役に立つ場合は記録してください。
11-16 ユーザーで修正可能な誤り クエリやパラメータ、権限を修正してください。
17-19 リソース エラー 再試行してください。 サーバーが負荷かリソース不足かもしれません。
20-25 致命的なエラー 接続が切断されています。 再接続して再試してみてください。

誤りを一時的または永続的に分類する

一時的なエラーは、ネットワークのブリップ、接続のスロットリング、または一時的なリソース競合など、自然に解決する一時的な状態を指します。 永久的なエラーはコードや設定の変更を必要とします。

一般的な過渡誤差番号

一時的な接続確立およびリクエストパストランスポートエラーの標準リストとして、以下の共有カタログを使用してください。

次のエラーは、接続の確立中またはサーバーへの要求の送信中に発生した一時的なエラーです。 短い境界付きバックオフで再試行します。 再試行回数を超えてエラーが続く場合は、通常、構成の問題 (間違ったサーバー、アクセス許可の不足、クォータの不足) が発生しても、再試行は修正されません。

エラー メッセージ 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 Engine のスロットリング。 推奨されるバックオフの下限は 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 Managed Instance、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のデッドロックグラフ(拡張イベントやシステムヘルスセッションを通じてキャプチャ)を使って競合する文やロックタイプを特定します。 デッドロック分析と予防の詳細な解説については、 Deadlocksガイドをご覧ください。 トランザクション特有のデッドロック処理戦略については、 デッドロック処理を参照してください。

接続プールの枯渇に対処する

プール内のすべての接続が使用中で 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 を増やしてください。
InUse 長期間は MaxOpenConns に等しい 接続はプールに戻されません *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
}

衝突検出を伴うアップサートパターン

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 レコメンデーション
型アサーション var mssqlErr mssql.Errorを宣言し、errors.As(err, &mssqlErr)を使ってSQL Serverエラーフィールドにアクセスします。
過渡検出 再挑戦の有無を決める前に、エラーの数と重症度で分類してください。
再試行ロジック ジッターを用いた指数バックオフを使用します。 コンテキストを使って最大試行回数と全体のタイムアウトを設定します。
デッドロック 個別の明細ではなく、取引全体を再試してみてください。 テーブルに一貫してアクセスすることでデッドロックを減らしましょう。
プールの枯渇 すべてのデータベース呼び出しでコンテキストの締め切りを設定し、 db.Stats() を監視してください。
エラーノーローズ QueryRowContext に対して sql.ErrNoRows を明示的に処理します。 サーバーエラーではありません。
エラーラッピング fmt.Errorf %wを使って文脈を加えつつ、エラーチェーンを保持しましょう。
制約違反 競合を円滑に処理するために、エラー番号2627、2601(ユニーク)、547(外部キー)を確認してください。