Dlaczego przelew bankowy wymaga więcej niż dwóch UPDATE
Przenieś 50 zł z jednego konta na drugie, a naiwna wersja to dwie instrukcje: odejmij od konta A, dodaj do konta B. Wykonaj je osobno, a jeśli proces padnie albo połączenie się zerwie między tymi dwoma, pieniądze znikają z A i nigdy nie docierają do B. Baza danych zrobiła dokładnie to, o co ją poproszono, dwa razy. Biznes i tak stracił 50 zł.
Transakcja łączy obie instrukcje w jedną jednostkę: baza danych wykonuje albo obie, albo żadną. Ta gwarancja, razem z trzema kolejnymi, to właśnie znaczenie skrótu “ACID”. Dzięki niej możesz powierzyć bazie danych pieniądze albo stany magazynowe: miejsca, gdzie “zrobione do połowy” jest gorsze niż “niezrobione”.
Co gwarantują cztery właściwości ACID
- Atomowość (Atomicity). Wszystkie instrukcje transakcji zatwierdzają się razem albo żadna się nie zatwierdza. Jeśli proces padnie po obciążeniu konta, ale przed zasileniem drugiego, baza danych wycofuje też to obciążenie. Nie ma żadnego stanu częściowego do ręcznego czyszczenia.
- Spójność (Consistency). Transakcja może przenieść bazę danych tylko z jednego poprawnego stanu do innego poprawnego stanu. Jeśli ograniczenie
CHECKmówi, że saldo nie może być ujemne, żadna transakcja nie może zatwierdzić wiersza, który to narusza, niezależnie od tego, co próbował zrobić kod aplikacji. - Izolacja (Isolation). Transakcje działające w tym samym czasie nie widzą nawzajem swojej niedokończonej pracy. To, jak rygorystycznie jest to wymuszane, to pokrętło, nie sztywna reguła: tym pokrętłem jest poziom izolacji, opisany dalej.
- Trwałość (Durability). Gdy baza danych powie, że transakcja się zatwierdziła, te dane przetrwają awarię sekundę później. Do tego służy write-ahead log: zmiana jest na dysku (albo zreplikowana), zanim commit wraca.
Atomowość i trwałość to zadanie bazy danych, i popularne silniki robią to dobrze. Izolacja to ta właściwość, o której decydujesz sam. Bardziej rygorystyczna izolacja kosztuje równoległość, a ten koszt widać na wykresach opóźnień na produkcji.
Jak wykonać transakcję za pomocą BEGIN, COMMIT i ROLLBACK
W PostgreSQL (i większości baz SQL, z niewielkimi różnicami w składni) transakcja to instrukcje między BEGIN i COMMIT:
| |
Jeśli coś pójdzie nie tak przed COMMIT, na przykład naruszenie ograniczenia albo sprawdzenie reguły biznesowej w kodzie aplikacji, ROLLBACK wycofuje wszystko, co stało się od BEGIN:
| |
Po rollbacku saldo konta 1 jest dokładnie takie, jakie było przed wykonaniem BEGIN. Przy długiej transakcji, w której chcesz wycofać tylko część pracy, savepoint daje ci częściowy rollback:
| |
Poza jawnym BEGIN większość sterowników wykonuje każdą instrukcję w swojej własnej, automatycznie zatwierdzanej transakcji. Każda instrukcja sama w sobie jest atomowa, ale nic nie łączy dwóch instrukcji razem. Pomiń BEGIN w przelewie bankowym i wracasz do straconych 50 zł.
Czemu zapobiegają poziomy izolacji transakcji
Dwie równoczesne transakcje mogą zakłócać się nawzajem w trzech konkretnych sposobach, a poziomy izolacji definiuje się na podstawie tego, które z nich dopuszczają:
| Anomalia | Co się dzieje |
|---|---|
| Dirty read | Transakcja A czyta wiersz, który B zapisało, ale jeszcze nie zatwierdziło. Jeśli B wycofa się, A przeczytało dane, które nigdy nie istniały. |
| Non-repeatable read | A czyta ten sam wiersz dwa razy w jednej transakcji i otrzymuje różne wartości, bo B zatwierdziło zmianę w międzyczasie. |
| Phantom read | A wykonuje to samo zapytanie WHERE dwa razy i otrzymuje inny zestaw wierszy, bo B wstawiło lub usunęło dopasowany wiersz w międzyczasie. |
Standard SQL definiuje cztery poziomy wokół tych trzech anomalii:
| Poziom izolacji | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | Możliwy | Możliwy | Możliwy |
| Read Committed | Zablokowany | Możliwy | Możliwy |
| Repeatable Read | Zablokowany | Zablokowany | Możliwy (dopuszczony przez standard) |
| Serializable | Zablokowany | Zablokowany | Zablokowany |
Rzadko będziesz chciał użyć READ UNCOMMITTED. PostgreSQL nawet go nie implementuje i po cichu wykonuje Read Committed; MySQL respektuje go i pokaże ci dirty read. W praktyce liczą się dwa poziomy: Read Committed i Repeatable Read, a dwie główne bazy open source mają różne domyślne ustawienia:
- PostgreSQL domyślnie używa Read Committed. Każda instrukcja w transakcji widzi świeży snapshot, zrobiony w momencie startu tej instrukcji, więc dwa
SELECTy w tej samej transakcji mogą zwrócić różne dane, jeśli inna transakcja zatwierdziła się w międzyczasie. - MySQL (InnoDB) domyślnie używa Repeatable Read. Snapshot jest robiony raz, przy pierwszym odczycie w transakcji, i każdy kolejny zwykły
SELECTw tej transakcji widzi ten sam snapshot, nawet jeśli inna transakcja od tamtej pory zatwierdziła update.
W PostgreSQL ustaw poziom zaraz po BEGIN, przed wykonaniem jakiegokolwiek zapytania:
| |
W MySQL forma SESSION zmienia poziom dla każdej kolejnej transakcji na tym połączeniu:
| |
Jak Repeatable Read blokuje phantom read w PostgreSQL
Otwórz dwie sesje psql na tej samej tabeli i wykonaj to. Zobaczyć to raz zapada w pamięć lepiej niż zapamiętanie tabeli.
Sesja A:
| |
Sesja B, podczas gdy transakcja A jest wciąż otwarta:
| |
Sesja A, ta sama otwarta transakcja, uruchamia identyczne zapytanie ponownie:
| |
Wiersz, który B wstawiło i zatwierdziło, nie pojawia się. Repeatable Read w PostgreSQL robi swój snapshot przy pierwszym zapytaniu transakcji A i trzyma go do COMMIT, więc nowe wiersze z innych transakcji nigdy się nie pojawiają. To jest bardziej rygorystyczne, niż wymaga standard: tabela powyżej dopuszcza phantomy na tym poziomie, ale implementacja PostgreSQL ich nie produkuje. Wykonaj tę samą sekwencję z A na Read Committed, a drugi SELECT zwróci nowy wiersz, bo Read Committed robi świeży snapshot przy każdej instrukcji.
Serializable idzie dalej. Wykrywa, kiedy dwie równoczesne transakcje wyprodukowały wynik, który nie mógłby się zdarzyć w żadnej sekwencyjnej kolejności, i przerywa jedną z nich błędem serializacji (PostgreSQL zwraca SQLSTATE 40001) zamiast pozwolić, by anomalia się zatwierdziła. To poziom, który zamyka klasyczny race condition, gdzie dwie transakcje czytają jakąś wartość, decydują na jej podstawie i obie zapisują. Kod używający SERIALIZABLE musi być gotowy na ponowną próbę:
| |
Co kosztują bardziej rygorystyczne poziomy izolacji
Cena wyższej izolacji to równoległość:
- Zapisy są blokowane do commitu, na każdym poziomie.
UPDATEbierze blokadę wiersza w momencie wykonania i trzyma ją doCOMMITalboROLLBACK, niezależnie od poziomu izolacji. To, co dodająRepeatable ReadiSerializable, to trzymanie tego samego snapshotu (albo, dlaSerializable, blokad predykatowych na wierszach, które przeglądały twoje zapytania) przez całą transakcję. Zrób wolne wywołanie zewnętrznego API międzyBEGINiCOMMIT, i każdy wiersz, który zapisałeś, zostaje zablokowany, a każdy wiersz, który odczytałeś podSerializable, zostaje śledzony — tak długo, jak trwa to wywołanie. - Ponowne próby nie są opcjonalne przy Serializable. Z założenia odrzuca transakcje, które wydają się poprawne, przy równoczesnym obciążeniu. Jeśli twoja aplikacja nie przechwytuje błędów serializacji i nie powtarza transakcji, te transakcje zawodzą sporadycznie, pod obciążeniem, czyli w najgorszym możliwym momencie.
Repeatable Readmoże rzucić ten sam błąd40001przy zwykłym równoczesnym update, więc kod ponownych prób nie jest potrzebny tylko dlaSerializable: każda transakcja powyżej Read Committed go potrzebuje. - Długie transakcje blokują zmiany schematu i rozdymają storage. MVCC w PostgreSQL trzyma starsze wersje wierszy tak długo, jak jakaś otwarta transakcja może ich jeszcze potrzebować. Transakcja pozostawiona otwarta godzinami (częsty błąd: otworzenie jej w żądaniu web i nigdy niewykonanie commit czy rollback) blokuje
VACUUMprzed odzyskaniem tego miejsca, aALTER TABLEna tabeli, której dotknęła, czeka, aż się zakończy. - Nie każdy zapis potrzebuje jawnej transakcji. Pojedyncza instrukcja
UPDATEjest już atomowa. Opakowanie jednej instrukcji wBEGIN/COMMITdodaje dodatkowy round trip bez żadnej dodatkowej gwarancji. Używaj jawnej transakcji, gdy poprawność zależy od tego, że dwie lub więcej instrukcji powiedzą się lub zawiodą razem: przelew bankowy, rejestracja, która zapisuje zarówno wiersz wusers, jak i waccounts, zamówienie, które zmniejsza stan magazynowy i wstawia rekord zamówienia.
ORM-y (Entity Framework, SQLAlchemy, ActiveRecord) zwykle otwierają transakcję na jednostkę pracy i zatwierdzają ją za ciebie. Nie walcz z tym. Błąd, na który trzeba uważać, to coś odwrotnego: kod, który wywołuje .save() albo .commit() osobno dla dwóch powiązanych zapisów, wyrzucając atomowość, którą dałby ORM.
Jaki poziom izolacji wybrać
| Jeśli twoje obciążenie to… | Użyj |
|---|---|
| Typowy CRUD w aplikacji webowej, odczyty i zapisy na żądanie | Read Committed. To domyślny poziom PostgreSQL nie bez powodu: najmniejsza kontencja, a dirty read są już wykluczone. |
| Raporty albo zadania batchowe, w których każde zapytanie w transakcji musi zgadzać się z innymi | Repeatable Read. Każde zapytanie czyta ten sam snapshot, więc suma i wiersze szczegółowe nie mogą się rozjechać w połowie raportu. |
| Przelewy finansowe, liczenie stanów magazynowych, każdy przypadek, gdzie wyścig między dwiema transakcjami jest błędem poprawności, nie utrudnieniem wydajnościowym | Serializable, z logiką powtarzania przy 40001. Trzymaj te transakcje krótkie, a koszt równoległości zostanie niski. |
| Pojedyncza instrukcja bez powiązanych zapisów | Jawna transakcja niepotrzebna. Instrukcja jest już atomowa. |
Zacznij od Read Committed i podnoś poziom tylko dla konkretnych transakcji, gdzie umiesz nazwać wyścig, który zamykasz. To ta sama dyscyplina co dodawanie indeksu tylko po znalezieniu wolnego zapytania. Jeśli nie umiesz zapisać tego wyścigu w jednym zdaniu, jeszcze nie potrzebujesz bardziej rygorystycznego poziomu.