Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Transakcje - bezpieczne operacje na tributach

Legionista Marek przekazuje złoty puchar Gajuszowi. W kodzie to dwie operacje: odjąć puchar z inwentarza Marka, dopisać go Gajuszowi. Wykonujesz pierwszą, a w tej samej chwili pada serwer bazy. Rezultat? Puchar zniknął ze skarbca Imperium. Nie ma go u Marka, nie dotarł do Gajusza - wyparował między dwoma zapisami.

Skarbiec nie może sobie na to pozwolić. Potrzebujemy sposobu, by powiedzieć bazie: "te dwie operacje to jeden rozkaz - wykonaj obie albo żadnej". Tym sposobem jest transakcja.

Atomowość i reszta ACID

Właściwość, której szukamy, nazywa się atomowość (ang. atomicity) - transakcja jest niepodzielna jak atom: nie da się jej wykonać w połowie. To pierwsza litera skrótu ACID, którym opisuje się gwarancje transakcji:

  • Atomicity - atomowość: albo wszystkie operacje, albo żadna,
  • Consistency - spójność: baza przechodzi z jednego poprawnego stanu w drugi, nigdy nie zostaje w połowie,
  • Isolation - izolacja: równolegle działające transakcje nie podglądają swoich niedokończonych zmian,
  • Durability - trwałość: po zatwierdzeniu zmiany przetrwają nawet awarię zasilania.

Zapamiętaj przede wszystkim to A - reszta z niego wynika i to o nie chodzi w naszym pucharze.

Cykl życia transakcji

Każda transakcja przechodzi tę samą drogę, niezależnie od bazy i języka. Najpierw BEGIN - ogłaszamy początek i od tej chwili baza zapisuje zmiany na boku, jako nietrwałe. Potem wykonujemy operacje - nasze dwa zapisy. Następnie sprawdzamy, czy wszystko się udało. I na końcu jedna z dwóch dróg: COMMIT zatwierdza całość i dopiero teraz zmiany stają się trwałe, albo ROLLBACK cofa wszystko, jakby transakcji nigdy nie było.

Ten schemat - rozpocznij, wykonaj, zweryfikuj, zatwierdź lub cofnij - warto mieć w głowie, zanim spojrzymy na kod. Cała reszta lekcji to dwa sposoby zapisania go w TypeORM.

Sposób pierwszy: transakcja zarządzana

Najprostszy wariant to

dataSource.transaction()
. Podajesz funkcję, a TypeORM sam otwiera transakcję przed jej wywołaniem i zamyka po zakończeniu:

1await this.dataSource.transaction(async (manager) => {
2  await manager.update(Tribute, tributeId, { legionariusze: { id: toLegionaryId } });
3  await manager.increment(Legionary, { id: toLegionaryId }, 'tributeCount', 1);
4  await manager.decrement(Legionary, { id: fromLegionaryId }, 'tributeCount', 1);
5});

Funkcja dostaje jeden argument:

manager
- odpowiednik repozytorium, ale przypisany do tej konkretnej transakcji. To najważniejszy szczegół tego kodu: wszystkie operacje muszą przechodzić przez
manager
. Gdybyś w środku sięgnął po zwykłe
this.tributeRepository.save(...)
, ten zapis poszedłby poza transakcją - i nie cofnąłby się przy błędzie.

A skąd baza wie, czy zatwierdzić, czy cofnąć? Z wyjątku. Jeśli funkcja dobiegnie do końca spokojnie, TypeORM wykonuje COMMIT. Jeśli cokolwiek w środku rzuci wyjątek - robi ROLLBACK i przekazuje wyjątek dalej. Nie piszesz ani jednej linii obsługi - i to polecam jako domyślny wybór, @name.

Sposób drugi: QueryRunner, czyli pełna kontrola

Czasem potrzebujesz sterować transakcją ręcznie - na przykład wykonać coś między operacjami albo zareagować na konkretny błąd. Wtedy sięgasz po QueryRunner: obiekt reprezentujący jedno, wyłączne połączenie z bazą, na którym sam wywołujesz kolejne etapy.

1const queryRunner = this.dataSource.createQueryRunner();
2
3await queryRunner.connect();
4await queryRunner.startTransaction();
5
6try {
7  await queryRunner.manager.update(Tribute, tributeId, {
8    legionariusze: { id: toLegionaryId },
9  });
10  await queryRunner.manager.increment(
11    Legionary, { id: toLegionaryId }, 'tributeCount', 1,
12  );
13
14  await queryRunner.commitTransaction();
15} catch (error) {
16  await queryRunner.rollbackTransaction();
17  throw error;
18} finally {
19  await queryRunner.release();
20}

Prześledźmy ten kod krok po kroku, bo każda linia odpowiada jednemu etapowi cyklu, który poznałeś wyżej.

createQueryRunner()
tworzy obiekt,
connect()
rezerwuje dla niego połączenie z puli, a
startTransaction()
to nasz BEGIN. Operacje wykonujemy przez
queryRunner.manager
- znów ten sam warunek co poprzednio: tylko to, co przejdzie przez menedżera tego runnera, należy do transakcji. Blok
try
kończy się
commitTransaction()
, a
catch
wywołuje
rollbackTransaction()
i rzuca wyjątek dalej, żeby warstwa wyżej wiedziała, że transfer się nie udał.

Najważniejsza jest ostatnia część.

release()
musi stać w
finally
, bo połączenie trzeba zwrócić do puli w każdym scenariuszu - i po sukcesie, i po błędzie. Pominięcie tego nie zepsuje pojedynczego transferu; skutek pojawi się później, gdy pula wyczerpie się z wolnych połączeń i cała aplikacja stanie. To najczęstszy błąd przy ręcznych transakcjach.

Zwróć też uwagę, czego

release()
nie robi: nie zatwierdza ani nie cofa niczego. Jeśli zwolnisz runnera bez commitu, transakcja zostanie wycofana - zwolnienie połączenia to nie zapisanie zmian.

Izolacja - transakcje obok siebie

Została nam litera I z ACID. Gdy dwie transakcje działają równocześnie, baza musi zdecydować, ile jedna widzi z niedokończonej pracy drugiej. Poziom tego odgrodzenia podajesz jako argument:

1await queryRunner.startTransaction('SERIALIZABLE');

Domyślny poziom to zwykle

'READ COMMITTED'
- widzisz tylko zmiany już zatwierdzone przez innych.
'SERIALIZABLE'
to poziom najostrzejszy: transakcje wykonują się tak, jakby stały w kolejce, jedna po drugiej. Daje najsilniejsze gwarancje, ale kosztem przepustowości - baza częściej każe którejś transakcji się wycofać i spróbować ponownie. Dlatego poziom podnosimy świadomie i tylko tam, gdzie naprawdę trzeba, na przykład przy operacjach na saldzie skarbca.

Podsumowanie

Puchar Marka nie zginie już nigdy w połowie drogi:

  • transakcja to grupa operacji wykonywana atomowo - albo wszystkie, albo żadna,
  • skrót ACID opisuje jej gwarancje, a litera A to właśnie atomowość,
  • cykl życia jest zawsze ten sam: BEGIN, operacje, weryfikacja, COMMIT albo ROLLBACK,
  • dataSource.transaction(async manager => {...})
    prowadzi ten cykl za Ciebie: wyjście bez wyjątku to COMMIT, wyjątek to ROLLBACK,
  • QueryRunner daje pełną kontrolę:
    createQueryRunner
    ,
    connect
    ,
    startTransaction
    , a potem
    commitTransaction
    w
    try
    ,
    rollbackTransaction
    w
    catch
    i
    release
    w
    finally
    ,
  • w obu wariantach operacje muszą iść przez
    manager
    transakcji - zwykłe repozytorium działa poza nią,
  • poziom izolacji decyduje, ile transakcje widzą nawzajem ze swojej niedokończonej pracy.

W następnej lekcji poznasz seedery - kwatermistrzów, którzy wyposażą świeżą bazę w dane startowe. A na razie zapamiętaj: transakcja to jeden rozkaz złożony z wielu ruchów - baza wykona go w całości albo udając, że nigdy go nie usłyszała, nie zrobi nic.

Przejdź do CodeWorlds