Transakcje w globalach InterSystems IRIS

Transakcje w globalach InterSystems IRISBaza danych InterSystems IRIS obsługuje interesujące struktury do przechowywania danych – globalne. W zasadzie są to wielopoziomowe klucze z różnymi dodatkowymi funkcjonalnościami, takimi jak transakcje, szybkie funkcje do przeszukiwania drzew danych, blokady oraz własny język ObjectScript.

Więcej o globalnych w cyklu artykułów „Globalne – miecze-klucze do przechowywania danych”:

Drzewa. Część 1
Drzewa. Część 2
Rzadkie macierze. Część 3

Zaciekawiło mnie, jak realizowane są transakcje w globalnych, jakie tam są szczególne cechy. Ponieważ jest to zupełnie inna struktura do przechowywania danych niż wszystkim znane tabele. O wiele bardziej niskopoziomowe.

Jak wiadomo z teorii relacyjnych baz danych, dobra realizacja transakcji powinna spełniać wymagania ACID:

A — Atomic (atomowość). Rejestrowane są wszystkie zmiany dokonane w transakcji lub nie ma żadnych.

S — Consistency (spójność). Po zakończeniu transakcji logiczny stan bazy danych powinien być wewnętrznie spójny. W dużej mierze to wymaganie dotyczy programisty, ale w przypadku baz danych SQL dotyczy także kluczy obcych.

I — Isolate (izolacja). Równolegle wykonywane transakcje nie powinny wpływać na siebie nawzajem.

D — Durable (trwałość). Po pomyślnym zakończeniu transakcji problemy na niższych poziomach (przykład: awaria zasilania) nie powinny wpływać na dane zmienione przez transakcję.

Globalne to struktury danych nierelacyjne. Zostały stworzone do ultra szybkiej pracy na bardzo ograniczonym sprzęcie. Przyjrzyjmy się wdrożeniu transakcji w globalnych przy użyciu oficjalnego obrazu dockerowego IRIS.

Aby wspierać transakcje w IRIS używa się poleceń: TSTART, TCOMMIT, TROLLBACK.

1. Atomowość

Najłatwiej jest sprawdzić atomowość. Sprawdzamy z konsoli bazy danych.

Kill ^a
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TCOMMIT

Następnie dokonujemy odczytu:

Write ^a(1), “ ”, ^a(2), “ ”, ^a(3)

Otrzymamy:

1 2 3

Wszystko w porządku. Atomowość przestrzegana: wszystkie zmiany zostały zapisane.

Utrudnimy zadanie, wprowadzimy błąd i zobaczymy, jak zachowa się transakcja, częściowo lub wcale.

Jeszcze raz sprawdzimy atomowość:

Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3

Po czym przymusowo zatrzymamy kontener, uruchomimy go i sprawdzimy.

docker kill my-iris

To polecenie jest praktycznie równoważne z nagłym odcięciem zasilania, ponieważ wysyła sygnał natychmiastowego zatrzymania procesu SIGKILL.

Czy transakcja mogła się zapisać częściowo?

WRITE ^a(1), ^a(2), ^a(3)
^
 ^a(1)

— Nie, nie została zapisana.

Wypróbujmy polecenie wycofania:

Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TROLLBACK

WRITE ^a(1), ^a(2), ^a(3)
^
 ^a(1)

Też nic nie zostało zapisane.

2. Spójność

Ponieważ w bazach na globalach klucze są również tworzone na globalach (przypomnę, że global to struktura o niższym poziomie do przechowywania danych niż tabela relacyjna), aby spełnić wymóg spójności, należy zmiany klucza uwzględnić w tej samej transakcji, co zmiana globalu.

Na przykład mamy global ^person, w którym przechowujemy persony i jako klucz używamy NIP.

^person(1234567, 'firstname') = 'Sergey'
^person(1234567, 'lastname') = 'Kamenev'
^person(1234567, 'phone') = '+74995555555
...

Aby mieć szybkie wyszukiwanie według nazwiska i imienia, stworzyliśmy klucz ^index.

^index('Kamenev', 'Sergey', 1234567) = 1

Aby baza była spójna, musimy dodawać personę w ten sposób:

TSTART
^person(1234567, 'firstname') = 'Sergey'
^person(1234567, 'lastname') = 'Kamenev'
^person(1234567, 'phone') = '+74995555555
^index('Kamenev', 'Sergey', 1234567) = 1
TCOMMIT

Odpowiednio, przy usuwaniu również musimy używać transakcji:

TSTART
Kill ^person(1234567)
ZKill ^index('Kamenev', 'Sergey', 1234567)
TCOMMIT

Innymi słowy, realizacja wymogu spójności leży całkowicie w gestii programisty. Ale kiedy mowa o globalach — jest to normalne, z uwagi na ich niskopoziomową naturę.

3. Izolacja

Tutaj zaczynają się zawirowania. Wiele użytkowników jednocześnie pracuje nad tą samą bazą, zmieniając te same dane.

Sytuacja jest porównywalna z tym, kiedy wielu użytkowników jednocześnie pracuje z tym samym repozytorium kodu i próbuje jednocześnie zatwierdzić zmiany w wielu plikach.

Baza danych musi to wszystko rozwiązywać w czasie rzeczywistym. Biorąc pod uwagę, że w poważnych firmach jest nawet specjalna osoba odpowiedzialna za kontrolę wersji (za łączenie gałęzi, rozwiązywanie konfliktów itp.), a DB musi to wszystko robić w czasie rzeczywistym, staje się oczywista złożoność zadania oraz poprawność projektowania bazy danych i kodu, który ją obsługuje.

Baza danych nie może zrozumieć intencji działań podejmowanych przez użytkowników, aby uniknąć konfliktów, gdy pracują nad tymi samymi danymi. Może jedynie cofnąć jedną transakcję, która jest sprzeczna z inną lub wykonać je sekwencyjnie.

Innym problemem jest to, że podczas realizacji transakcji (przed zatwierdzeniem), stan bazy danych może być niespójny, dlatego pożądane jest, aby inne transakcje nie miały dostępu do niespójnego stanu bazy danych, co osiąga się w relacyjnych bazach danych na wiele sposobów: poprzez tworzenie migawków, wersjonowanie wierszy itp.

Podczas równoległego wykonywania transakcji ważne jest, aby nie przeszkadzały sobie nawzajem. To jest właśnie właściwość izolacji.

SQL definiuje 4 poziomy izolacji:

  • ODCZYT NIEZAJĘTY
  • ODCZYT ZAŁATWIONY
  • POWTARZALNY ODCZYT
  • SERIALIZOWANY

Rozważmy każdy poziom oddzielnie. Koszty wdrożenia każdego poziomu rosną prawie wykładniczo.

ODCZYT NIEZAJĘTY — to najniższy poziom izolacji, ale jednocześnie najszybszy. Transakcje mogą odczytywać zmiany wprowadzone przez siebie nawzajem.

ODCZYT ZAŁATWIONY — to następny poziom izolacji, który jest kompromisem. Transakcje nie mogą odczytywać zmian wprowadzonych przez siebie nawzajem do momentu zatwierdzenia, ale mogą odczytywać wszelkie zmiany wprowadzone po zatwierdzeniu.

Jeśli mamy długą transakcję T1, w trakcie której miały miejsce zatwierdzenia w transakcjach T2, T3... Tn, które pracowały z tymi samymi danymi co T1, to przy zapytaniu o dane w T1 będziemy za każdym razem otrzymywać różny wynik. Zjawisko to nazywa się niepowtarzalnym odczytem.

POWTARZALNY ODCZYT — na tym poziomie izolacji nie występuje zjawisko niepowtarzalnego odczytu, ponieważ dla każdego zapytania o odczyt danych tworzona jest migawka danych wyniku, a przy ponownym wykorzystaniu w tej samej transakcji wykorzystywane są dane z migawki. Jednak w tym poziomie izolacji możliwe jest odczytywanie danych fantomowych. Mamy na myśli odczyt nowych wierszy, które zostały dodane przez równoległe zatwierdzone transakcje.

SERIALIZOWANY — najwyższy poziom izolacji. Charakteryzuje się tym, że dane w jakikolwiek sposób wykorzystywane w transakcji (odczyt lub zmiana) stają się dostępne dla innych transakcji dopiero po zakończeniu pierwszej transakcji.

Na początek sprawdźmy, czy operacje w transakcji są izolowane od głównego wątku. Otwórzmy 2 okna terminala.

Kill ^t

Write ^t(1)
2

TSTART
Set ^t(1)=2

Nie ma izolacji. Jeden wątek widzi, co robi drugi, który otworzył transakcję.

Zobaczmy, czy transakcje różnych wątków widzą to, co dzieje się wewnątrz nich.

Otwórzmy 2 okna terminala i uruchommy 2 transakcje równolegle.

kill ^t
TSTART
Write ^t(1)
3

TSTART
Set ^t(1)=3

Równolegle wykonywane transakcje widzą dane nawzajem. Zatem otrzymaliśmy najprostszy, ale i najszybszy poziom izolacji READ UNCOMMITTED.

W zasadzie można było się tego spodziewać dla globalów, dla których wydajność zawsze była najważniejsza.

Co zrobić, jeśli będziemy potrzebować wyższego poziomu izolacji w operacjach na globalach?

Trzeba zastanowić się, po co w ogóle potrzebne są poziomy izolacji i jak działają.

Najwyższy poziom izolacji SERIALIZE oznacza, że wyniki równolegle wykonywanych transakcji są równoważne ich sekwencyjnemu wykonaniu, co gwarantuje brak kolizji.

Możemy to osiągnąć za pomocą odpowiednich blokad w ObjectScript, które mają wiele różnych sposobów zastosowania: można stosować blokady zwykłe, inkrementacyjne lub wielokrotne polecenia. LOCK.

Niższe poziomy izolacji to kompromisy mające na celu zwiększenie szybkości działania bazy danych.

Zobaczmy, jak możemy osiągnąć różne poziomy izolacji za pomocą blokad.

Ten operator pozwala na uzyskiwanie nie tylko ekskluzywnych blokad, potrzebnych do modyfikacji danych, ale także tzw. shared, które mogą być zajmowane równolegle przez wiele wątków, gdy muszą odczytać dane, które nie powinny być zmienione przez inne procesy podczas odczytu.

Więcej informacji o dwufazowej metodzie blokad w języku polskim i angielskim:

Dwufazowa blokada
Two-phase locking

Trudność polega na tym, że w trakcie transakcji stan bazy może być niespójny, jednak te niespójne dane są widoczne dla innych procesów. Jak tego uniknąć?

Stworzymy za pomocą blokad takie okna widoczności, w których stan bazy będzie spójny. Wszelkie dostępy do takich okien widoczności spójnego stanu będą kontrolowane przez blokady.

Blokady współdzielone tych samych danych są wielokrotne — mogą być używane przez kilka procesów. Blokady te zabraniają innym procesom zmiany danych, tzn. są wykorzystywane do formowania okien spójnego stanu bazy danych.

Blokady ekskluzywne są używane do zmiany danych — taką blokadę może przejąć tylko jeden proces. Blokadę ekskluzywną może przejąć:

  1. Każdy proces, jeśli dane są wolne.
  2. Tylko ten proces, który ma na te dane blokadę współdzieloną i jako pierwszy zażądał blokady ekskluzywnej.

Transakcje w globalach InterSystems IRIS

Im węższe okno widoczności, tym dłużej muszą czekać inne procesy, ale tym spójniejszy może być stan bazy danych w nim.

READ_COMMITED — sednem tego poziomu jest to, że widzimy tylko zatwierdzone dane z innych wątków. Jeśli dane w innej transakcji nie zostały jeszcze zatwierdzone, widzimy ich starą wersję.

Pozwala nam to na równoległe działanie zamiast czekania na zwolnienie blokady.

Bez specjalnych sztuczek nie będziemy mogli zobaczyć starej wersji danych w IRIS, dlatego będziemy musieli ograniczyć się do blokad.

Odpowiednio musimy za pomocą blokad współdzielonych zezwolić na odczyt danych tylko w momentach spójności.

Załóżmy, że mamy bazę użytkowników ^person, którzy przelewają sobie nawzajem pieniądze.

Moment przelewu od osoby 123 do osoby 242:

LOCK +^person(123), +^person(242)
Set ^person(123, amount) = ^person(123, amount) - amount
Set ^person(242, amount) = ^person(242, amount) + amount
LOCK -^person(123), -^person(242)

Moment zapytania o kwotę pieniędzy u osoby 123 przed obciążeniem musi być poprzedzony blokadą ekskluzywną (domyślnie):

LOCK +^person(123)
Write ^person(123)

A jeśli trzeba pokazać stan konta w panelu użytkownika, to można użyć blokady współdzielonej lub w ogóle jej nie używać:

LOCK +^person(123)#”S”
Write ^person(123)

Jednak jeśli założymy, że operacje na bazie danych są wykonywane praktycznie natychmiast (przypominam, że globalne zmienne są znacznie bardziej niskopoziomową strukturą niż tabela relacyjna), to potrzeba tego poziomu spada.

POWTARZALNY ODCZYT — na tym poziomie izolacji dopuszczalne jest, że mogą wystąpić kilka odczytów danych, które mogą być zmieniane przez równoległe transakcje.

Odpowiednio musimy ustawić blokadę współdzieloną na odczyt danych, które zmieniamy oraz blokady ekskluzywne na dane, które zmieniamy.

Operator LOCK allows you to detail all necessary locks in one operator, which can be quite numerous.

LOCK +^person(123, amount)#”S”
reading ^person(123, amount)

other operations (during this time, parallel threads attempt to modify ^person(123, amount), but cannot)

LOCK +^person(123, amount)
modification ^person(123, amount)
LOCK -^person(123, amount)

reading ^person(123, amount)
LOCK -^person(123, amount)#”S”

When listing locks separated by commas, they are acquired sequentially, but if done this way:

LOCK +(^person(123),^person(242))

they are taken atomically all at once.

SERIALIZE — we need to set locks in such a way that ultimately all transactions that share data are executed sequentially. For this approach, most locks must be exclusive and obtained over the smallest areas of the global for performance.

If we talk about spending money in global ^person, only SERIALIZE isolation level is acceptable, as money must be spent strictly sequentially, otherwise it is possible to spend the same amount multiple times.

4. Durability

I conducted tests with hard container shutdowns using

docker kill my-iris

The database handled it well. No problems were identified.

Podsumowanie

For globals in InterSystems IRIS, there is support for transactions. They are indeed atomic and reliable. To ensure database consistency for globals, programmer effort and transaction usage are necessary, as there are no complex built-in constructs like foreign keys.

The isolation level for globals without the use of locks is READ UNCOMMITTED, and with locks, it can be ensured up to the SERIALIZE level.

The correctness and speed of transaction execution on globals heavily depend on the programmer's skill: the more widely shared locks are used when reading, the higher the isolation level, and the more narrowly exclusive locks are taken, the greater the performance.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster