Co zrobić, gdy baza danych puchnie? O skalowaniu w chmurze

Relacyjna baza danych potrafi być ciężka w utrzymaniu. Zwłaszcza, gdy przechowuje dużo danych, z których codziennie korzysta wielu użytkowników. Timeouty, deadlocki, długo wykonujące się zapytania, przerwy w działaniu – z takimi problemami mierzył się zespół, z którym pracowałem nad systemem obsługi… salonów fryzjerskich. O tym jak poradziliśmy sobie z puchnącą bazą danych dowiecie się poniżej.

Mateusz TurzyńskiFull-stack developer w Aspire Systems PolandJest miłośnikiem czystego kodu i rozwiązań chmurowych. Na co dzień zajmuje się projektowaniem skalowalnych aplikacji działających na platformie Microsoft Azure. Automatyzuje procesy wdrożenia i utrzymania systemów IT. Prywatnie miłośnik lotnictwa cywilnego i piłki nożnej.


Projekt świetnie się rozwijał, z każdym sprintem dowoziliśmy nowe funkcje systemu. Razem z nimi pojawiały się nowe tabele oraz kolejni użytkownicy, którzy obciążali serwer.

Pracowaliśmy nad optymalizacją bazy danych, przeglądając plany wykonania najbardziej krytycznych zapytań. Dodawaliśmy i optymalizowaliśmy indeksy, coraz więcej danych trafiało do cache’a oraz do nierelacyjnych baz danych.

Mieliśmy wrażenie, że bawimy się w kotka i myszkę. Na miejsce każdego zoptymalizowanego zapytania trafiało kolejne do poprawy, a nakłady pracy nie przynosiły wystarczającej poprawy. Postanowiliśmy podejść do problemu kompleksowo i stworzyć platformę, która pozwoli dowozić nowe funkcje systemu, bez większych obaw o wydajność bazy danych.

O skalowaniu słów kilka

Projekt od początku tworzony był w podejściu „cloud first”. Korzystając z chmury Microsoft Azure i modelu PaaS zapewniliśmy sobie dostęp do nowinek technicznych. Chcieliśmy też mieć możliwość nieograniczonego skalowania infrastruktury.

Jeśli chodzi o usługi takie jak serwery webowe, Redis Cache i inne nierelacyjne bazy danych – faktycznie mogliśmy bez problemu skalować się w poziomie (np. zwiększając liczbę serwerów www).

W przypadku bazy danych skalowanie nie wyglądało tak kolorowo. Z dostępnych usług bazodanowych wybraliśmy Azure SQL. To chmurowy odpowiednik Microsoft SQL Server. Chmurowa usługa daje nam tak przydatne narzędzia, jak automatyczne kopie danych, „point-in-time restore”, replikacja geograficzna, audyty bezpieczeństwa itp. Azure SQL udostępnia również proste skalowanie w pionie. Pozwala zwiększyć lub zmniejszyć wydajność serwera, który obsługuje bazę danych.

Skalowanie w pionie ma kilka znaczących wad. W pewnym momencie osiągamy punkt, w którym dalsze skalowanie nie będzie już możliwe, albo (jak było w naszym przypadku) będzie nieakceptowalnie drogie.

Model multi-tenant

Projekt krótko po starcie wyewoluował do modelu multi-tenant, gdzie grupy użytkowników są obsługiwane przed pojedynczą instancję systemu. W przypadku naszego projektu tenantem był salon fryzjerski. Dane poszczególnych tenantów nie przenikają się między sobą. Pracownik jednego salonu fryzjerskiego nigdy nie będzie miał dostępu do klienta innego salonu fryzjerskiego. Wszystkie tenanty dzielą jednak jedną, wspólną bazę danych.

Data sharding – skalowanie bazy w poziomie

Jednym ze sposobów na skalowanie bazy danych w poziomie jest data sharding. Data sharding polega na podzieleniu jednej, dużej bazy danych, na wiele mniejszych (ang. shard). Zbiór danych możemy podzielić na kilka sposobów.

  • Możemy podzielić je według domeny (dla przykładu salonu fryzjerskiego: w jednej bazie danych moglibyśmy trzymać wizyty klientów, w drugiej samych klientów, w trzeciej pracowników salonu, w czwartej dane fiskalne itp.).
  • Możemy podzielić je wg ich właściciela – czyli tenanta. Dane różnych tenantów trafiałyby na różne bazy danych „w komplecie” (analogicznie: dla jednego salonu fryzjerskiego, wizyty klientów, dane klientów itp., byłyby umieszczone w jednej bazie danych – shardzie).

Pierwszy sposób jest często implementowany w systemach składających się z mikroserwisów. Dla działającego systemu takie podejście wymagałoby przebudowanie znacznej części projektu.

Drugie podejście idealnie nadaje się dla modelu multi-tenant. Nasz system i tak zawsze odpytuje bazę danych w kontekście konkretnego tenanta (np. pobranie wizyt konkretnego salonu, szukanie klienta konkretnego salonu itp.).

Dzieląc dane wg tenantów mamy do wyboru:

  • Umieścić dane każdego z tenantów w osobnej bazie danych.
  • Pozwolić na umieszczenie danych kilku tenantów w jednej bazie danych.

Pierwszy sposób jest zdecydowanie wygodniejszy. Każdy salon posiadałby swoją osobną bazę danych, którą można oddzielnie zarządzać i skalować. Aplikacja nie wymagałaby wprowadzania większych zmian, żeby przystosować ją do tego modelu. Niestety, jeśli mamy sporo tenantów (w naszym przypadku było ich ponad 1500), zarządzanie tyloma bazami danych może być pracochłonne. I co gorsze, stosunkowo drogie (w przypadku korzystania z Azure SQL).

Drugi sposób jest trudniejszy do zaimplementowania, ale możemy określić, na ile baz danych chcemy podzielić nasz system. Potem, w zależności od rozwoju systemu, możemy zmienić ich liczbę. To właśnie w ten sposób postanowiliśmy wdrożyć data sharding w naszym systemie.

Zarys architektury – wyzwania i wymagania

Projektując architekturę naszego rozwiązania, wyodrębniliśmy kilka wymagań:

  • Zmiany powinny mieć jak najmniejszy wpływ na cały system – nie mogliśmy sobie pozwolić na przepisanie połowy projektu. Skupiliśmy się raczej na odpowiednim „wstrzyknięciu” warstwy odpowiedzialnej za sharding.
  • Powinniśmy mieć możliwość „żonglowania” danymi. Żeby móc osiągnąć lepszą wydajność, musieliśmy rozłożyć ruch na bazach danych równomiernie. Chcieliśmy móc łatwo przenosić dane poszczególnych tenantów pomiędzy bazami.
  • Proste zarządzanie bazami – wiedzieliśmy, że od teraz, zamiast zarządzać jedną bazą danych, będziemy zarządzać – w najlepszym przypadku – kilkunastoma. Chcieliśmy móc łatwo zakładać indeksy, zmieniać schemat bazy danych, monitorować ruch i ewentualne problemy.
  • Możliwość odpytywania wielu baz naraz – na potrzeby panelu administracyjnego, raportowania, musimy wykonywać zapytania na wielu bazach naraz, a następnie zbierać je w jeden zbiór danych.
  • Synchronizacja danych słownikowych, współdzielonych między salonami. Takimi danymi są wszelkiego rodzaju kategorie, metody płatności, waluty itp.

Zidentyfikowaliśmy też kilka wyzwań:

  • Kierowanie ruchu do konkretnej bazy danych – aplikacja musiała wiedzieć, którą bazę danych ma odpytać. Na szczęście, w każdym miejscu systemu, wiedzieliśmy do jakiego tenanta należy użytkownik, który wykonuje daną funkcję.
  • Unikalność kluczy typu „auto-increment” – klucze w naszej bazie były „autoinkrementowane”. Każda baza danych ma swój własny „licznik”. Prędzej czy później dojdzie do sytuacji, gdzie klucze główne nie byłyby unikalne w pełnym zbiorze danych. Nie pozwoliłoby to nam przenosić danych salonów między bazami (ponieważ mógłby wystąpić konflikt kluczy głównych).

Kierowanie ruchu do konkretnej bazy danych nie wyglądało na skomplikowany mechanizm. Potrzebowaliśmy oddzielnego źródła danych, które będzie mówiło w jakiej bazie danych trzymane są dane tenanta o określonym ID. Wystarczy zrobić fabrykę połączeń SQL, która na podstawie ID tenanta połączy system z właściwą bazą danych.

Żeby zapewnić unikalność wierszy w całym zestawie danych, na wszystkich bazach danych, moglibyśmy klucze typu autoincrement zamienić kluczami GUID. Niestety takie rozwiązanie nie jest wydajne. Pole typu uniqueidentifier zajmuje znacznie więcej miejsca niż pole typu int. Ma to wpływ na wydajność zapytań, JOINów, indeksów, itd.

Wpadliśmy na lepszy pomysł – każdą tabelę w bazie rozszerzyliśmy o dodatkowe pole „TenantId”. TenantId trafiło jako składowa każdego klucza głównego (każdy klucz główny stał się kluczem kompozytowym). Teraz każda baza mogła generować zduplikowane (w pełnym zbiorze danych) wartości dla pól Id. Unikalna wartość TenantId gwarantowała unikalność pary {TenantId, Id} w całym systemie.

Dodanie pola TenantId do każdego klucza głównego, oprócz zapewnienia unikalności identyfikatora zasobu, miało też inną, dużą zaletę. Klucze główne, które w większości przypadków pokrywały się z indeksem klastrowanym, stały się bardziej precyzyjne. Każde zapytanie do bazy danych wymagało podania wartości TenantId. Dzięki temu znalezienie wierszy w bazie danych wymagało mniejszej liczby operacji wejścia / wyjścia. W ten sposób znacząco zwiększyliśmy wydajność systemu.

A co daje nam chmura

Okazuje się, że chmura rozwiązuje niektóre z wyzwań za nas. Microsoft Azure dostarcza kilka usług i narzędzi, które pomagają zaimplementować data sharding. Narzędzia te wchodzą w skład pakietu Elastic Database Tools. Najważniejsze z elementów to:

  • Elastic Database Client to biblioteka (dostępna między innymi dla .NET i Javy), która pozwala w prosty sposób kierować ruch na właściwą bazę danych. Elastic Database Client składa się z bazy danych, która zawiera mapę TenantId, oraz z mechanizmu, który z poziomu kodu tworzy połączenie do odpowiedniej bazy danych.
  • Split-merge Tool to serwis (z dostępem przez Powershella i webowy UI), który pozwala na przenoszenie danych tenantów pomiędzy poszczególne bazy danych (shardy). Wymaga określenia tabel „shardowanych” oraz tabel referencyjnych (wszelkie słowniki, które są wspólne w każdej bazie danych, np. kraje, metody płatności, waluty). Warunkiem koniecznym jest, żeby każda „shardowana” tabela zawierała pole TenantId.
  • Elastic Query pozwala na odpytanie wielu baz danych na raz. Elastic Query tworzy dodatkową bazę danych, w której możemy zdefiniować tak zwane tabele zewnętrzne. Odpytując bazę Elastic Query, dostajemy wyniki zebrane z wszystkich baz danych, uczestniczących w shardingu.
  • Elastic Pool jest częścią Azure SQL. Pozwala na tworzenie i utrzymanie wielu baz danych, nie wydając na to fortuny. Elastic Pool pozwala wykonywać zadania (Elastic Database Jobs) na wszystkich bazach uczestniczących w shardingu (np. migracja danych, zmiana schematu bazy danych, zarządzanie indeksami). Elastic Pool posiada też duże możliwości skalowania. Bazy wewnątrz jednej grupy współdzielą pulę zasobów, które możemy dowolnie skalować.
  • Elastic Database Transactions pozwalają na wykonywanie rozproszonych transakcji, zmieniające dane na wielu bazach danych. Nie mieliśmy konieczności korzystania z tego rozwiązania w naszym projekcie.

Taki zestaw narzędzi pokrył większość wyzwań i wymagań, zdefiniowanych wcześniej. Oczywiście data sharding da się zaimplementować, robiąc wszystko po swojemu i od nowa, także w środowiskach innych, niż .NET i Microsoft Azure.

Pozbyć się Single Point of Failure

Jedna baza danych, bez której system nie może działać, to „single point of failure”. W przypadku awarii – żaden z naszych klientów nie ma dostępu do systemu. Wiele baz danych powoduje, że awaria jednej z nich wpływa na działanie tylko pewnej, ograniczonej grupy klientów. Poszczególne bazy danych można też umieścić na różnych serwerach, w celu zminimalizowania ryzyka awarii, która ma negatywny wpływ na wszystkich użytkowników.

Microsoft Azure udostępnia mechanizm zwany active geo-replication. Polega on na replikowaniu wszystkich transakcji do drugiego serwera bazodanowego, znajdującego się w innym regionie geograficznym chmury. Razem z mechanizmem failover groups, który pozwala na automatyczne wykrywanie awarii i przekierowanie ruchu do aktualnie działającego serwera, jest świetnym narzędziem, który zwiększa dostępność i niezawodność systemu.

Dodatkowo, w przypadku data shardingu, rozsądnym jest umieszczenie połowy baz danych w jednym regionie (np. West Europe) i replikowanie ich do drugiego regionu (North Europe). Drugą połowę baz danych umieszczamy odwrotnie. W takiej konfiguracji, jeśli nastąpi awaria jednego regionu, tylko połowa klientów odczuje tymczasowy spadek dostępności, spowodowany przepięciem ruchu na zapasowy region. Oczywiście możemy użyć więcej regionów, żeby jeszcze bardziej zwiększyć niezawodność systemu.

Lepsza dystrybucja geograficzna

Chcąc minimalizować opóźnienia pomiędzy użytkownikami, serwerami www i bazą danych, możemy umieszczać bazy danych klientów w różnych regionach chmury, odpowiednich dla samych klientów. Dla przykładu – salony fryzjerskie znajdujące się w USA powinny mieć bazy danych umieszczone w jednym z amerykańskich regionów chmury. Za pomocą data shardingu i Traffic Managera możemy dodatkowo zwiększyć wydajność systemu.

Stopniowe wprowadzanie zmian

Mechanizm shardingu pozwala też na tworzenie grupy klientów, którzy chętniej sięgają po nowości w używanym przez siebie oprogramowaniu. Dane takiej grupy klientów trzymajmy w osobnej bazie danych. Będzie nam łatwiej wprowadzić nowe funkcje systemu, które są dostępne tylko dla nich. Po okresie przejściowym możemy nowe funkcje udostępniać szerszej grupie klientów (najlepiej robić to stopniowo). Bez data shardingu bylibyśmy ograniczeni do stopniowego wprowadzania tylko tych funkcji, które nie wymagają zmian w schemacie bazy danych. Sposób ten możemy też wykorzystać, żeby sprawdzić, czy nowy indeks faktycznie podniesie wydajność bazy danych.

Kiedy warto myśleć o shardingu?

Czy warto myśleć o shardingu, kiedy zaczynamy projekt w modelu multi-tenant? Sharding niesie za sobą sporo wyzwań i pracy, a tego chcemy uniknąć na początku projektu. Dodatkowe narzędzia, takie jak Elastic Database Tools, dodatkowo komplikują projekt.

Nic nie stoi jednak na przeszkodzie, żeby projektując schemat bazy danych, umieścić pole TenantId w każdej tabeli, która przechowuje dane tenantów. Włączając pole TenantId do klucza głównego i do indeksów tabel, zwiększamy ich precyzję i wydajność. Dzięki temu będziemy gotowi wprowadzić sharding, kiedy tylko system urośnie do tego stopnia, że korzyści idące z wydajności i zwiększenia niezawodności systemu przewyższą zagrożenia związane ze skomplikowaniem projektu.

Po wprowadzeniu shardingu system jest gotowy na nieograniczone skalowanie. Nie musimy obawiać się momentu, w którym skalowanie w pionie będzie niemożliwe.

Mniejsze bazy danych oraz mniejsze indeksy spowodowały znaczny wzrost wydajności systemu, czyniąc go przy tym bardziej elastycznym.

Podsumowując: sharding świetnie sprawdzi się w modelu multi-tenant, zapewniając lepszą wydajność, większą izolację danych, lepszą dystrybucję danych i znacznie zwiększy odporność systemu na awarie.


Zdjęcie główne artykułu pochodzi z unsplash.com.

Patronujemy

 
 
Polecamy
Standard OAuth2 inspiracją dla rozwiązań bezpieczeństwa w Polish API