Snapshot + Serializable isolation = Serializable Snapshot Isolation

Opublikowane przez admin w dniu

Cześć, dziś krótko rzucimy okiem na dosyć “nowy” poziom izolacji transakcji bazodanowej, na który natknąłem się czytając książkę Designing Data-Intensive Applications.  Mowa o Serializable Snapshot Isolation. Zacznijmy jednak od przypomnienia, jak działają poziomy izolacji Snapshot i Serializable oraz po co w ogóle istnieją.

Po co w ogóle nam izolacja transakcji?

Transakcja w bazie danych jest to zbiór operacji odczytujących i modyfikujących dane. W większości baz danych transakcje są atomowe. Oznacza to, że każde wykonanie transakcji może się odbyć na dwa sposoby. Wszystkie operacje w transakcji muszą zostać wykonane bez błędów lub żadna z tych operacji nie zostanie wykonana (zostaną wycofane przy błędzie). Poziom izolacji jest mechanizmem pozwalającym ograniczyć widoczność wykonywanych operacji dla innych użytkowników bazy danych. Dla przykładu izolacja typu read commited oznacza, że inni użytkownicy będą widzieć tylko te dane, które zostały z sukcesem “zakomitowane” przez inne transakcje.

 

Snapshot isolation

Poziom izolacji, w którym każda transakcja w bazie danych operuje na “własnej kopii danych”. Implementacja tego mechanizmu zazwyczaj odbywa się poprzez MVCC (Multi version concurrency control). W skrócie polega to na tym, że każda zmiana obiektu/wiersza jest trzymana jako jego oddzielna wersja. Następnie każda transakcja odczytuje daną wersję obiektu, używając swojego id. W przypadku kiedy dwie transakcje modyfikują tę samą wartość, wygrywa pierwsza, która “”zakomituje”, a druga jest wycofywana.

Ten poziom izolacji zakłada optymistycznie, że dane nie będą często modyfikowane jednocześnie. Izolacja pozwala na powstanie konfliktów i w przypadku ich wystąpienia wycofuje jedną z transakcji.

 

Write Skew

Jednym z poważnych problemów, który może występować przy izolacji typu snapshot to write skew. Załóżmy, że tworzymy aplikacje dla szpitali. Szpital wymaga, żeby na dyżurze zawsze był przynajmniej jeden lekarz. Rozważmy przypadek, gdzie na dyżurze jest dwójka lekarzy, Bartek i Ania. Ania poczuła się źle, a Bartek dostał nagły telefon. Oboje w tym samym czasie postanowili zejść z dyżuru w szpitalu, używając naszego systemu. Spójrzmy teraz na przykładowy pseudo kod, jak takie zejście z dyżuru mogłoby być zaimplementowane.

Kod jest prosty, pobieramy wszystkich doktorów, którzy aktualnie mają ustawioną flagę ON_CALL na true. Jeśli lekarzy na dyżurze jest przynajmniej dwóch, to aktualizujemy rekord lekarza, który chce zejść z dyżuru. Problem z tym rozwiązaniem jest taki, że jeśli aktualnie lekarzy na dyżurze jest dwóch i będą chcieli zejść z dyżuru w tym samym czasie, to nasz system im na to pozwoli, czym złamie wymaganie biznesowe.

Izolacja typu snapshot nie uchroni nas przed takim typem współbieżnej modyfikacji, ponieważ aktualizacja dotyczy dwóch różnych wierszy w bazie. Oczywiście można problem obejść, implementując go inaczej, zwykle jednak możemy po prostu zapomnieć, że taki problem może zaistnieć. Dobrze by było, aby baza danych chroniła nas przed takimi anomaliami.

Serializable isolation

Izolacją, która chroni nas przed powyższą anomalią to Serializable. Zapewnia, że transakcje mogą być wykonywane równolegle, jednak rezultat wygląda tak, jakby wykonane były one seryjnie, jedna po drugiej. Implementacja opiera się najczęściej na blokowaniu dostępu do zasobu (2 phase-locking). Izolacja ta zakłada pesymistycznie, że dane będą często modyfikowane jednocześnie. “Locki” mogą występować w dwóch trybach: współdzielonym (shared) i wyłącznym (exclusive). Lock w trybie shared może być współdzielony tylko przez operacje odczytu. Jeśli transakcja chce zmienić wartość wiersza, musi ona poczekać, aż wszystkie odczyty (transakcje) się zakończą (zdejmą locki w trybie shared), a następnie zablokować zasób w trybie wyłącznym (exclusive). Gdy na zasobie jest założony lock typu exclusive, transakcje nie mogą ani odczytywać danych, ani ich zapisywać. Muszą poczekać, aż transakcja odblokuje wiersz/obiekt. Niestety ma to duży wpływ na wydajność aplikacji. Może zdarzyć się tak, że jedna długa transakcja blokować może dostęp  do wielu zasobów w bazie. Często także może dojść to tkzw. deadlocków, gdzie pierwsza transakcja czeka zwolnienie locków drugiej, a druga czeka na zwolnienie pierwszej. Do takiego deadlocka dojdzie właśnie przy uruchomieniu powyższego przykładu z doktorami i dyżurem w izolacji Serializable, ale wynik będzie zgodny z wymaganiem biznesowym.

 

Serializable Snapshot Isolation

Izolacja ta została pierwszy raz opisana w pracy doktorskiej Michaela Jamesa Cahilla. Bazuje ona na izolacji typu snapshot, ale posiada ona wszystkie cechy izolacji typu serializable i jest od bardziej wydajna. W powyższym problemie typu write skew możemy zauważyć, że  transakcja w izolacji snapshot operuje na przestarzałym założeniu. Transakcja “myśli”, ze doktorów na dyżurze nadal jest dwóch. Kiedy jednak chcę zapisać dane, wynik wcześniej uruchomionego zapytania jest już przestarzały.

Baza danych z izolacją SSI śledzi zależności pomiędzy transakcjami i ich operacjami odczytu i zapisu, które mogą  być skonfliktowane. Baza musi wykrywać dwa przypadki:

  1. Odczyt danych w Tx1 po zapisie w Tx2
  2. Odczyt danych w Tx1 przed zapisem w Tx2

Przypadek pierwszy jest łatwy do wykrycia, ponieważ izolacja snapshot używa wcześniej wspomnianego mechanizmu MVCC. Jeśli Tx2 odczytuje dane, baza danych może zweryfikować czy inna wersja wiersza istnieje (inna transakcja zmodyfikowała ten sam wiersz). Jeśli baza danych wykryje taka skonfliktowaną relacje zapisu i odczytu może przerwać transakcje i ją ponowić.

Przypadek drugi musi być śledzony w inny sposób. Niektóre bazy danych implementujące poziom izolacji serializable, posiadają mechanizm tkzw. range-locków. Kiedy zapytanie pobiera wiele wierszy, zakłada na nich współdzielony lock (shared), po to, aby inna transakcja nie mogła nic zmienić w pobranych wierszach. Podobny mechanizm został zaproponowany w izolacji SSI, jednak wiersze nie są blokowane, a jedynie oznaczane, że zostały pobrane przez jakąś transakcje.

W przypadku wykrycia takich zależności baza nie pozwala ich zapisać i przerywa transakcje i ewentualnie ją ponawia. Oba powyższe mechanizmy zapewniają, że skonfliktowane zapisy będą wykonywały się seryjnie, jeden po drugim tak jak w izolacji serializable. Dużą zaletą dwóch powyższych podejść jest to, że oba nie blokują dostępu do wierszy/dokumentów.

Potencjalne wady

Oczywiście jak to w informatyce bywa, wszystko ma swoje wady i zalety. Po pierwsze, jeśli nie śledzimy całości informacji o pobranych i modyfikowanych wierszach, to możliwe jest, że transakcja może zostać omyłkowo anulowana (false positive). Z drugiej strony, jeśli śledzimy wszystko, to może stać się to zbyt dużym obciążeniem dla bazy. Trzeba tutaj znaleźć balans.

Dodatkowo, jeśli dużo transakcji modyfikuje te same wartości jednocześnie, to mechanizmy w SSI będą anulować i ponawiać skonfliktowane transakcje, a to wpłynie diametralnie na wydajność bazy. Jak zawsze warto zmierzyć, jak taki mechanizm zachowuje się pod naszym natężeniem, ale zwykle radzi sobie o wiele lepiej od izolacji typu serializable, ponieważ nie blokuje dostępu do zasobów.

 

Podsumowanie

Jak na bazy danych, SSI to stosunkowo nowy mechanizm i musi najprawdopodobniej jeszcze dojrzeć. Niemniej jednak jest to ciekawa alternatywa jeśli nasze wymagania biznesowe nie pozwalają na występowanie błędów typu write skew. Na ten moment został on zaimplementowany w Postrgesie oraz FoundationDB. W Postgresie jest określany jako izolacja serializable. Z kolei FoundationDB, jest rozproszoną bazą danych typu klucz wartość oferującą właściwości ACID. Ze względu na śledzenie konfliktów, nie pozwala ona uruchamiać transakcji dłuższych niż 5 sekund.

 

Źródła

Designing Data-Intensive Applications

https://wiki.postgresql.org/wiki/SSI

https://apple.github.io/foundationdb/developer-guide.html#working-with-transactions

Performance of Serializable Snapshot Isolation on Multicore Servers

 

Kategorie: Bazy danych