Transakcje z go-mssqldb

Transakcje grupują wiele operacji w jednostkę atomową. Albo wszystkie operacje zakończą się powodzeniem (zatwierdzenie), albo żadna z nich nie zostanie zastosowana (wycofanie). Ten artykuł omawia, jak korzystać z transakcji z sterownikiem go-mssqldb , w tym poziomy izolacji, obsługę błędów oraz wzorce dla zastosowań produkcyjnych.

Przykłady w tym artykule porównane są z przykładową bazą danych AdventureWorks2025 . Przykłady zorientowane na zapis obejmują HumanResources.Department, Production.ProductCategory, Production.ProductSubcategory i Production.ProductInventory.

Rozpocznij transakcję

Używaj db.BeginTx do rozpoczęcia transakcji. Zwrócone *sql.Tx piny stanowią pojedyncze połączenie z puli na cały okres trwania transakcji:

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

Ważna

Zawsze wywołuj defer tx.Rollback() bezpośrednio po BeginTx. Jeśli Commit() zakończy się powodzeniem, odroczone Rollback() nie wykona żadnej operacji. Jeśli przed Commit() wystąpi jakikolwiek błąd, odroczone wywołanie Rollback() zapewnia, że transakcja nie pozostanie otwarta i że połączenie nie wróci do puli w stanie niezatwierdzonym.

Poziomy izolacji

SQL Server obsługuje kilka poziomów izolacji, które kontrolują, jak współbieżne transakcje współdziałają. Ustaw poziom izolacji w sql.TxOptions:

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

Porównanie poziomów izolacji

Poziom izolacji Odczyty zanieczyszczone Niepowtarzalne odczyty Phantom czyta Wpływ na wydajność Użyj, gdy
sql.LevelReadUncommitted Yes Yes Yes Najniższe koszty ogólne Przybliżone liczby, pulpity monitorowania. Dokładność danych nie jest kluczowa.
sql.LevelReadCommitted No Yes Yes Domyślne. Dobre do większości zadań. Ogólne obciążenia OLTP. Domyślny i zalecany punkt wyjścia.
sql.LevelRepeatableRead No No Yes Umiarkowany. Dłużej utrzymuje zamki. Odczyty, które muszą mieć spójne wartości dla tych samych wierszy w transakcji.
sql.LevelSerializable No No No Najwyższy. Blokady zakresowe blokują współbieżne operacje wstawiania. Transakcje finansowe, zarządzanie zapasami, wszędzie tam, gdzie widmiczne odczyty są nie do przyjęcia.
sql.LevelSnapshot No No No Wykorzystuje wersjonowanie wierszy w tempdb. Bez blokowania. Obciążenia wymagające dużej ilości czytania, które wymagają spójności w określonym momencie bez blokowania autorów.

Uwaga / Notatka

sql.LevelSnapshot wymaga włączenia izolacji migawek w bazie danych: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON.

Przykład: odczyt zatwierdzonych danych a poziom serializowalny

Określmy poziom izolacji w 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,
})

Rutowanie tylko odczytu

Sterownik nie obsługuje sql.TxOptions.ReadOnly. Jeśli przekażesz ReadOnly: true, BeginTx zwróci błąd.

W przypadku kierowania połączeń tylko do odczytu AlwaysOn ustaw applicationintent=ReadOnly w parametrach połączenia podczas otwierania połączenia:

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

To jest ustawienie na poziomie połączenia. Może kierować kwalifikowane sesje do czytelnego pliku wtórnego, ale nie sprawia, że istniejąca transakcja jest tylko do odczytu. Używaj dedykowanego połączenia w trybie tylko do odczytu lub poświadczeń z minimalnymi uprawnieniami dla obciążeń roboczych, które nie mogą zapisywać danych.

Obsługa błędów w transakcjach

Ostrożnie radzcie sobie z błędami w transakcjach. Jeśli jakakolwiek instrukcja zakończy się niepowodzeniem, transakcja musi zostać w całości wycofana. Nie próbuj kontynuować innych stwierdzeń po błędzie:

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
}

Jeśli partia lub procedura składowana jest uruchamiana z SET XACT_ABORT ON, każdy błąd w instrukcji należy traktować jako powodujący zakończenie transakcji. Cofnij się od razu i nie próbuj kolejnych instrukcji ani Commit(). Obecne wersje sterowników wykrywają przerwane transakcje serwera i zwracają błąd zamiast dopuszczać ciche częściowe zatwierdzenie.

Punkty zapisywania

Punkty przywracania tworzą pośrednie punkty wycofania w ramach transakcji. SQL Server natywnie obsługuje punkty zapisu. Ponieważ pakiet database/sql w Go nie udostępnia bezpośrednio punktów zapisu, wykonaj je jako surowe instrukcje SQL za pośrednictwem transakcji:

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

Uwaga / Notatka

SAVE TRANSACTION <name> tworzy punkt zapisu. ROLLBACK TRANSACTION <name> cofa się do tego punktu zapisu bez kończenia zewnętrznej transakcji. ROLLBACK bez nazwy wycofuje całą transakcję.

Obsługa zakleszczeń

SQL Server rozwiązuje martwe punkty, kończąc jedną z konkurencyjnych transakcji (ofiarę zablokowania) i zwracając błąd 1205. Zakończona transakcja jest automatycznie cofana przez serwer.

Wykrywanie i ponowne próbowanie blokad

Sprawdź błąd 1205 i spróbuj ponownie przeprowadzić całą transakcję z krótkim opóźnieniem:

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

Użyj mechanizmu ponawiania po zakleszczeniu

Przekaż funkcję transakcyjną do mechanizmu ponawiania:

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

Zmniejszenie blokad

Strategy Jak to pomaga
Uzyskuj dostęp do tabel w stałej kolejności Gdy wszystkie transakcje blokują tabelę A przed tabelą B, nie może dojść do cyklicznego oczekiwania.
Utrzymuj transakcje krótkie Krótsze transakcje utrzymują blokady przez krótszy czas, zmniejszając okres, w którym mogą wystąpić konflikty.
Użyj najniższego wystarczającego poziomu izolacji ReadCommitted Zawiera mniej zamków niż Serializable.
Dodaj odpowiednie indeksy Aktualizacje ukierunkowane na indeks blokują mniej wierszy niż skanowanie tabel.
Unikaj interakcji użytkownika podczas transakcji Nigdy nie czekaj na dane wejściowe od użytkownika między BeginTx a Commit.

Ponowne próbowanie jest poprawną odpowiedzią w kodzie aplikacji, ale powtarzające się impaski na tym samym zapytaniu wskazują na problem projektowy. Użyj wykresu impasu SQL Server (zarejestrowanego za pomocą funkcji Extended Events lub sesji system_health), aby zidentyfikować kolidujące instrukcje i typy blokad, a następnie zastosować strategie z poprzedniej tabeli. Pełny przegląd analizy i zapobiegania impasom znajdziesz w przewodniku Deadlocks.

Transakcje i przypinanie połączeń

Transakcja rezerwuje pojedyncze połączenie z puli do momentu wywołania Commit() lub Rollback(). W tym czasie żadna inna gorutyna nie może korzystać z tego połączenia.

Implikacje:

  • Transakcje długoterminowe zmniejszają efektywną wielkość puli. Jeśli masz MaxOpenConns=25 i 20 otwartych transakcji, dostępnych jest tylko 5 połączeń do innych zadań.
  • Zapomniane Rollback() powoduje trwały wyciek połączenia.
  • Anulowanie kontekstu w kontekście transakcji cofa transakcję i przywraca połączenie do puli.
// 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()

Transakcje współistniejące

Każda goroutine powinna tworzyć własną transakcję. Nigdy nie udostępniaj elementu *sql.Tx pomiędzy gorutynami, ponieważ *sql.Tx nie jest bezpieczne do współbieżnego użycia:

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

Transakcje rozproszone

Sterownik go-mssqldb nie obsługuje transakcji rozproszonych (transakcji XA lub System.Transactions ich odpowiedników). Jeśli musisz skoordynować pracę w wielu bazach danych:

  • Użyj wzorca sagi z działaniami kompensacyjnymi.
  • Konsoliduj operacje w jednej bazie danych, gdy to możliwe.
  • Korzystaj z serwerów połączonych za pomocą BEGIN DISTRIBUTED TRANSACTION z języka Transact-SQL (T-SQL), jeśli obie bazy danych działają w programie SQL Server.

Lista kontrolna transakcji

Area Zalecenie
Bezpieczeństwo wycofywania zmian Zawsze zaraz defer tx.Rollback() po BeginTx.
Poziom izolacji Zacznij od ReadCommitted (domyślnie). Eskaluj tylko wtedy, gdy jest to konieczne.
Deadlocks Opakuj kod transakcyjny w pętlę powtórek. Dostęp do tabel w spójnej kolejności.
Czas trwania Utrzymuj transakcje tak krótkie, jak to możliwe. Ustaw limity czasu kontekstu.
Concurrency Nigdy nie udostępniaj elementu *sql.Tx między goroutines.
Punkty zapisywania Użyj SAVE TRANSACTION i ROLLBACK TRANSACTION <name> do częściowego wycofania.
Wpływ na basen Monitoruj db.Stats().InUse, aby wykrywać wycieki połączeń wynikające z niezatwierdzonych transakcji.