Transakcje w Firebird
Transakcje w Firebird: ACID, poziomy izolacji, zakleszczenia i rozwiązywanie konfliktów aktualizacji
Alexey Kovyazin, przy pomocy Vlada Khorsuna i Dmitry’ego Kuzmenki, 08-KWI-2019
Spis treści:
- Jak będziemy mówić o transakcjach
- ACID na wykresach
- Poziomy izolacji transakcji w Firebird
- Rozwiązywanie konfliktów aktualizacji: opcja Wait
- Prawdziwe zakleszczenie
Czy trzeba wiedzieć, jak działają transakcje?
Prawdopodobnie tak, ponieważ pojęcie transakcji jest proste, wielu programistów nie docenia znaczenia prawidłowego używania transakcji w Firebird. Jednak dopiero po dokładnym zrozumieniu, jak działają transakcje, można pojąć wiele tajemniczych rzeczy związanych z wydajnością, takich jak nagłe spowolnienia bazy danych (związane z zamiataniem nadmiernych wersji rekordów, które pojawiają się z powodu złego zarządzania transakcjami).
Ogólnie rzecz biorąc, pojęcie transakcji odnosi się do każdego dynamicznego systemu, który przechodzi z jednego stanu do drugiego. Na przykład klasycznym przykładem transakcji jest przelewanie pieniędzy z jednego konta na drugie. Zwykle wygląda to mniej więcej tak:
Begin --- przelewanie pieniędzy z konta 1 na konto 2
--zmniejszenie konta 1
--zwiększenie konta 2
End - zatwierdzenie transakcji
Przykład sprowadza się do tego, że pieniądze muszą zniknąć z konta 1 i pojawić się na koncie 2 jednocześnie, w przeciwnym razie w systemie będzie albo nadmiar pieniędzy, albo niewyjaśniony ich brak przez pewien czas.
Z punktu widzenia baz danych transakcja jest zwykle definiowana jako grupa operacji wykonywanych na bazie danych, która jest postrzegana jako niezależna od innych transakcji. Z mojego punktu widzenia ta definicja nie jest ani lepsza, ani gorsza od innych, ale jak każda definicja, ma niewielki sens bez znajomości rzeczywistych wewnętrznych mechanizmów i logiki DBMS.
Uważa się, że transakcja w bazie danych musi spełniać tak zwane wymagania ACID
A - Atomowość (Atomicity)
С - Spójność (Consistency)
I - Izolacja (Isolation)
D - Trwałość (Durability)
Wielu programistów aplikacji bazodanowych jest tak zainspirowanych tym akronimem, że często używa argumentów typu “nie masz D w ACID”, gdy porównuje różne systemy DBMS (co zwykle jest natychmiast kontynuowane słowami “nie obchodzi mnie, co myślisz”).
W rzeczywistości wszystko jest dość proste - ACID to zestaw wymagań dotyczących implementacji transakcji w konkretnym DBMS, niektóre z nich są bardzo rygorystyczne (na przykład D - oczywiście trwałość jest ważna!), a niektóre mniej rygorystyczne - gdy przyjrzymy się poziomom izolacji transakcji, zobaczymy, że izolacja może się różnić.
Dlatego nie warto od razu próbować zrozumieć, co dosłownie oznacza ten akronim. Zamiast tego przyjrzymy się logice działania systemów DBMS (a w szczególności transakcji) i spojrzymy na ACID z punktu widzenia “jak to jest zrobione”, a nie “co to znaczy”.
Ponieważ aspekty transakcji są złożone, potrzebujemy reprezentacji graficznej - rodzaju wykresów, aby pokazać działanie i interakcję transakcji. Korzystając z tych wykresów, będziemy mogli zbudować logiczną narrację i przyjrzeć się szczegółom działania transakcji.
Przede wszystkim wprowadzimy oś czasu, ponieważ transakcje rozwijają się w czasie. Oś czasu będzie oznaczona tak, jak tego potrzebujemy - nie potrzebujemy ani sekund, ani minut, ale kluczowe kroki interakcji między transakcjami:

Następnie dodamy transakcję na tę oś czasu - narysujmy ją w formie prostokąta, którego boki będą odpowiadać początkowi i końcowi transakcji. Ponieważ wszystkie transakcje w Firebird są numerowane, podamy również numer transakcji.

Zatem wykres pokazuje transakcję numer 11, która rozpoczęła się w momencie t3 i zakończyła w momencie t10. Istnieją dwa sposoby zakończenia transakcji - COMMIT, czyli zastosowanie wszystkich zmian dokonanych w ramach transakcji, oraz ROLLBACK, czyli anulowanie wszystkich zmian dokonanych w ramach transakcji. Sposób zakończenia transakcji pokażemy w następujący sposób:

Aby móc kontynuować, będziemy musieli określić różne parametry transakcji na tych wykresach i będziemy je określać w lewym dolnym rogu prostokąta reprezentującego odpowiednią transakcję - ten przykład pokazuje, że transakcja #11 ma poziom izolacji snapshot.

Kiedy mówimy, że “transakcja X wstawia dane” lub “transakcja Y czyta takie a takie dane” - jest to formalnie niepoprawne, ponieważ powinniśmy mówić “zmiany zostały dokonane w ramach transakcji X”. Tylko instrukcje SQL mogą czytać lub wstawiać dane, więc jeśli jest to ważne dla narracji, pokażemy te instrukcje wewnątrz prostokąta transakcji:

W tym przykładzie mamy operację INSERT dla tabeli T1, pola i1, wartości 100 - ta operacja jest wykonywana w ramach transakcji #11 i jest zatwierdzona.
Ponadto czasami będziemy musieli pokazać wynik operacji, na przykład w następującym przykładzie:

Ten przykład pokazuje następujące rzeczy:
- Transakcja #11 z parametrem poziomu izolacji ustawionym na snapshot (poziomy izolacji zostaną omówione później, tutaj jest to pokazane tylko po to, aby narysować pełny obraz) rozpoczyna się w momencie t3
- Operacja INSERT INTO T1(i1) values (100), która wstawia wartość 100 do pola i1 tabeli t1, rozpoczyna się w momencie t5 I kończy w momencie t7
- Operacja SELECT i1 from T1, która zwraca wartość i1 równą 100, rozpoczyna się w momencie t8
- Transakcja #11 kończy się instrukcją COMMIT, tj. zmiany dokonane przez transakcję #11 są zatwierdzane w bazie danych
Zatem za pomocą wykresów transakcji możemy szczegółowo opisać, co dzieje się w bazie danych i poznać, jak działają transakcje.
Teraz, gdy mamy wykresy transakcji, zobaczmy, co naprawdę oznacza akronim ACID.
Atomowość
Atomowość oznacza, że albo wszystkie operacje składające się na transakcję są wykonywane, albo żadna z nich nie jest wykonywana: “wszystko albo nic”. Wydaje się to proste, ale potem wychodzą na jaw szczegóły.
Po pierwsze, DBMS (nie tylko Firebird, ale prawie wszystkie) ma 2 typy atomowości: atomowość na poziomie pojedynczej instrukcji i atomowość na poziomie grupy instrukcji w ramach transakcji.
Atomowość na poziomie instrukcji oznacza, że instrukcja UPDATET1 SETX=1 WHEREY=2 jest zawsze albo wykonana pomyślnie, albo nie.
Atomowość na poziomie grupy instrukcji działa inaczej (użyjemy tutaj pseudokodu, aby oznaczyć, kiedy transakcja jest rozpoczynana i zatwierdzana):
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
Mniej więcej tak będzie to wyglądać na wykresie:

Oznacza to, że wszystkie trzy instrukcje INSERT są wykonywane pomyślnie, a zmiany przez nie dokonane są zatwierdzane w momencie zatwierdzenia transakcji #11.
Pytanie, które często zadaję na warsztatach, gdy mowa o transakcjach - czy instrukcja COMMIT zostanie pomyślnie wykonana dla transakcji 11, jeśli INSERT INTO..300 zgłosi wyjątek:

Znaczna część publiczności zawsze odpowiada, że instrukcja COMMIT nie zostanie pomyślnie wykonana! (Co ciekawe, w niektórych innych systemach DBMS spowoduje to wycofanie transakcji!)
Jednak to nieprawda - wystarczy uruchomić isql i przeprowadzić eksperyment z dowolną bazą danych (isql ma prostą i bezpośrednią implementację operacji, bez “zgadywania” za użytkownika).
Rzecz w tym, że atomowość na poziomie grup instrukcji zapewniana przez zatwierdzanie transakcji jest kwestią logiki biznesowej. Programista aplikacji musi zdecydować, czy transakcja powinna zostać zatwierdzona w przypadku wyjątku w trzeciej instrukcji INSERT, czy nie. Jeśli logika biznesowa umożliwia zatwierdzenie wyniku, instrukcja COMMIT może być z łatwością wykonana.
Zatem wymóg atomowości w ACID jest wymogiem, aby DBMS mógł zatwierdzić lub wycofać wyniki grupy instrukcji wykonanych w ramach jednej transakcji. Decyzja o zatwierdzeniu lub wycofaniu zależy od logiki biznesowej, którą należy zaimplementować.
I podkreślmy to jeszcze raz - chociaż atomowość transakcji dla grupy instrukcji oznacza możliwość zatwierdzenia lub wycofania całej grupy niezależnie od wyników (a wybór zależy od logiki biznesowej), to atomowość pojedynczej instrukcji jest gwarantowana przez implementację DBMS, tj. niemożliwe jest wykonanie jednej instrukcji (na przykład UPDATE) “niecałkowicie” (nieatomowo).
Spójność
Spójność oznacza, że dane w bazie danych nie zawierają sprzeczności. Oczywiście tutaj widzimy całe pole do spekulacji, ponieważ “co to znaczy ’nie zawiera sprzeczności’ w ogóle”?
Zwykle wyróżnia się dwa poziomy spójności:
- Poziom bazy danych, gdzie spójność oznacza zgodność danych z ograniczeniami bazy danych, takimi jak klucze Primary, Unique i Foreign, Checks. Ten poziom spójności jest zapewniany przez fakt, że ograniczenia bazy danych nie pozwolą na wstawienie danych, które nie odpowiadają ograniczeniom: np. CHECK(x>0) nie pozwoli na wstawienie liczby ujemnej do odpowiedniego pola.
- Poziom logiki biznesowej, gdzie spójność jest zapewniana przez programistę aplikacji za pomocą narzędzi oferowanych przez DBMS, takich jak transakcje.
Jak transakcje pomagają zapewnić spójność na poziomie logiki biznesowej? Dość prosto - jeśli weźmiemy przykład przelewu pieniędzy, programista musi upewnić się, że wszystkie zmiany zostaną wycofane w przypadku wyjątku, a użycie transakcji mu w tym pomaga.
Starttransaction
Zmniejszenie kwoty pieniędzy na koncie 1…. Sukces
Zwiększenie jej na koncie 2… Błąd
Rollback ---- w przypadku wyjątku!
Innymi słowy, programista musi napisać kod w taki sposób, aby dane zostały wycofane w przypadku wyjątku, a tym samym spójność danych została zachowana z punktu widzenia logiki biznesowej.
W ten sposób wymóg spójności w akronimie ACID oznacza, że konieczne jest, aby DBMS miał możliwość utrzymania spójności danych za pomocą mechanizmu transakcji.
Izolacja
Wymóg izolacji transakcji wynika z konieczności zagwarantowania wyniku zestawu operacji niezależnie od kolejności, w jakiej są wykonywane.
Mówiąc prosto, każda transakcja musi być wykonana z jednym i tym samym wynikiem niezależnie od jednocześnie aktywnych transakcji.
Mechanizm transakcji ma zapewniać spójność na poziomie logiki biznesowej, ale ma również chronić transakcje przed tymczasowymi niezatwierdzonymi danymi, które mogą pojawić się podczas wykonywania równoczesnych transakcji.
W praktyce wygląda to tak:

Widzimy transakcję #11 rozpoczętą w momencie t2, w ramach której wstawienie do tabeli następuje w momencie t3-t5. Transakcja #11 nie jest zatwierdzana bezpośrednio po wstawieniu, ale pozostaje aktywna aż do momentu t8.
Równocześnie rozpoczyna się transakcja #12, która wykonuje instrukcję SELECT dla rekordów tabeli, do których wstawiane są dane w ramach transakcji #11. Pierwsza instrukcja SELECT jest wykonywana w momencie t6, kiedy operacja wstawiania jest już zakończona, ale ta instrukcja zwraca pusty wynik, ponieważ transakcja #12 nie może widzieć niezatwierdzonych danych z innych transakcji.
Transakcja #11 jest zatwierdzana w momencie t8, a instrukcja SELECT w ramach transakcji #12 jest wykonywana w momencie t9. Zwraca wynik równy 100, ponieważ dane utworzone w ramach transakcji #11 są teraz zatwierdzone (oraz ponieważ poziom izolacji transakcji #12 to read committed, ale o tym porozmawiamy później).
Ten przykład w zupełności wystarczy, aby zilustrować wymóg izolacji - w przeciwieństwie do atomowości i spójności, izolacja jest implementowana jako ścisłe reguły zwane poziomami izolacji, a każda transakcja musi mieć parametr ustawiający poziom izolacji, z którym pracuje.
Trwałość
Koncepcja trwałości pozwala programiście w pełni polegać na fakcie, że dane utworzone w ramach zatwierdzonej transakcji natychmiast pojawią się w bazie danych i nie znikną z niej (bez jawnych poleceń usuwających lub zmieniających je, oczywiście), niezależnie od tego, co wydarzy się później.
Jak widać, wymóg trwałości to po prostu zdrowy rozsądek - mało kto zgodziłby się korzystać z systemu, z którego dane mogą nagle zniknąć.
ACID: podsumowanie
ACID oznacza wymagania dotyczące tego, jak muszą działać transakcje:
- Atomowość
- Polecenia są zawsze atomowe
- Grupy poleceń mogą być uczynione atomowymi za pomocą transakcji
- Spójność
- Dwa poziomy spójności: ograniczenia bazy danych i logika biznesowa
- Izolacja
- Zapewniana przez mechanizm transakcji za pomocą ustawionych dla nich poziomów izolacji
- Trwałość
- Wszystkie zatwierdzone dane stają się trwałe
Jak widać, wszystko jest dość logiczne. W praktyce główną trudność stanowią poziomy izolacji, więc przyjrzyjmy się szczegółowo, jak działają.
Poziom izolacji transakcji określa, które zatwierdzone dane ta transakcja może zobaczyć.
Istnieją poziomy izolacji, które umownie nazywane są standardowymi. Są one opisane w standardzie ANSI SQL (w różnych rewizjach). O ile mi wiadomo, nie ma ani jednego DBMS, w którym byłyby zaimplementowane dokładnie tak, jak opisano je w standardzie, ale nikomu to nie przeszkadza, ponieważ rzeczywiste mechanizmy transakcji w konkretnych DBMS mają wszystkie niezbędne opcje do implementacji logiki biznesowej.
Klasyczną definicję poziomów izolacji można znaleźć w „A Critique of ANSI SQL Isolation Levels”.
Dla tych, którzy przeczytali ten artykuł, poniżej znajduje się tabela porównująca klasyczne poziomy izolacji z podobnymi w Firebird. Oczywiście odpowiedniość nie jest bezpośrednia, ponieważ poziomy izolacji w Firebird, podobnie jak w innych DBMS, nie są w 100% zgodne z definicjami ANSI SQL, ale są do nich bardzo podobne.
| Poziomy izolacji ANSI | Poziom izolacji w Firebird |
| Read Uncommitted | n/d |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
Jak każdy inny DBMS, Firebird ma swoje specyficzne cechy w implementacji izolacji. Teraz skupimy się na tym, jak poziomy izolacji działają w Firebird, zamiast na tym, jak dobrze są zgodne ze standardem.
Poziom izolacji Snapshot
Poziom izolacji Snapshot był pierwszym w oryginalnym kodzie InterBase i pozostaje domyślnym dla podstawowego API Firebird i narzędzi (na przykład isql.exe). To może być powód, dla którego jest najłatwiejszy do zrozumienia.
Snapshot izoluje transakcję od wszelkich zmian dokonanych od momentu jej rozpoczęcia.
Spójrzmy na poniższy wykres transakcji: pokazuje on transakcję #10 rozpoczętą z poziomem izolacji snapshot. W ramach tej transakcji wykonywanych jest kilka poleceń SELECT dla tabeli T1, która w tym przykładzie nie ma żadnych rekordów.

Równoległa transakcja #15 rozpoczęta po starcie transakcji #10 wstawia dane do tabeli T1, a transakcja ta kończy się poleceniem COMMIT w momencie t9, tj. dane są w tym momencie zatwierdzone w bazie danych i są dostępne dla poleceń z innych transakcji.
Jednak polecenie w transakcji #10 wykonane w momencie t10 (tj. po zatwierdzeniu transakcji #15) nie widzi wstawionych danych, ponieważ poziom izolacji snapshot pozwala jej widzieć tylko zatwierdzone dane wstawione lub zmienione PRZED ROZPOCZĘCIEM transakcji #10.
Zatem poziom izolacji Snapshot pozwala pracować z bazą danych tak, jakby była zamrożona w momencie rozpoczęcia transakcji. Jest to zwykle niezbędne do budowania skomplikowanych raportów opartych na szybko zmieniających się danych: snapshot jest używany, aby uniknąć sytuacji, w której pierwsza część raportu opiera się na jednych danych, a ostatnia na innych.
Jednak ta świetna funkcja ma swoją cenę - gdy później przyjrzymy się, jak izolacja jest implementowana w Firebird, zobaczysz, że uruchamianie bardzo długich transakcji z poziomem izolacji snapshot skutkuje nadmierną liczbą wersji rekordów i niższą wydajnością.
Poziom izolacji Read Committed
Transakcja z poziomem izolacji read committed może widzieć zatwierdzone dane innych transakcji, które są zatwierdzane, gdy jest ona aktywna (w przeciwieństwie do poziomu snapshot, gdy można widzieć tylko dane zatwierdzone przed momentem rozpoczęcia transakcji).
Pokażmy, jak działa poziom izolacji read committed, używając poniższego wykresu:

Pokazuje on przykład praktycznie identyczny z poprzednim: dwie równoległe transakcje, z których jedna regularnie odczytuje dane z tabeli T1, podczas gdy druga wstawia i zatwierdza dane.
W przeciwieństwie do przypadku z poziomem izolacji snapshot, transakcja #10 w tym przykładzie widzi dane wstawione i zatwierdzone przez transakcję #15.
Ten przykład daje nam wyobrażenie o wpływie poziomu izolacji read committed: polecenia w ramach transakcji z tym poziomem izolacji mogą widzieć dane zatwierdzone przed momentem wykonania odpowiedniego polecenia.
Następny wykres pokazuje przykład, w którym dwie równoległe transakcje #11 i #18 zmieniają dane.
Zauważ, że transakcja #11 zaczyna się przed rozpoczęciem transakcji #14, która odczytuje dane, podczas gdy transakcja #18 zaczyna się po niej, ale nie wpływa to na wynik: jeśli dane są zatwierdzone, mogą być widziane przez równoległą transakcję z poziomem izolacji read committed.

Ta możliwość czyni poziom izolacji read committed naturalnym wyborem dla tych poleceń SQL, które są regularnie wykonywane w celu pokazania najnowszego stanu bazy danych (na przykład w celu pokazania najnowszych zamówień).
Część poświęcona odśmiecaniu pokaże, że transakcje read committed z modyfikatorem read-only w Firebird do wersji 4 są najlepszym wyborem dla „nieskończonych” transakcji odczytu, ponieważ są uruchamiane jako pre-committed.
Poziom izolacji Snapshot table stability
Historię o poziomie izolacji snapshot table stability, który jest odpowiednikiem standardowego poziomu Serializable, można opowiedzieć albo bardzo krótko, albo dość długo i szczegółowo.
Krótka wersja historii jest następująca: ten poziom jest całkowicie podobny do poziomu snapshot z dodatkowym blokowaniem tabeli (tabela musi być jawnie określona w parametrach transakcji) dla zapisu i odczytu. Oznacza to, że można rozpocząć transakcję, która w pełni zajmie określoną tabelę, a wszelkie inne transakcje otrzymają błędy dostępu.
Innymi słowy, transakcja z poziomem izolacji snapshot table stability faktycznie umieści wszystkie zapytania do określonej tabeli w kolejce. W rzeczywistości tylko odczyty w zwykłych transakcjach będą wykonywane poza kolejnością (jak zwykle), podczas gdy wszystkie inne tryby utworzą kolejkę (zależy to oczywiście od interakcji).
Jeśli zostanie zaimplementowana bez ostrożności, może powodować blokady i niemożność pracy z bazą danych, dlatego programiści aplikacji bazodanowych Firebird mogą obawiać się używania tego poziomu izolacji.
Jednak jeśli zostanie poprawnie zaimplementowany, poziom izolacji Serializable umożliwia łatwe tworzenie kolejek i sekwencyjne zmiany rekordów w bazie danych, co może być bardzo przydatne do implementacji liczników, sekwencyjnych numerów dokumentów i innych podobnych obiektów.
Aby poprawnie opisać, jak utworzyć kolejkę za pomocą transakcji z poziomem izolacji snapshot table stability, będziemy musieli przyjrzeć się jeszcze jednemu parametrowi transakcji: wait/nowait - a następnie wrócić do przykładu z kolejką.
Wcześniej zbadaliśmy taki sposób interakcji między transakcjami, w którym dane są zmieniane w jednej transakcji, a odczytywane w innej.
Jednak w praktyce często zdarza się, że różne transakcje próbują zmienić te same dane, a ponieważ w bazie danych zapisywany jest tylko jeden wynik, równoległa transakcja otrzyma komunikat o konflikcie - w rzeczywistości wyjątek, który przerwie (i anuluje) wykonanie tego konkretnego polecenia, które próbuje zmienić już zmienione dane.
Opcja wait określa, jak transakcja powinna zareagować na konflikt aktualizacji. Istnieją trzy sposoby skonfigurowania tej opcji:
- Wait (bez parametrów) = czekaj, aż równoległa transakcja się zakończy
- Wait Timeout N sec = czekaj, aż równoległa transakcja się zakończy, ale nie dłużej niż N sekund
- Nowait - nie czekaj, aż równoległa transakcja się zakończy
Zauważ, że opcja wait jest tutaj określona w pseudokodzie, podczas gdy nazwy mogą być różne w API i w konkretnych komponentach, chociaż znaczenie pozostaje takie samo.
Przyjrzyjmy się szczegółowo, co dzieje się w przypadku konfliktów aktualizacji z różnymi wariantami opcji wait.
Wait
Wyobraźmy sobie dwie równoległe aktywne transakcje (#11 i #14), w ramach których wykonywane jest polecenie UPDATE, które musi zmienić ten sam rekord w tej samej tabeli T1.
Transakcja #14 działa z opcją wait (jeśli używasz isql do odtworzenia przykładów, wait jest ustawione domyślnie).
Polecenie UPDATE w transakcji #11 zaczyna się w momencie t3 i kończy w momencie t5, ale transakcja nie jest jeszcze zatwierdzona - tj. polecenie COMMIT nie występuje aż do momentu t6.
Poniższy wykres pokazuje tę sytuację:

Polecenie UPDATE jest również wykonywane w transakcji #14 i próbuje zaktualizować ten sam rekord w tej samej tabeli, ale zaczyna się później - około momentu t4.
Ponieważ istnieje konflikt aktualizacji z aktualizacją z transakcji #11 i w transakcji #14 określono wait, polecenie UPDATE będzie czekać, aż konfliktowa transakcja #11 się zakończy.
Jeśli transakcja #11 trwa wystarczająco długo, polecenie UPDATE w transakcji #14 będzie wydawać się zamrożone z punktu widzenia użytkownika obserwującego wykonanie tego polecenia.
Jeśli odtworzysz tę sytuację za pomocą dwóch instancji isql.exe, następny obrazek pokazuje moment, w którym druga transakcja (dokładniej transakcja, w której równoległe polecenie UPDATE zaczyna się później - w naszym przykładzie jest to transakcja #14) czeka, aż pierwsza transakcja się zakończy (w naszym przykładzie jest to transakcja #11).

Po wykonaniu polecenia COMMIT w transakcji #11, transakcja #14 czekająca na nią zostanie natychmiast powiadomiona, a konfliktowa aktualizacja zakończy się wyjątkiem.
Poniżej możesz zobaczyć przykład takiego komunikatu o błędzie (numer równoległej transakcji nie pokrywa się z naszym przykładem, ponieważ numery transakcji zaczynają się od początku w każdej bazie danych, a następnie tylko rosną, resetując się dopiero po backupie/restore):
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>
Zwróć uwagę na słowo „deadlock” w komunikacie o błędzie - teraz w rzeczywistości nie ma żadnego deadlocka zgodnie z jego klasyczną definicją. Zamiast tego jest konflikt aktualizacji, ale programiści Firebird nie zmieniają komunikatu o błędzie, ponieważ jest używany od ponad 35 lat. Prawdziwym „klasycznym” deadlockiem zajmiemy się później.
Zbadaliśmy więc sytuację, w której transakcja z równoległym poleceniem UPDATE kończy się poleceniem COMMIT. Rozważmy teraz podobną sytuację, ale gdy jest ona wycofywana - możesz to zobaczyć na poniższym wykresie:

Sytuacja jest całkowicie podobna do poprzedniej - dwa polecenia UPDATE próbują zaktualizować ten sam rekord, ale tym razem równoległa transakcja #20 kończy się wycofaniem, a zmiany w ramach transakcji #15 są w rezultacie zapisywane w bazie danych bez błędu.
Tak więc opcja wait umożliwia zorganizowanie logiki biznesowej aktualizacji w taki sposób, że konfliktowe aktualizacje czekają w nieskończoność w kolejce, mając nadzieję do ostatniej chwili, że transakcja z nimi konfliktowa zakończy się instrukcją ROLLBACK.
Czy ta taktyka zawsze ma sens? Oczywiście zależy to od implementacji logiki biznesowej, ale Firebird oferuje również inne opcje rozwiązywania konfliktów aktualizacji za pomocą opcji wait.
Wait z limitem czasu
Przede wszystkim dobrym pomysłem może być ograniczenie czasu oczekiwania - zamiast czekać w nieskończoność w przypadku konfliktu, można ograniczyć czas oczekiwania, określając limit czasu dla opcji wait.
W isql.exe taki parametr jest określany za pomocą następującej instrukcji:
SET TRANSACTION WAIT LOCK TIMEOUT N;
Gdzie N to czas (w sekundach), przez który równoległa transakcja będzie czekać na rozwiązanie konfliktu.
Więcej szczegółów na temat instrukcji sterowania transakcjami można znaleźć w Firebird Language Reference. Należy pamiętać, że w konkretnych sterownikach lub komponentach dostępowych mogą istnieć różne sposoby określania limitu czasu (zwykle za pomocą parametru API).
Przykład w isql można zobaczyć na poniższym obrazku:

Przeanalizujmy za pomocą wykresów transakcji, jak transakcje współdziałają, jeśli określisz limit czasu dla opcji wait.
Sytuacja jest więc taka sama - dwie równoległe transakcje #11 i #14, w ramach których wykonywana jest instrukcja UPDATE próbująca zaktualizować ten sam rekord w tabeli T1.

Jednak w tym przypadku instrukcja w ramach transakcji #14 czeka albo do zakończenia transakcji #11, albo do upływu określonego limitu czasu (3 sekundy) - w zależności od tego, co nastąpi wcześniej.
W tym przykładzie limit czasu wygasa wcześniej, a instrukcja kończy się wyjątkiem:
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40
Zauważ, że “deadlock” ponownie pojawia się w komunikacie o błędzie, ale nadal nie jest to “prawdziwy” deadlock.
Odpowiednio, sytuacja jest podobna do tej z opcją wait, ale jest ograniczona limitem czasu - jeśli określony limit czasu wygaśnie wcześniej niż zakończy się równoległa transakcja.
Określenie opcji wait z limitem czasu może być dobrym rozwiązaniem do implementacji logiki biznesowej, jeśli wiesz na pewno, że wszystkie transakcje zapisujące są dość krótkie (na przykład nie dłuższe niż 1-2 sekundy).
Nowait
Bardzo łatwo jest wyjaśnić, czym jest Nowait z formalnego punktu widzenia - jest to wait z zerowym limitem czasu. Jeśli określisz nowait w transakcjach, konfliktowe aktualizacje natychmiast wygenerują wyjątek.

W tym przypadku ponownie mamy równoległe transakcje #11 i #14 (nowait), w których wykonywane są równoległe instrukcje UPDATE. Instrukcja w ramach transakcji z opcją nowait nie czeka, gdy widzi równoległą aktualizację, ale natychmiast zgłasza następujący wyjątek w momencie swojej aktualizacji (różni się tylko numer transakcji):
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>
Tak to wygląda w przykładzie z dwoma narzędziami isql:

Zauważ, że transakcja nowait nie przejmuje się tym, kiedy i jak zakończy się transakcja z równoległą instrukcją UPDATE - niezależnie od tego, czy będzie to instrukcja COMMIT czy ROLLBACK, wyjątek i tak zostanie zgłoszony.
Z punktu widzenia logiki biznesowej transakcja nowait może być wygodna, jeśli wiesz na pewno, że równoległa aktualizacja musi skutkować bezsprzecznym anulowaniem działań bieżącej instrukcji.
Wiele sterowników Firebird używa opcji nowait jako wartości domyślnej i dopóki wielu programistów nie wie, że można ustawić mniej restrykcyjny poziom rozwiązywania konfliktów aktualizacji (na przykład wait lock timeout 1), ich aplikacje (a czasami także użytkownicy) cierpią z powodu niepotrzebnych błędów wynikających z konfliktów.
Ponieważ słowo kluczowe “deadlock” jest obecne w każdym wyjątku związanym z konfliktami aktualizacji, wielu programistów aplikacji jest przekonanych, że to właśnie jest prawdziwy deadlock (niektórzy nawet myślą, że niejaki Dead miał rolę w tym błędzie).
Jednocześnie, jeśli spojrzymy na plik konfiguracyjny firebird.conf, zobaczymy tam parametr DeadlockTimeout (domyślnie 10 sekund), a jeśli spojrzymy na nagłówek wyjścia narzędzia fb_lock_print, zobaczymy również parametr “Deadlock scans”.
Rzecz w tym, że “prawdziwy deadlock” jest możliwy we Firebirdzie, a słowo kluczowe “deadlock” pojawiające się we wszystkich wyjątkach związanych z konfliktami aktualizacji nie ma z nim bezpośredniego związku. Na szczęście prawdziwy deadlock występuje dość rzadko.
Zobaczmy, czym jest ten “prawdziwy deadlock”. Aby to zrobić, spójrzmy na poniższy wykres interakcji transakcji:

Mamy dwie równoległe transakcje z opcją wait, w których wykonywana jest instrukcja UPDATE. W przeciwieństwie do prostego konfliktu aktualizacji, tutaj mamy do czynienia z współzależnym konfliktem aktualizacji:
- Transakcja #11 aktualizuje rekord z kluczem = 20, a transakcja #12 aktualizuje rekord z kluczem = 10;
- Następnie transakcja #11 aktualizuje rekord z kluczem = 10, a transakcja #12 aktualizuje rekord z kluczem = 20;
W rezultacie mamy sytuację, w której każda transakcja musi czekać na zakończenie drugiej i obie mogą czekać w nieskończoność, ponieważ obie mają określoną opcję wait. Oczywiście serwer nie może na to pozwolić, więc jedna z transakcji zostanie wymuszona do wycofania po upływie limitu czasu określonego w parametrze DeadlockTimeout ustawionym domyślnie na 10 sekund.
Możemy odtworzyć tę sytuację za pomocą dwóch narzędzi isql:

Po uruchomieniu drugiej transakcji występuje sytuacja prawdziwego deadlocka. Aby to wykryć, serwer uruchamia procedurę zwaną Deadlock scan - jest ona uruchamiana w odstępach równych DeadlockTimeout, który domyślnie wynosi 10 sekund.
Zauważ, że klient (w tym przypadku isql) otrzymuje zwykły komunikat o konflikcie aktualizacji, ale jest on inicjowany po 10 sekundach, nawet jeśli transakcja została uruchomiona z opcją wait.
Po wykryciu przez serwer współzależnej blokady dwóch transakcji, zwiększy on również wewnętrzny licznik deadlocków (można go zobaczyć w wyjściu fb_lock_print).
Praktyczne zastosowanie Snapshot Table Stability
Teraz, gdy wiemy, jak transakcje działają z konfliktowymi instrukcjami UPDATE, możemy wrócić do poziomu izolacji Snapshot Table Stability i znaleźć dla niego praktyczne zastosowanie.
Tak więc, gdy określony jest ten poziom izolacji, tabela jest blokowana do zapisu, a nawet do odczytu.
Zauważ, że jeśli tabela nie jest jawnie określona w parametrach transakcji, wszystkie tabele, do których instrukcje uzyskują dostęp w ramach tej transakcji, są blokowane i dzieje się to podczas pierwszego dostępu do tabeli. Najwyraźniej, jeśli ten poziom izolacji jest używany bez ostrożności, łatwo doprowadzi do dużej liczby konfliktów aktualizacji.
Klauzula Reserving TableNN pozwala określić konkretną tabelę (lub kilka tabel) do zablokowania na początku transakcji (możliwe jest również określenie trybu rezerwacji).
Ta świetna funkcja wraz z opcją wait pozwala zaimplementować bardzo efektywną sekwencyjną kolejkę do zmiany konkretnej tabeli.
W praktyce wygląda to tak - ci klienci, którzy muszą utworzyć kolejkę do określonej tabeli, uruchamiają transakcję SNAPSHOT TABLE STABILITY z określeniem tej tabeli, a następnie próbują wykonać w ramach tej transakcji operację i natychmiast ją zakończyć.
Na przykład chcemy utworzyć sekwencyjnie zwiększany licznik w tabeli z jedynym rekordem typu CREATE TABLE Table1(i1 integer not null), ale z jakiegoś powodu nie możemy użyć generatora.
Pseudokod wygląda mniej więcej tak:
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
Jeśli uruchomimy ten kod nie z poziomem izolacji Snapshot Table Stability (Table1), ale z niższym poziomem izolacji, możliwe będzie, że równoległa instrukcja UPDATE wtrąci się między rozpoczęciem transakcji a jej instrukcją UPDATE. W rezultacie otrzymamy albo wyjątek aktualizacji natychmiast (nowait), albo instrukcja zamrozi się do końca równoległej transakcji (wait) albo do limitu czasu (wait interval) - innymi słowy, konflikt zostanie jakoś rozwiązany na poziomie instrukcji.
Z poziomem izolacji snapshot table stability jesteśmy przed tym zabezpieczeni, ponieważ tabela jest zarezerwowana na początku transakcji - jest albo w całości nasza, albo w całości nie nasza. Jeśli określimy opcję wait do rozwiązywania konfliktów, równoległe połączenia automatycznie utworzą kolejkę bez obsługi jakichkolwiek błędów.

Oczywiście to podejście może być stosowane tylko do krótkich transakcji (tak jak w naszym przykładzie).
W praktyce poziom izolacji Snapshot table stability jest używany do tworzenia kolejek i przeliczania złożonej logiki w trybie wyłącznym (w stosunkowo małych tabelach lub gdy nie ma innych użytkowników).
Wewnątrz silnika Firebird używa poziomu izolacji Snapshot Table Stability do tworzenia indeksów - tj. gdy wykonujesz instrukcję ALTER INDEX indexname ACTIVE;, Firebird całkowicie zajmie tabelę, dla której budowany jest indeks.
Co dalej?
Ten artykuł stanowi jedynie wprowadzenie do koncepcji transakcji Firebird. Aby w pełni zrozumieć, jak działają transakcje we Firebirdzie, należy rozważyć architekturę wielogeneracyjną (koncepcje wersji rekordów i odśmiecania), rozważyć znaczniki transakcji (Oldest Interesting, Oldest Active, Oldest Snapshot, next) i inne rzeczy.
Artykuł oparty jest na materiałach seminarium/warsztatów “All About Transactions”, które zostało po raz pierwszy zaprezentowane w 2013 roku podczas seminariów Firebird Tour, oraz na podstawie szkolenia IBSurgeon " Firebird Transaction in details".
Kontakt
[email protected] Prosimy o kontakt z nami w przypadku jakichkolwiek pytań lub sugestii: [email protected]