W tym artykule dowiesz się, jak korzystać z semantyki kryjącej się za danymi podczas partycjonowania bazy danych. Może to radykalnie poprawić wydajność Twojej aplikacji. A co najważniejsze, odkryjesz, że powinieneś dostosować kryteria partycjonowania do swojej unikalnej domeny aplikacji.
Współpracowałem ze start-upem, aby opracować aplikację internetową dla ekspertów sportowych do podejmowania decyzji i eksploracji danych. Aplikacja obsługuje każdy sport, ale nasza siedziba znajduje się w Europie - a Europejczycy kochają piłkę nożną. Każda z setek gier rozgrywanych codziennie na całym świecie ma tysiące rzędów. W ciągu zaledwie kilku miesięcy tabela Wydarzenia w naszej aplikacji osiągnęła pół miliarda wierszy!
Dzięki zrozumieniu, w jaki sposób eksperci piłki nożnej sprawdzali nasze dane, mogliśmy inteligentnie podzielić bazę danych. Średnia poprawa czasu na tym nowym stole była od 20x do 40x szybsza. Średnia poprawa czasu we wszystkich zapytaniach wynosiła od 5X do 10X.
Zagłębmy się teraz w ten scenariusz i dowiedzmy się, dlaczego nie można ignorować kontekstu danych podczas partycjonowania bazy danych.
Prezentacja kontekstu
Nasza aplikacja sportowa oferuje zarówno dane surowe, jak i zagregowane, chociaż profesjonaliści, którzy ją przyjęli, wolą te drugie. Bazowa baza danych zawiera terabajty złożonych, nieustrukturyzowanych, heterogenicznych danych pochodzących od kilku dostawców. Tak więc największym wyzwaniem było zaprojektowanie niezawodnej, szybkiej i łatwej do eksploracji bazy danych.
Domena aplikacji
W tej branży wielu dostawców oferuje swoim klientom dostęp do wydarzeń z najważniejszych rozgrywek piłkarskich. W szczególności dostarczają danych związanych z tym, co wydarzyło się podczas gry, takie jak gole, asysty, żółte kartki, podania i wiele innych. Tabela zawierająca te dane jest zdecydowanie największą, z jaką musieliśmy pracować.
Specyfikacje, technologie i architektura VPS
Mój zespół rozwijał aplikację backendową, która udostępnia najważniejsze funkcje eksploracji danych. Przyjęliśmy Kotlin v1.6 działający na JVM (Java Virtual Machine) jako język programowania, Spring Boot 2.5.3 jako framework i Hibernate 5.4.32.Final jako ORM (Object Relational Mapping). Głównym powodem, dla którego zdecydowaliśmy się na ten stos technologii, jest to, że szybkość jest jednym z najważniejszych wymagań biznesowych. Potrzebowaliśmy więc technologii, która mogłaby wykorzystać intensywne przetwarzanie wielowątkowe, a Spring Boot okazał się niezawodnym rozwiązaniem.
Wdrożyliśmy nasz backend na 16 GB VPS 8CPU za pośrednictwem kontenera Docker zarządzanego przez Dokku. Może używać maksymalnie 15 GB pamięci RAM. Dzieje się tak, ponieważ jeden GB pamięci RAM jest przeznaczony na system pamięci podręcznej oparty na Redis. Dodaliśmy go, aby poprawić wydajność i uniknąć przeciążania backendu powtarzającymi się operacjami.
Struktura bazy danych i tabeli
Jeśli chodzi o bazę danych, zdecydowaliśmy się na MySQL 8. Serwer VPS o pojemności 8 GB i 2 procesory obsługuje obecnie serwer bazy danych, który obsługuje do 200 jednoczesnych połączeń. Aplikacja zaplecza i baza danych znajdują się w tej samej farmie serwerów, aby uniknąć narzutów komunikacyjnych. Zaprojektowaliśmy strukturę bazy danych, aby uniknąć duplikacji i mając na uwadze wydajność. Zdecydowaliśmy się na relacyjną bazę danych, ponieważ chcieliśmy mieć spójną strukturę do konwersji danych otrzymanych od dostawców. W ten sposób standaryzujemy dane sportowe, ułatwiając ich eksplorację i prezentację użytkownikom końcowym.
Baza danych zawiera w momencie pisania setki tabel i nie mogę ich wszystkich przedstawić ze względu na podpisaną przeze mnie umowę NDA. Na szczęście jedna tabela wystarczy, aby dokładnie przeanalizować, dlaczego w końcu przyjęliśmy partycję opartą na kontekście danych, którą za chwilę zobaczysz. Prawdziwe wyzwanie pojawiło się, gdy zaczęliśmy wykonywać ciężkie zapytania w tabeli Zdarzenia. Ale zanim zagłębimy się w to, zobaczmy, jak wygląda tabela Zdarzenia:
Jak widać, nie zawiera wielu kolumn, ale pamiętaj, że niektóre z nich musiałem pominąć ze względu na poufność. Ale co naprawdę? ważne są tutaj parameterId
i gameId
kolumny. Używamy tych dwóch kluczy obcych, aby wybrać rodzaj parametru (np. gol, żółta kartka, podanie, kara) oraz gry, w których to się wydarzyło.
Problemy z wydajnością
Tabela Zdarzenia osiągnęła pół miliarda wierszy w ciągu zaledwie kilku miesięcy. Jak już szczegółowo omówiliśmy w tym poście na blogu, głównym problemem jest to, że musimy wykonywać operacje agregujące przy użyciu powolnych zapytań IN. Dzieje się tak, ponieważ to, co dzieje się podczas gry, nie jest tak ważne. Zamiast tego eksperci sportowi chcą analizować zagregowane dane, aby znaleźć trendy i na ich podstawie podejmować decyzje.
Ponadto, chociaż generalnie analizują cały sezon lub ostatnie 5 lub 10 meczów, użytkownicy często chcą wykluczyć niektóre konkretne mecze ze swojej analizy. Dzieje się tak, ponieważ nie chcą, aby gra rozgrywana szczególnie słabo lub dobrze spolaryzowała ich wyniki. Nie możemy wstępnie wygenerować danych zbiorczych, ponieważ musielibyśmy to zrobić we wszystkich możliwych kombinacjach, co nie jest wykonalne. Dlatego musimy przechowywać wszystkie dane i agregować je w locie.
Zrozumienie problemu z wydajnością
Przejdźmy teraz do głównego aspektu, który doprowadził do problemów z wydajnością, z którymi musieliśmy się zmierzyć.
Tabele z milionami wierszy są wolne
Jeśli kiedykolwiek miałeś do czynienia z tabelami zawierającymi setki milionów wierszy, wiesz, że są one z natury wolne. Nie możesz nawet pomyśleć o uruchamianiu JOIN na tak dużych stołach. Jednak możesz wykonać zapytania SELECT w rozsądnym czasie. Jest to szczególnie prawdziwe, gdy te zapytania zawierają proste warunki WHERE. Z drugiej strony stają się strasznie powolne podczas korzystania z funkcji agregujących lub klauzul IN. W takich przypadkach mogą one z łatwością zająć do 80 sekund, co jest po prostu za dużo.
Indeksy nie wystarczą
Aby poprawić wydajność, postanowiliśmy zdefiniować kilka indeksów. To było nasze pierwsze podejście do znalezienia rozwiązania problemów z wydajnością. Ale niestety doprowadziło to do kolejnego problemu. Indeksy zajmują czas i przestrzeń. Jest to generalnie nieistotne, ale nie w przypadku tak dużych stołów. Okazało się, że definiowanie złożonych indeksów na podstawie najczęstszych zapytań zajęło kilka godzin i GB miejsca. Ponadto indeksy są pomocne, ale nie są magiczne.
Kontekstowe partycjonowanie bazy danych jako rozwiązanie
Ponieważ nie mogliśmy rozwiązać problemu z wydajnością za pomocą niestandardowych indeksów, postanowiliśmy wypróbować nowe podejście. Rozmawialiśmy z innymi ekspertami, szukaliśmy rozwiązań online, czytaliśmy artykuły oparte na podobnych scenariuszach i ostatecznie zdecydowaliśmy, że partycjonowanie bazy danych jest właściwym podejściem.
Dlaczego tradycyjne partycjonowanie może nie być właściwym podejściem
Zanim podzieliliśmy wszystkie nasze największe tabele na partycje, przestudiowaliśmy ten temat zarówno w oficjalnej dokumentacji MySQL, jak iw ciekawych artykułach. Chociaż wszyscy zgodziliśmy się, że jest to droga do zrobienia, zdaliśmy sobie również sprawę, że stosowanie partycjonowania bez uwzględnienia naszej konkretnej domeny aplikacji byłoby błędem. W szczególności zrozumieliśmy, jak ważne było znalezienie odpowiednich kryteriów podczas partycjonowania bazy danych. Niektórzy eksperci od partycjonowania nauczyli nas, że tradycyjnym podejściem jest partycjonowanie według liczby rzędów. Ale chcieliśmy znaleźć coś bardziej inteligentnego i wydajniejszego.
Zagłębianie się w domenę aplikacji w celu znalezienia kryteriów partycjonowania
Istotną lekcję nauczyliśmy się analizując domenę aplikacji i przeprowadzając wywiady z naszymi użytkownikami. Eksperci sportowi mają tendencję do analizowania zagregowanych danych z gier w tych samych rozgrywkach. Na przykład zawody w piłce nożnej mogą być ligą, turniejem lub pojedynczym meczem, w którym można wygrać trofeum. Istnieją tysiące różnych konkursów. Najważniejsze w Europie to Liga Mistrzów, Premier League, LaLiga, Serie A, Bundesliga, Eredivisie, Liga 1 i Primeira Liga.
Oznacza to, że nasi użytkownicy bardzo rzadko biorą pod uwagę dane pochodzące z różnych konkursów. Ponadto wolą przeglądać dane sezon po sezonie. Innymi słowy, rzadko wychodzą z kontekstu, jakim są rozgrywane w danym sezonie zawody sportowe. Nasza struktura bazy danych wyraża tę koncepcję za pomocą tabeli o nazwie SeasonCompetition
, którego celem jest skojarzenie zawodów z konkretnym sezonem. Tak więc zdaliśmy sobie sprawę, że dobrym podejściem byłoby podzielenie naszych większych tabel na podtabele związane z konkretną SeasonCompetition
przykład.
W szczególności zdefiniowaliśmy następujący format nazw dla tych nowych tabel:<tableName>_<seasonCompetitionId>
.
W konsekwencji, gdybyśmy mieli 100 wierszy w SeasonCompetition
tabeli, musielibyśmy podzielić duże Events
tabeli do mniejszego Events_1
, Events_2
, …, Events_100
tabele. Na podstawie naszej analizy takie podejście doprowadziłoby do znacznego wzrostu wydajności w przeciętnym przypadku, chociaż w najrzadszych przypadkach wprowadziłoby pewne narzuty.
Dopasowywanie kryteriów do najczęstszych zapytań
Przed zakodowaniem i uruchomieniem skryptów wykonujących tę złożoną i potencjalnie bezzwrotną operację, zweryfikowaliśmy nasze badania, przyglądając się najczęstszym zapytaniom wykonywanym przez naszą aplikację backendową. Ale robiąc to, odkryliśmy, że zdecydowana większość zapytań dotyczyła tylko gier rozgrywanych w ramach konkursu SeasonCompetition. To przekonało nas, że mamy rację. Dlatego podzieliliśmy wszystkie duże tabele w bazie danych na partycje, stosując właśnie zdefiniowane podejście.
SELECT AVG('value') as 'value', SUM('minutes') as 'minutes'
FROM 'Events'
WHERE 'parameterId' = 15 AND 'gameId' IN(223,241,245,212,201,299,187,304,187,205)
GROUP BY 'teamId'
Przeanalizujmy teraz plusy i minusy tej decyzji.
Plusy
- Uruchamianie zapytań na tabeli zawierającej co najwyżej pół miliona wierszy jest znacznie bardziej wydajne niż wykonywanie tego na tabeli zawierającej pół miliarda wierszy, zwłaszcza jeśli chodzi o zapytania agregujące.
- Mniejsze tabele są łatwiejsze w zarządzaniu i aktualizowaniu. Dodanie kolumny lub indeksu nie jest nawet porównywalne pod względem czasu i przestrzeni. Dodatkowo każdy
SeasonCompetition
jest inny i wymaga innych analiz. W związku z tym może wymagać specjalnych kolumn i indeksów, a wspomniane wcześniej partycjonowanie pozwala nam łatwo sobie z tym poradzić. - Dostawca może zmienić niektóre dane. To zmusza nas do wykonywania zapytań usuwających i aktualizujących, które są nieskończenie szybsze na tak małych tabelach. Poza tym zawsze dotyczą tylko niektórych gier z konkretnego
SeasonCompetition
, więc teraz musimy operować tylko na jednym stole.
Wady
- Przed wykonaniem zapytania w tych podtabelach musimy znać
seasonCompetitionId
związane z interesującymi grami. Dzieje się tak, ponieważseasonCompetitionId
wartość jest używana w nazwie tabeli. Dlatego nasz backend musi pobrać te informacje przed uruchomieniem zapytania, patrząc na gry w analizie, co stanowi niewielki narzut. - Kiedy zapytanie obejmuje zestaw gier, w których bierze udział wiele
SeasonCompetitions
, aplikacja zaplecza musi uruchomić zapytanie w każdej podtabeli. Tak więc w takich przypadkach nie możemy już agregować danych na poziomie bazy danych i musimy to zrobić na poziomie aplikacji. Wprowadza to pewną złożoność logiki zaplecza. Jednocześnie możemy wykonywać te zapytania równolegle. Ponadto możemy efektywnie i równolegle agregować pobrane dane. - Zarządzanie bazą danych z tysiącami tabel nie jest łatwe i może być trudne do eksploracji w kliencie. Podobnie, dodanie nowej kolumny lub aktualizacja istniejącej kolumny w każdej tabeli jest kłopotliwe i wymaga niestandardowego skryptu.
Wpływ partycjonowania danych na podstawie kontekstu na wydajność
Przyjrzyjmy się teraz poprawie czasu osiągniętego podczas wykonywania zapytania w nowej partycjonowanej bazie danych.
- Poprawa czasu w przeciętnym przypadku (zapytanie obejmujące tylko jeden
SeasonCompetition
):od 20x do 40x - Skrócenie czasu w ogólnym przypadku (zapytanie obejmujące co najmniej jeden
SeasonCompetitions
):od 5x do 10x
Końcowe przemyślenia
Partycjonowanie bazy danych to niewątpliwie doskonały sposób na poprawę wydajności, zwłaszcza w przypadku dużych baz danych. Jednak zrobienie tego bez uwzględnienia konkretnej domeny aplikacji może być błędem lub prowadzić do nieefektywnego rozwiązania. Zamiast tego poświęcenie czasu na zbadanie domeny poprzez przeprowadzenie wywiadów z ekspertami i użytkownikami oraz przyjrzenie się najczęściej wykonywanym zapytaniom ma kluczowe znaczenie dla opracowania wysoce wydajnych kryteriów partycjonowania. W tym artykule pokazano, jak to zrobić, i zademonstrowano wyniki takiego podejścia w studium przypadku w świecie rzeczywistym.