Kurs NestJS · Moduł 3: TypeORM i bazy danych

Transakcje - bezpieczne operacje na tributach

5 min czytania
W tej lekcji6

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.

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.

Kod do tej lekcji: src/transactions.ts
1// Transakcje - Bezpieczne Operacje na Tributach
2import { Injectable } from '@nestjs/common';
3import { DataSource, QueryRunner, EntityManager } from 'typeorm';
4
5console.log("Transakcje - atomowe operacje na danych imperium!");
6
7// ===========================================
8// 1. Transakcja z QueryRunner
9// ===========================================
10
11@Injectable()
12export class TributeTransferService {
13  constructor(private dataSource: DataSource) {}
14
15  async transferTribute(
16    fromProvince: string,
17    toProvince: string,
18    amount: number,
19  ): Promise<void> {
20    // Utworz QueryRunner
21    const queryRunner = this.dataSource.createQueryRunner();
22    await queryRunner.connect();
23    await queryRunner.startTransaction();
24
25    try {
26      // Odejmij z prowincji zrodlowej
27      await queryRunner.query(
28        'UPDATE provinciae SET tribute = tribute - $1 WHERE name = $2',
29        [amount, fromProvince],
30      );
31
32      // Dodaj do prowincji docelowej
33      await queryRunner.query(
34        'UPDATE provinciae SET tribute = tribute + $1 WHERE name = $2',
35        [amount, toProvince],
36      );
37
38      // Zatwierdz transakcje
39      await queryRunner.commitTransaction();
40      console.log('Transfer zakonczony: ' + amount + ' z ' + fromProvince + ' do ' + toProvince);
41    } catch (error) {
42      // Cofnij w razie bledu
43      await queryRunner.rollbackTransaction();
44      console.error('Transfer anulowany: ' + error.message);
45      throw error;
46    } finally {
47      // Zwolnij polaczenie
48      await queryRunner.release();
49    }
50  }
51}
52
53// ===========================================
54// 2. Transakcja z EntityManager
55// ===========================================
56
57@Injectable()
58export class PromotionService {
59  constructor(private dataSource: DataSource) {}
60
61  async promoteLegionary(
62    legionaryId: number,
63    newRank: string,
64  ) {
65    return this.dataSource.transaction(async (manager: EntityManager) => {
66      // Znajdz legioniste
67      const legionary = await manager.findOneBy('Legionary', {
68        id: legionaryId,
69      });
70
71      if (!legionary) {
72        throw new Error('Legionary non inventus!');
73      }
74
75      const oldRank = legionary.rank;
76
77      // Zaktualizuj range i doswiadczenie
78      legionary.rank = newRank;
79      legionary.experience += 1;
80      await manager.save('Legionary', legionary);
81
82      // Zapisz historie awansu
83      await manager.save('PromotionHistory', {
84        legionaryId,
85        oldRank,
86        newRank,
87        date: new Date(),
88      });
89
90      console.log('Awans: ' + oldRank + ' -> ' + newRank);
91      return legionary;
92    });
93  }
94}
95
96// ===========================================
97// 3. Wlasciwosci ACID
98// ===========================================
99
100// Atomicity  - wszystko albo nic
101// Consistency - dane zawsze spojne
102// Isolation   - transakcje nie koliduja
103// Durability  - zatwierdzone = trwale
104
105console.log("\n=== PODSUMOWANIE TRANSAKCJI ===");
106console.log("QueryRunner: connect -> startTransaction -> commit/rollback -> release");
107console.log("dataSource.transaction(manager => {...}) - prostsza skladnia");
108console.log("ACID - atomowosc, spojnosc, izolacja, trwalosc");
109console.log("try/catch/finally - zawsze obsluz bledy!");
110

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest transakcja bazodanowa?

  2. 2. Co oznacza litera 'A' w skrócie ACID opisującym właściwości transakcji?

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj etapy cyklu życia transakcji bazodanowej:

  • Edytor kodu

    Stwórz metodę transferTributes, która używa queryRunner.startTransaction(), wykonuje dwa zapisy i commituje (lub rollbackuje przy błędzie)

  • Klikanie w kolejności

    Ułóż elementy kodu transakcji z QueryRunner w prawidłowej kolejności:

  • Układanie w poziomie

    Ułóż elementy rozpoczęcia transakcji w prawidłowej kolejności:

  • Edytor kodu

    Napisz metodę serwisu używającą dataSource.transaction(async manager => { ... }), która tworzy tribute i aktualizuje prowincję

Przydatne artykuły