Dostępność czy spójność? Czyli jak utrzymać wysoką dostępność oraz silną spójność przy odporności na awarie

14.05.2019 | Robert Czupioł

Wstęp

U podstaw każdej bazy danych stoi sformułowana przez Erica Brewera teoria CAP. Mówi ona, że nie da się stworzyć bazy, która jest równocześnie spójna (Consistency), dostępna (Availaibity) oraz odporna na awarie poszczególnych węzłów bądź przerwanie połączenia między nimi (Partition tolerance). Każda baza spełnia dwie przesłanki.

Jako, że najpopularniejszą w ING bazą nierelacyjną (noSQL) typu szeroko-kolumnowego (wide-columns) jest Cassandra (w skrócie C*) to dalsze rozważania będę prowadzone w ramach tej technologii. Dodam również, że C* występuje na rynku pod postacią trzech implementacji – Apache Cassandra, DataStax Enterprise oraz ScyllaDB. W przypadku tego artykułu zachowanie wszystkich trzech baz jest takie same. Jednakże różnice między nimi są na tyle wyraziste, a zarazem ciekawe, że mogą być tematem na kilka kolejnych publikacji.

Jak widać z powyższego schematu CAP, C* jest bazą typu AP. Czego jej brakuje? Oczywiście spójności. Baza z definicji jest dążącą do spójności (Eventually Consistent), ale ma też inną właściwość – zmienną/przestrajaną spójność (Tunable Consistency). Zatem jak bardzo jest dostępna, a na ile spójna zależy od nas, od tego jakie ustawienia wprowadzimy do aplikacji.

Można mieć przypadek, kiedy wymusimy wysoką spójność pisząc do klastra z CL (Consistency level) = ALL (poziomy opiszę później)tracąc przy tym dostępność, tudzież najniższą spójność pisząc z CL=ANY zyskując maksymalną dostępność.

Topologia klastra

Żeby przejść dalej muszę jeszcze opisać topologię C*, czyli czym jest węzeł (node), szafa (rack), centrum danych (data center) oraz klaster (cluster). Podstawową jednostką jest node – pojedynczy uruchomiony proces bazodanowy. Node jest zawsze w jakimś racku, który może być jeden dla wszystkich węzłów. Rack znajduje się w DC (Data Center). Dla przykładu wszystkie racki mogą znajdować się w jednym DC. Finalnie jedno bądź kilka DC tworzy nam klaster. W idealnym świecie taka logiczna topologia odzwierciedla również fizyczną (geograficzną), prócz odstępstw typu wirtualne DC przy separacji węzłów na realizujące OLTP (Online Transaction Processing) i OLAP (Online Analytics Processing). Podsumowując, topologię można  zaprezentować w następujący sposób. Co ważne, każdy węzeł ‘plotkuje’ z każdym, niezależnie od DC w którym się znajduje.

Struktura logiczna

Następnym ważnym elementem jest logiczny podział danych w klastrze. W ramach klastra C* zakłada się przestrzenie kluczy (keyspace). Miłośnicy baz relacyjnych mogą tę strukturę przyrównać do schematu, gdyby nie fakt, że keyspace jest czymś więcej. Prócz grupowania tabel posiada dwie kluczowe właściwości. Pierwsza to trwały zapis (durable writes), standardowo ustawiona na true, informuje o tym, że wszystkie zapisy (insert, update, delete) powinny być odłożone w commitlogu (sekwencyjny log odkładający na węźle mutacje w celu odbudowania struktury w pamięci – memtable’a). Druga to replication factor strategy, która informuje o sposobie replikowania danych. W C* znajdują się następujące strategie:

- SimpleStrategy – ile razy dana ma być zreplikowana w klastrze, jako parametr przyjmuje wartość liczbową np. 2.

- NetworkTopologyStrategy – ile razy dana ma być zreplikowana w klastrze w podziale na konkretne DC, jako parametr przyjmuje mapę np. DC1:3, DC2:2. Jeśli w jakimś DC ma danych nie być, to go po prostu pomijamy.

- LocalStrategy – dane znajdują się jedynie lokalnie na nodzie, nie są między nami replikowane.

- EverywhereStrategy - replikuje dane po wszystkich node’ach w klastrze – zalecana przy takich keyspace jak np. system_auth (ta strategia jest tylko dostępna w DSE).

W ramach keyspace tworzone są wspomniane wcześniej tabele, zwane przed C* 3.0 rodzinami kolumn (column family). Wraz z nowym silnikiem, nową strukturą Sorted Strings Table, w skrócie SSTable (format trzymania danych na dysku), a co za tym idzie odejściem od naleciałości z technologii Google BigTable, zmieniła się również nomenklatura. W tabelach dane są przechowywane w partycjach, których jest tak wiele ile mamy różnych wartości klucza partycji. W ScyllaDB oraz od DSE 6.0 partycje są grupowane w shardy, ale to kolejny długi wątek. W partycjach znajdują się wiersze. Każdy unikalny wiersz ma inną wartość klucza sortującego/klastrującego (clustering column). Na kolejnym poziomie są już wartości oraz meta-informacje (np. timestamp albo opcjonalny Time-to-live, w skrócie ttl). W dużym uproszczeniu wizualizuje się to tak:

Dla lepszego rozumienia tej struktury C* przedstawia się ją jako: 

HashMap<PartitionKey,SortedMap<ClusteringColum,Rows>>

Chcę również obalić często spotkany mit o tym, że Cassandra jest bazą typu klucz-wartość (key-value). Nie, nie jest. Jest bazą szeroko-kolumnową, którą w specyficznym przypadku można używać jako key-value, analogicznie jak każdą bazę relacyjną. Zatem użycie C* jako rozproszonego pamięciowego cache jest tylko jednym z wielu możliwych zastosowań.

Koordynator

Ostatnim pojęciem, które należy sobie przyswoić jest koordynator. C* posiada architekturę masterless (nie ma mastera), co za tym idzie, każdy węzeł potrafi przyjąć rolę koordynatora naszego zapytania i je zrealizować. Dla ludzi znających pojęcie koordynatora z Presto pragnę zaznaczyć, że w Cassandrze jest inaczej. To znaczy nikt nie jest koordynatorem „na stałe” i nie występuje tu pojedynczy punkt awarii (Single Point of Failure). Realizacja zapytań poprzez wyliczanie tokenów, ich znajdowanie na Consistency Hash Ring-u, plotkowanie o tym w protokole gossip... Jak się zapewne domyślacie, jest kolejnym długim tematem :)

Realizacja poziomu spójności podczas zapisu

Podczas zapisu danych, czyli operacji insert, update, delete (tak, w Cassandrze zawsze się zapisuje, nawet podczas usuwania) możemy (jak zostało to opisane we wcześniejszym akapicie) wybrać dowolny node jako koordynator naszego żądania. Na podstawie RF oraz hasha klucza partycji dane zostaną wysłane do wszystkich węzłów, które powinny te informacje posiadać. W tej sytuacji CL będzie nas informować, ile węzłów powinno potwierdzić zapis, zanim koordynator zwróci odpowiedź klientowi. Zatem jeśli jakiś node nam ‘wypadnie’, przez co nie będziemy w stanie uzyskać z tego węzła potwierdzenia, to czy koordynator nam potwierdzi zapis, tudzież poinformuje o braku możliwości zapisu, zależy od ustawienia CL. Obniżając CL przyśpieszamy również komunikację klient – baza, chociaż pod spodem zawsze zapisy idą wszędzie – proces wewnątrz klastra staje się asynchroniczny.

Realizacja poziomu spójności podczas odczytu

Podczas odczytu danych (select), koordynator do wykonania zapytania wybierze jedynie potrzebną mu liczbę node’ów do spełnienia przekazanego CL. Zatem np. przy RF =3 i pytając się klaster C* o dane z CL = Quorum koordynator odpyta się jedynie 2 node (abstrahując od przypadku read-repair bądź speculative execution). Dla przyśpieszenia operacji zostaną przesłane wyliczone skróty danych, w celu szybkiego porównania wyników. Jeśli zachodzi konflikt, to koordynator rozwiązuje go na podstawie czasu (nowsze dane zwyciężają), po czym wyrównuje dane w node’ach. Sposobów na wyrównywanie danych jest kilka (read-repair, repair, hinted handoff, nodeSync), o czym napiszę przy innej sposobności.

Poziomy spójności dostępne w Cassandrze:

Poziom Odczyt Zapis Przykład
ALL Każdy węzeł Każdy węzeł

S: 4 = 4,

NTS: DC1:3, DC2:4 = 7
Each_quorum Nie istnieje W każdym DC więcej jak połowa węzłów

S: 4 = 3

NTS: DC1:3, DC2:4 = 2 i 3
Quorum Więcej jak połowa węzłów Więcej jak połowa węzłów

S:4 = 3

NTS: DC1:3, DC2:4 = 4
Local_quorum W lokalnym DC więcej jak połowa węzłów W lokalnym DC więcej jak połowa węzłów

S:4 = 3

NTS: DC1:3, DC2:4 = (local-DC1) = 2
One/two/three Jeden/dwa/trzy Jeden/dwa/trzy  
Local_one W lokalnym DC jeden węzeł W lokalnym DC jeden węzeł

S:4 = 1

NTS: DC1:3, DC2:4 = (local-DC1) = 1
Serial/Local_serial Podczas LWT pozwala na czytanie niezatwierdzonych wartości Nie istnieje  
Any Nie istnieje Koordynator przyjął żądanie, żaden węzeł nie musi potwierdzić zapisu  

 

Poziom spójności ustawia się dla każdego żądania, zatem zawsze przy niespełnieniu wysokiego CL możemy zdecydować o jego obniżeniu. Taka polityka ponawiania jest często ustawiana w sterowniku (driver), co zostało z czasem określone jako niewskazane. Głównie z powodu tego, że wrzuca się wszystkie przypadki użycia do jednego worka. To, który DC jest lokalnym, zależy również od konfiguracji w driverze i użytej polityki Load Balancingu.

Silna spójność

Wiedząc już jak w Cassandrze sterować poziomami spójności, można zadać sobie pytanie – zatem jakie powinny być? Odpowiedz brzmi - ‘to zależy’ :) Zależy od tego, jaki mamy przypadek biznesowy, czego wymagamy, czy godzimy się na otrzymanie fałszywych odpowiedzi? Dla większości przypadków lepiej nie otrzymać w ogóle odpowiedzi, niż otrzymać ją nieprawdziwą. Sam fałsz może być dwojaki – dane nieprawdziwe, bądź po prostu ich brak. Żeby zmierzyć się z tym problemem, wprowadzono pojęcie silnej spójności. Oznacza ona brak ryzyka pobrania fałszywych danych. W C* silną spójność realizuje się poprzez takie dobranie poziomów spójności podczas odczytu oraz zapisu, aby sumarycznie otrzymać potwierdzenie z silnie większej ilości node’ów jak nasze RF. Spełnia to wzór RF < WCL+RCL.

Dla przykładu przy RF=3, należy zapisywać z CL=Quorum i czytać z CL=Quorum (3<2+2). Inna możliwość to zapis z CL=All i czytanie z CL=One (3<3+1). Jednakże w takim przypadku podczas zapisu muszą być dostępne wszystkie node’y, posiadające replikę danych, co obniża nam dostępność. Innym często stosowanym zestawem CL dla silnej spójności jest zapis z CL=Each_quorum i odczyt z CL=Local_quorum. W systemach, gdzie DC oddalone są od siebie o wiele tysięcy kilometrów, spotyka się często Local_quorum oraz Local_quorum, ponieważ jeśli użytkownik jest w Europie to prawdopodobieństwo, że jego dane będą w tym samym czasie potrzebne na Zachodnim Wybrzeżu Stanów Zjednoczonych jest relatywnie niskie (godzi się wówczas na ryzyko).

Idąc dalej tym tropem ciężko nie zgodzić się ze stwierdzeniem, iż ustawienie właściwego CL (w tym ew. możliwość jego obniżania) zależy od naszego przykładu użycia, np. systemy do logów często zapisując do C* z CL = Any, co przyśpiesza działanie aplikacji, zwiększa dostępność klastra, a koszt jaki ponosimy to ewentualny brak spójności.

Reasumując

Rozproszona nierelacyjna szeroko-kolumnowa baza danych Cassandra daje nam możliwość zachowania wysokiej dostępności przy utrzymaniu silnej spójności danych. Aby to uzyskać należy dobrać odpowiednie poziomy spójności, co powinno wynikać z naszego przypadku biznesowego oraz posiadanej infrastruktury. Dodatkowo, to jakie cechy przejawia klaster C*, czy jest „bardziej” dostępny, czy „bardziej” spójny, wynika nie tylko z niego samego, ale w głównej mierze z naszej aplikacji.