Transactions avec go-mssqldb

Les transactions regroupent plusieurs opérations dans une unité atomique. Soit toutes les opérations réussissent (validation), soit aucune n’est appliquée (annulation). Cet article explique comment utiliser les transactions avec le go-mssqldb pilote, y compris les niveaux d’isolation, la gestion des erreurs et les motifs pour les applications de production.

Les exemples de cet article s’exécutent sur la base de données d’exemple AdventureWorks2025. Les exemples orientés écriture ciblent HumanResources.Department, Production.ProductCategory, Production.ProductSubcategory, et Production.ProductInventory.

Lancer une transaction

Utilisez-le db.BeginTx pour commencer une transaction. La valeur renvoyée *sql.Tx réserve une seule connexion du pool pendant toute la durée de la transaction :

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

Appelez defer tx.Rollback() toujours immédiatement après BeginTx. Si Commit() réussit, le différé Rollback() est un no-op. Si une erreur survient avant Commit(), l’appel différé à Rollback() garantit ainsi que la transaction ne reste pas ouverte et empêche que la connexion soit renvoyée au pool sans validation.

Niveaux d’isolation

SQL Server prend en charge plusieurs niveaux d’isolation qui contrôlent l’interaction des transactions concurrentes. Fixez le niveau d’isolation dans sql.TxOptions:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

Comparaison des niveaux d’isolation

Niveau d’isolation Lectures incorrectes Lectures non répétables Lectures fantômes Impact sur les performances À utiliser lorsque
sql.LevelReadUncommitted Oui Oui Oui Frais généraux les plus bas Comptages approximatifs, surveillance des tableaux de bord. La précision des données n’est pas cruciale.
sql.LevelReadCommitted Non Oui Oui Default. Convient à la plupart des charges de travail. Charges de travail générales de l’OLTP. Le point de départ par défaut et recommandé.
sql.LevelRepeatableRead Non Non Oui Modéré. Ça tient les serrures plus longtemps. Des lectures qui doivent voir des valeurs cohérentes pour les mêmes lignes dans la transaction.
sql.LevelSerializable Non Non Non Le plus élevé. Les verrous de plage bloquent les insertions simultanées. Transactions financières, gestion des stocks, partout où les lectures fantômes sont inacceptables.
sql.LevelSnapshot Non Non Non Utilise la versionisation par ligne dans tempdb. Pas de blocage. Des charges de travail à forte composante de lecture qui nécessitent une cohérence à un instant donné sans bloquer les opérations d’écriture.

Note

sql.LevelSnapshot nécessite que l’isolation des instantanés soit activée sur la base de données : ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON.

Exemple : lire engagé vs. sérialisable

Spécifier le niveau d’isolation dans 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,
})

Routage en lecture seule

Le pilote ne prend pas en charge sql.TxOptions.ReadOnly. Si vous transmettez ReadOnly: true, BeginTx renvoie une erreur.

Pour le routage AlwaysOn en lecture seule, définissez applicationintent=ReadOnly dans la chaîne de connexion lorsque vous ouvrez la connexion :

sqlserver://listener.example.com?database=AdventureWorks2025&applicationintent=ReadOnly

C’est un réglage de niveau connexion. Il peut acheminer les sessions éligibles vers un réplica secondaire lisible, mais ne transforme pas une transaction existante en transaction en lecture seule. Utilisez une connexion dédiée en lecture seule ou des identifiants à privilège minimum pour les charges de travail qui ne doivent pas écrire de données.

Gestion des erreurs dans les transactions

Gérez les erreurs au sein des transactions avec soin. Lorsqu’un relevé échoue, la transaction doit être entièrement annulée. N’essayez pas de continuer avec d’autres affirmations après une erreur :

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
}

Si la procédure batch ou stockée s’exécute avec SET XACT_ABORT ON, considérez toute erreur d’instruction comme un terminal pour la transaction. Revenez immédiatement en arrière et ne tentez pas d’autres déclarations ou Commit(). Les versions actuelles des pilotes détectent les transactions avortées par le serveur et renvoient une erreur au lieu de permettre un commit partiel silencieux.

Points de sauvegarde

Les points de sauvegarde créent des points de retour intermédiaires au sein d’une transaction. SQL Server prend en charge les points de sauvegarde nativement. Puisque le package de database/sql Go n’expose pas directement les points de sauvegarde, exécutez-les en SQL brut via la transaction :

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

Note

SAVE TRANSACTION <name> Crée le point de sauvegarde. ROLLBACK TRANSACTION <name> Ça revient à ce point de sauvegarde sans mettre fin à la transaction extérieure. ROLLBACK sans nom annule l’intégralité de la transaction.

Gestion de l'interblocage

SQL Server résout les blocages en terminant l’une des transactions concurrentes (la victime du blocage) et en renvoyant l’erreur 1205. La transaction terminée est automatiquement annulée par le serveur.

Détection et réessai des blocages

Vérifiez l’erreur 1205 et retentez toute la transaction avec un court délai :

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

Utilisez le mécanisme de nouvelle tentative en cas d’interblocage

Passer une fonction de transaction au wrapper de retry :

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

Réduire les blocages

Strategy Comment cela aide
Accéder aux tables dans un ordre cohérent Lorsque toutes les transactions verrouillent la Table A avant la Table B, les attentes circulaires ne peuvent pas avoir lieu.
Gardez les transactions courtes Les transactions plus courtes conservent les verrous moins longtemps, réduisant ainsi la période propice aux conflits.
Utilisez le niveau d’isolation suffisant le plus bas ReadCommitted contient moins de verrous que Serializable.
Ajouter des index appropriés Les mises à jour ciblées par index verrouillent moins de lignes que les scans de tables.
Évitez l’interaction utilisateur lors des transactions N’attendez jamais l’entrée utilisateur entre BeginTx et Commit.

Réessayer est la réponse correcte dans le code applicatif, mais des blocages répétés sur la même requête indiquent un problème de conception. Utilisez le graphique de blocage de SQL Server (capturé via les événements étendus ou la session de santé système) pour identifier les instructions concurrentes et les types de verrous, puis appliquez les stratégies du tableau précédent. Pour un aperçu complet de l’analyse et de la prévention des blocages, consultez le guide des blocages.

Transactions et maintien de la connexion

Une transaction immobilise une seule connexion du pool jusqu’à ce Commit() que ou Rollback() soit appelée. Pendant ce temps, aucune autre goroutine ne peut utiliser cette connexion.

Implications :

  • Les transactions de longue durée réduisent la taille effective du pool. Si vous avez MaxOpenConns=25 et 20 transactions ouvertes, il ne reste que 5 connexions disponibles pour d’autres tâches.
  • Un Rollback() oublié provoque une fuite de connexion permanente.
  • L’annulation de contexte sur le contexte de la transaction annule la transaction et renvoie la connexion au pool.
// 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()

Transactions concurrentes

Chaque goroutine doit créer sa propre transaction. Ne partagez jamais un *sql.Tx entre plusieurs goroutines, car *sql.Tx n’est pas sûr en cas d’utilisation concurrente :

// 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()

Transactions distribuées

Le pilote go-mssqldb ne prend pas en charge les transactions distribuées (transactions XA ou leurs équivalents System.Transactions). Si vous devez coordonner le travail sur plusieurs bases de données :

  • Utilise un motif saga avec des actions compensatrices.
  • Consolider les opérations dans une seule base de données lorsque c’est possible.
  • Utilisez des serveurs liés avec BEGIN DISTRIBUTED TRANSACTION from Transact-SQL (T-SQL) si les deux bases de données sont SQL Server.

Liste de contrôle des transactions

Area Recommendation
Sécurité de recul Toujours defer tx.Rollback() immédiatement après BeginTx.
Niveau d’isolation Commencez par ReadCommitted (par défaut). Escaladez uniquement si besoin.
Interblocages Enroulez le code transactionnel dans une boucle de réessayage. Accédez aux tables dans un ordre cohérent.
Durée Gardez les transactions aussi courtes que possible. Fixez des échéances contextuelles.
Concurrency Ne partagez jamais un *sql.Tx entre plusieurs goroutines.
Points de sauvegarde Utilisation SAVE TRANSACTION et ROLLBACK TRANSACTION <name> pour un retour en arrière partiel.
Impact du bassin Surveillez db.Stats().InUse pour détecter les fuites de connexion dues à des transactions non engagées.