READ COMMITTED
REPEATABLE READ
SERIALIZABLE
Na Moodle’u są przykłady w MySQL-u i Oraclu. My będziemy bawić się Postgresem, którego model concurrency control jest trochę inny.
Będziemy korzystać z mało ambitnego schematu bazy danych, który zademonstruje wszystko, co nas interesuje.
CREATE TABLE account (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) UNIQUE,
balance NUMERIC(18, 4)
);
https://www.postgresql.org/docs/7.1/mvcc.html
Unlike most other database systems which use locks for concurrency control, Postgres maintains data consistency by using a multiversion model. This means that while querying a database each transaction sees a snapshot of data (a database version) as it was some time ago, regardless of the current state of the underlying data. This protects the transaction from viewing inconsistent data that could be caused by (other) concurrent transaction updates on the same data rows, providing transaction isolation for each database session.
The main difference between multiversion and lock models is that in MVCC locks acquired for querying (reading) data don’t conflict with locks acquired for writing data and so reading never blocks writing and writing never blocks reading.
Baza Postgresa działa trochę jak immutable struktura danych znana z programowania funkcyjnego. Wiersze występują w wersjach i zmiany powodują stworzenie nowej wersji, a nie nadpisanie poprzedniej.
Rozważamy 5 rodzaji anomalii:
Tabela dopuszczalnych anomalii przy różnych trybach izolacji:
Isolation level | Dirty read | Lost update | Non-repeatable read | Phantom read | Serialization anomaly |
---|---|---|---|---|---|
READ UNCOMMITTED |
YES (not in PG) | YES | YES | YES | YES |
READ COMMITTED |
NO | YES | YES | YES | YES |
REPEATABLE READS |
NO | NO | NO | YES (not in PG) | YES |
SERIALIZABLE |
NO | NO | NO | NO | YES (not in PG) |
Jak widać PostgreSQL zapewnia więcej niż wymaga standard.
Po pierwsze, w Postgresie nie ma trybu READ UNCOMMITTED
. Znaczy jest, ale jest równoważny z READ COMMITTED
. W związku z tym nie da się w Posgresie wyprodukować dirty reada.
READ COMMITTED
Tryb READ COMMITTED
to domyślny w Postgresie. Każde zapytanie w transakcji dostaje swój snapshot bazy, który widzi tylko zmiany scommittowane do momentu jej rozpoczęcia. Transakcja zachowuje się tak, jakby innych, równoległych transakcji nie było.
Transakcja zakłada zamki na wiersze, które modyfikuje. Są to jednak wyłącznie writer locki, co znaczy, że inna transakcja może swobodnie czytać te wiersze , ale gdy spróbuje je zmodyfikować, to się blokuje i czeka na zakończenie drugiej transakcji.
Dane:
INSERT INTO account(name, balance) VALUES ('Murlak', 1500);
Rozważmy takie dwie transakcje:
A | B |
---|---|
|
|
Jeśli zaczynamy od to jedyna możliwa wartość balance
przy szeregowaniu transakcji dla naszego wiersza to , ale READ COMMITTED
nam tego nie zapewnia.
Lost update mamy dla następujący przeplotu:
A | B | Opis |
---|---|---|
– | – | Początkowa wartość wiersza to . |
– | Step 1 | Czyta . |
Step1 | – | Zapisuje lokalnie . |
– | Step 2 | Snapshot zapewnia, że znowu przeczyta . |
– | Step 3 | Blokuje się, wiersz jest modyfikowany przez A. |
COMMIT |
– | Utrwala globalnie. |
– | Step 3 | Wznawia się, zapisuje lokalnie. |
– | COMMIT |
Utrwala globalnie. |
złotych zniknęło.
Note: Tutaj widać też, dlaczego dirty read jest niemożliwy. Żadna z transakcji nie widzi niescommitowanych zmian drugiej, bo pracują na osobnych snapshotach.
Ponownie mamy jeden wiersz:
INSERT INTO account(name, balance) VALUES ('Murlak', 1500);
Rozważmy takie dwie transakcje:
A | B |
---|---|
|
|
Transakcja A wygląda jakby zapewniała, że każdy wiersz dostanie albo albo odsetek. A więc jedyne możliwości dla serializowalnych transakcji to , jeśli najpierw wykona się B, lub , jeśli najpierw wykona się A.
Ponownie, READ COMMITTED
nam tego nie zagwarantuje:
A | B | Opis |
---|---|---|
– | – | Początkowa wartość wiersza to . |
Step 1 | – | Czyta , a więc nie aktualizuje. |
– | Step 1 | Zapisuje lokalnie . |
– | COMMIT |
Utrwala . |
Step 2 | – | Czyta , a więc znowu nie aktualizuje. |
COMMIT |
– | Brak zmian. |
Dostaliśmy , czyli niemożliwy do uzyskania sekwencyjnie stan końcowy. Widać tutaj rzeczony “non-repeatable read” – odczytaliśmy wiersz dwa razy, raz z wartością w kolumnie balance
, a raz .
Phantom read jest podobny do non-repeatable, ale trochę bardziej zniuansowany. Tym razem nie chodzi o niespójny widok wiersza, tylko danego zapytania.
INSERT INTO account(name, balance) VALUES ('Murlak', 1500);
A | B |
---|---|
|
|
A | B | Opis |
---|---|---|
– | – | W tabeli mamy ('Murlak', 1500) . |
Step 1 | – | Żaden wiersz nie ma mniej niż , przechodzi. |
– | Step 1 | Lokalnie mamy ('Murlak', 1500), ('Stencel', 500) . |
– | COMMIT |
Utrwala globalnie. |
Step 2 | – | Odejmuje od 👻 obu 👻wierszy. |
COMMIT |
– | Utrwala ('Murlak', 500), ('Stencel', -500) |
Ujmując tę anomalię inaczej, wykonanie zapytania SELECT balance FROM account
w dwóch różnych momentach A daje dwa różne zbiory wyników, gdzie w drugim zbiorze znajdują się jakieś wcześniej niewidziane wartości (fantomy).
REPEATABLE READ
Ponownie operujemy na snapshotach, jednak teraz to cała transakcja dostaje jeden snapshot, a nie każde zapytanie w transakcji. Dzięki temu pozbywamy się wszystkich trzech powyższych anomalii za jednym zamachem.
Co jeśli jakaś inna transakcja wykona modyfikację wierszy, które my chcieliśmy zmodyfikować? Takiego konfliktu nie umiemy dobrze rozwiązać, więc transakcja, która czekała, się crashuje.
Wróćmy do pierwszego przykładu z lost update:
A | B |
---|---|
|
|
i rozważmy ten sam przeplot:
A | B | Opis |
---|---|---|
– | – | Początkowa wartość wiersza to . |
– | Step 1 | Czyta . |
Step1 | – | Zapisuje lokalnie . |
– | Step 2 | Snapshot zapewnia, że znowu przeczyta . |
– | Step 3 | Blokuje się, wiersz jest modyfikowany przez A. |
COMMIT |
– | Utrwala globalnie. |
– | Step 3 | Wznawia się, A utrwaliła zmianę, B rzuca wyjątek. |
– | forced ROLLBACK |
SQL Error [40001]: ERROR: could not serialize access due to concurrent update |
WAŻNE: z perspektywy programisty aplikacji wybór bardziej restrykcyjnego poziomu izolacji ma większe implikacje, niż tylko potencjalnie zmniejszona wydajność (w przypadku MVCC zresztą mało tracimy wydajności). Transakcje modyfikujące wiersze na wysokim poziomie izolacji mogą zawieść i rzucić wyjątkiem. Pisząc logikę musimy być na to gotowi i w jakiś sposób taką ewentualność obsłużyć, np. ponowić transakcję.
INSERT INTO account(name, balance) VALUES ('Murlak', 1500);
A | B |
---|---|
|
|
A | B | Opis |
---|---|---|
– | – | Początkowa wartość wiersza to . |
Step 1 | – | Czyta , a więc nie aktualizuje. |
– | Step 1 | Zapisuje lokalnie . |
– | COMMIT |
Utrwala . |
Step 2 | – | Widzi, że nastąpiła równoległa aktualizacja, rzuca wyjątek. |
forced ROLLBACK |
– | SQL Error [40001]: ERROR: could not serialize access due to concurrent update |
Znowu mamy na koncie, ale teraz cała transakcja zawiodła i nikt nie dostał odsetek. Zapewne będziemy chcieli transakcję A ponawiać do skutku.
INSERT INTO account(name, balance) VALUES ('Murlak', 1500);
A | B |
---|---|
|
|
A | B | Opis |
---|---|---|
– | – | W tabeli mamy ('Murlak', 1500) . |
Step 1 | – | Żaden wiersz nie ma mniej niż , przechodzi. |
– | Step 1 | Lokalnie mamy ('Murlak', 1500), ('Stencel', 500) . |
– | COMMIT |
Utrwala globalnie. |
Step 2 | – | Nadal działamy w tym samym snapshocie, więc widzimy i modyfikujemy tylko wiersz ('Murlak', 1500) |
COMMIT |
– | Utrwala swoją jedną zmianę, wynik to ('Murlak', 500), ('Stencel', 500) |
Nadal możemy jednak sforsować zachowanie, którego nie da się uzyskać szeregowo.
Będziemy potrzebowali trochę więcej danych, żeby to zademonstrować. Załóżmy, że tabela wygląda tak:
name | balance |
---|---|
Marcin Peczarski | 100.0 |
Marcin Engel | 200.0 |
Krzysztof Ciebiera | 3000.0 |
Krzysztof Stencel | 5000.0 |
Transakcje są następujące:
A | B |
---|---|
|
|
Jakie są możliwe wyniki dla uszeregowanych transakcji?
('Krzysztof Diks', 300)
. B wykonuje się teraz i widzi sumę po Krzysztofach . W wyniku mamy więc:name | balance |
---|---|
Krzysztof Diks | 300.0 |
Marcin Benke | 8300.0 |
('Marcin Benke', 8000)
. A wykonuje się teraz i widzi sumę po Marcinach . W wyniku mamy więc:name | balance |
---|---|
Krzysztof Diks | 8300.0 |
Marcin Benke | 8000.0 |
W przypadku REPEATABLE READS
nie tylko nie musi tak być, taki wynik jest niemożliwy jeśli transakcje zbiegną się w czasie. Niezależnie od kolejności, jeśli X zacznie się zanim Y scommituje, to dostanie osobny snapshot bez wierszy wstawionych przez Y. A więc jedyny możliwy wynik to:
name | balance |
---|---|
Krzysztof Diks | 300.0 |
Marcin Benke | 8000.0 |
SERIALIZABLE
Tryb SERIALIABLE
w Postgresie działa właściwie tak samo jak REPEATABLE READ
, ale dodaje dodatkowy poziom analizy zależności na przetwarzaną transakcję. Nie mamy żadnych nowych zamków, ale baza danych bacznie obserwuje zależności między zapisami i odczytami i jeśli wykryje, że nie da się ich wykonać szeregowo (czyli wykryje cykl), to nie pozwala nam scommitować danej transakcji.
A | B |
---|---|
|
|
Niezależnie od tego, w jakiej kolejności spróbujemy wykonać te transakcje (o ile zaczniemy jedną przed committem drugiej), to ta druga w kolejności zostanie przerwana z błędem:
SQL Error [40001]: ERROR: could not serialize access due to read/write dependencies among transactions
Detail: Reason code: Canceled on identification as a pivot, during commit attempt.
Hint: The transaction might succeed if retried.
Korzystając z tego co już wiemy dość łatwo zmusić bazę do deadlocku. Dane:
INSERT INTO account ("name", balance)
VALUES ('Murlak', 1500.0),
('Stencel', 500.0);
A | B |
---|---|
|
|
A | B | Opis |
---|---|---|
Step 1 | – | Aktualizuje konto Murlaka na . |
– | Step 1 | Aktualizuje konto Stencla na . |
Step 2 | – | Blokuje się, wiersz zaktualizowany przez B. |
– | Step 1 | Blokuje się, wiersz zaktualizowany przez A. |
Postgres wykrywa tę sytuację i po chwili wyrzuca jedną z transakcji z komunikatem:
SQL Error [40P01]: ERROR: deadlock detected
W skrypcie na Moodle’u są jeszcze dwa przykłady transakcji, które się nie serializują a przechodzą w Oraclu.
Na pustej tabeli:
A | B |
---|---|
|
|
Oraz dla danych (trzeba zrzucić UNIQUE
constraint):
name | balance |
---|---|
Alice | 300.0 |
Alice | 300.0 |
Alice | 300.0 |
Bob | 300.0 |
A | B |
---|---|
|
|
Obie te sytuacje są wykrywane przez PostgreSQL i odrzucane przy commicie drugiej w kolejności transakcji.
Dokumentacja poziomów izolacji PostgreSQL:
https://www.postgresql.org/docs/current/transaction-iso.html
Polecam, bardzo pouczająca lektura.
Pełen skrypt z wszystkimi transakcjami z tej strony:
isolation-a.sql
isolation-b.sql