1. Wstęp do transakcji i spójności danych

Transakcja to logiczna jednostka w procesie wykonywania systemu zarządzania bazą danych, składająca się z serii operacji. Te operacje albo wszystkie się udają, albo wszystkie zawodzą i są traktowane jako nierozdzielna całość. Główne cechy transakcji można streścić jako ACID:

  • Atomowość: Wszystkie operacje w transakcji są albo całkowicie wykonane, albo w ogóle nie zostały wykonane; częściowe wykonanie nie jest możliwe.
  • Spójność: Transakcja musi przenieść bazę danych z jednego spójnego stanu do innego spójnego stanu.
  • Izolacja: Wykonanie transakcji musi być odizolowane od ingerencji innych transakcji, a dane pomiędzy wieloma jednoczesnymi transakcjami muszą być izolowane.
  • Trwałość: Po zatwierdzeniu transakcji, modyfikacje dokonane przez nią będą trwałe w bazie danych.

Spójność danych odnosi się do utrzymania poprawnego i ważnego stanu danych w bazie danych po serii operacji. W scenariuszach z udziałem równoczesnego dostępu lub awarii systemu, spójność danych jest szczególnie ważna, a transakcje zapewniają mechanizm, który gwarantuje, że spójność danych nie jest naruszona nawet w przypadku błędów lub konfliktów.

2. Przegląd frameworka ent

ent to framework encyjny, który poprzez generowanie kodu w języku programowania Go zapewnia bezpieczne API typów do operowania na bazach danych. Dzięki temu operacje bazodanowe stają się bardziej intuicyjne i bezpieczne, umożliwiając uniknięcie problemów związanych z bezpieczeństwem, takich jak wstrzykiwanie SQL. W zakresie przetwarzania transakcji, framework ent zapewnia silne wsparcie, umożliwiając programistom wykonywanie skomplikowanych operacji transakcyjnych za pomocą zwięzłego kodu i zapewnienie spełnienia właściwości ACID transakcji.

3. Inicjowanie transakcji

3.1 Jak rozpocząć transakcję w ent

W frameworku ent, nową transakcję można łatwo zainicjować w określonym kontekście, używając metody client.Tx, która zwraca obiekt transakcji Tx. Przykład kodu wygląda następująco:

tx, err := client.Tx(ctx)
if err != nil {
    // Obsłuż błędy podczas rozpoczynania transakcji
    return fmt.Errorf("Wystąpił błąd podczas rozpoczynania transakcji: %w", err)
}
// Wykonywanie kolejnych operacji za pomocą tx...

3.2 Wykonywanie operacji w ramach transakcji

Po pomyślnym utworzeniu obiektu Tx, można go użyć do wykonywania operacji na bazie danych. Wszystkie operacje tworzenia, usuwania, aktualizacji i zapytania wykonane na obiekcie Tx staną się częścią transakcji. Poniższy przykład przedstawia serię operacji:

hub, err := tx.Group.
    Create().
    SetName("Github").
    Save(ctx)
if err != nil {
    // Jeśli wystąpi błąd, cofnij transakcję
    return rollback(tx, fmt.Errorf("Nie udało się utworzyć grupy: %w", err))
}
// Można tutaj dodać dodatkowe operacje...
// Zatwierdź transakcję
tx.Commit()

4. Obsługa błędów i cofanie transakcji

4.1 Ważność obsługi błędów

Pracując z bazami danych, różne błędy, takie jak problemy z siecią, konflikty danych czy naruszenia ograniczeń, mogą wystąpić w dowolnym momencie. Prawidłowa obsługa tych błędów jest kluczowa dla utrzymania spójności danych. W transakcji, jeśli operacja zawiedzie, transakcja musi zostać cofnięta, aby zapewnić, że częściowo wykonane operacje, które mogą naruszać spójność bazy danych, nie pozostaną.

4.2 Jak zaimplementować cofanie

W frameworku ent, można użyć metody Tx.Rollback() do całkowitego cofnięcia transakcji. Zazwyczaj definiuje się funkcję pomocniczą rollback, aby obsłużyć cofanie i błędy, jak pokazano poniżej:

func rollback(tx *ent.Tx, err error) error {
    if rerr := tx.Rollback(); rerr != nil {
        // Jeśli cofnięcie zawiedzie, zwróć oryginalny błąd i błąd cofnięcia razem
        err = fmt.Errorf("%w: Wystąpił błąd podczas cofania transakcji: %v", err, rerr)
    }
    return err
}

Dzięki tej funkcji rollback można bezpiecznie obsługiwać błędy i cofanie transakcji, gdy dowolna operacja w ramach transakcji zawiedzie. Zapewnia to, że nawet w przypadku błędu nie będzie to miało negatywnego wpływu na spójność bazy danych.

5. Użycie klienta transakcyjnego

W praktycznych zastosowaniach mogą wystąpić sytuacje, w których potrzebujemy szybko przekształcić kod niedziałający transakcyjnie w kod działający transakcyjnie. W takich przypadkach możemy skorzystać z klienta transakcyjnego, aby bezproblemowo przenieść kod. Poniżej znajduje się przykład, jak przekształcić istniejący kod klienta niedziałającego transakcyjnie, aby wspierał transakcje:

// W tym przykładzie pakujemy oryginalną funkcję Gen w transakcję.
func WrapGen(ctx context.Context, client *ent.Client) error {
    // Najpierw tworzymy transakcję
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    // Pobieramy klienta transakcyjnego z transakcji
    txClient := tx.Client()
    // Wykonujemy funkcję Gen przy użyciu klienta transakcyjnego, niezmieniając oryginalnego kodu Gen
    if err := Gen(ctx, txClient); err != nil {
        // Jeśli wystąpi błąd, cofamy transakcję
        return rollback(tx, err)
    }
    // W przypadku powodzenia, zatwierdzamy transakcję
    return tx.Commit()
}

W powyższym kodzie używany jest klient transakcyjny tx.Client(), umożliwiając wykonanie oryginalnej funkcji Gen podczas gwarancji transakcji. Ten sposób pozwala nam wygodnie przekształcić istniejący kod niedziałający transakcyjnie w kod działający transakcyjnie przy minimalnym wpływie na oryginalną logikę.

6. Najlepsze praktyki dotyczące transakcji

6.1 Zarządzanie transakcjami za pomocą funkcji wywoławczych

Gdy nasza logika kodu staje się złożona i obejmuje wiele operacji na bazie danych, scentralizowane zarządzanie tymi operacjami w ramach transakcji staje się szczególnie istotne. Poniżej znajduje się przykład zarządzania transakcjami za pomocą funkcji wywoławczych:

func WithTx(ctx context.Context, client *ent.Client, fn func(tx *ent.Tx) error) error {
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    // Używamy defer i recover do obsługi potencjalnych scenariuszy wystąpienia panic
    defer func() {
        if v := recover(); v != nil {
            tx.Rollback()
            panic(v)
        }
    }()
    // Wywołujemy dostarczoną funkcję wywoławczą w celu wykonania logiki biznesowej
    if err := fn(tx); err != nil {
        // W przypadku wystąpienia błędu, cofamy transakcję
        if rerr := tx.Rollback(); rerr != nil {
            err = fmt.Errorf("%w: cofanie transakcji: %v", err, rerr)
        }
        return err
    }
    // Jeśli logika biznesowa jest bezbłędna, zatwierdzamy transakcję
    return tx.Commit()
}

Korzystając z funkcji WithTx do owinięcia logiki biznesowej, możemy zapewnić, że nawet jeśli wystąpią błędy lub wyjątki w obrębie logiki biznesowej, transakcja będzie poprawnie obsługiwana (zatwierdzona lub cofnięta).

6.2 Użycie haków transakcji

Podobnie jak w przypadku haseł schematu i haseł czasu wykonania, możemy także zarejestrować haki w ramach aktywnej transakcji (Tx), które zostaną wywołane podczas Tx.Commit lub Tx.Rollback:

func Do(ctx context.Context, client *ent.Client) error {
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    tx.OnCommit(func(next ent.Committer) ent.Committer {
        return ent.CommitFunc(func(ctx context.Context, tx *ent.Tx) error {
            // Logika przed zatwierdzeniem transakcji
            err := next.Commit(ctx, tx)
            // Logika po zatwierdzeniu transakcji
            return err
        })
    })
    tx.OnRollback(func(next ent.Rollbacker) ent.Rollbacker {
        return ent.RollbackFunc(func(ctx context.Context, tx *ent.Tx) error {
            // Logika przed cofnięciem transakcji
            err := next.Rollback(ctx, tx)
            // Logika po cofnięciu transakcji
            return err
        })
    })
    // Wykonujemy inną logikę biznesową
    //
    // 
    //
    return err
}

Dodając haki podczas zatwierdzania i cofania transakcji, możemy obsługiwać dodatkową logikę, taką jak logowanie czy oczyszczanie zasobów.

7. Zrozumienie różnych poziomów izolacji transakcji

W systemie baz danych ustawienie poziomu izolacji transakcji ma kluczowe znaczenie dla zapobiegania różnym problemom z równoczesnością (takim jak brudne odczyty, odczyty nierekurencyjne i odczyty upiory). Oto kilka standardowych poziomów izolacji i jak je ustawić w frameworku ent:

  • READ UNCOMMITTED: Najniższy poziom, umożliwia odczyt zmian danych, które nie zostały jeszcze zatwierdzone, co może prowadzić do brudnych odczytów, odczytów nierekurencyjnych i odczytów upiornych.
  • READ COMMITTED: Umożliwia odczyt i zatwierdzanie danych, zapobiegając brudnym odczytom, ale odczyty nierekurencyjne i odczyty upiorne mogą wciąż wystąpić.
  • REPEATABLE READ: Zapewnia, że odczytywanie tych samych danych wielokrotnie w ramach tej samej transakcji daje spójne wyniki, zapobiegając odczytom nierekurencyjnym, ale odczyty upiorne mogą wciąż wystąpić.
  • SERIALIZABLE: Najbardziej rygorystyczny poziom izolacji, próbuje zapobiec brudnym odczytom, odczytom nierekurencyjnym i odczytom upiornym poprzez blokowanie zaangażowanych danych.

W ent, jeśli sterownik bazy danych obsługuje ustawianie poziomu izolacji transakcji, można go ustawić w następujący sposób:

// Ustaw poziom izolacji transakcji na repeatable read
tx, err := client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})

Zrozumienie poziomów izolacji transakcji i ich zastosowanie w bazach danych jest kluczowe dla zapewnienia spójności danych i stabilności systemu. Deweloperzy powinni wybrać odpowiedni poziom izolacji w oparciu o konkretne wymagania aplikacji, aby osiągnąć najlepsze praktyki zapewnienia bezpieczeństwa danych i optymalizacji wydajności.