Bardziej złożone aplikacje mogą potrzebować wykonywać wiele rodzajów zapytań używając wiele różnych atrybutów jako kryteria zapytania. W celu obsłużenia tych wymagań możemy utworzyć jeden lub więcej globalnych indeksów wtórnych i wydawać zapytania Query do tych indeksów w DynamoDB. Aby lepiej zrozumieć tematykę tych indeksów w obrębie tego wpisu poruszymy kilka tematów do których będzie należeć m.in. omówienie przykładowego scenariusza, odczyt danych z globalnego indeksu pomocniczego, zarządzenie indeksami czy kilka przykładów z wykorzystaniem .NET.
Scenariusz użycia globalnego indeksu wtórnego
Posłużymy się tutaj przykładem, który możecie znaleźć w oficjalnej dokumentacji (https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html) tj. tabela GameScore, która śledzi użytkowników i zbiera punktację w jednej z przykładowych gier mobilnych. Każdy element tabeli jest identyfikowany przez klucz partycji (UserId) i klucz sortowania (GameTitle). Poniższy widok pokazuje przykładową organizację elementów w tabeli (nie wszystkie atrybuty są widoczne:
Załóżmy teraz, że chcemy napisać aplikację, która wyświetli tabelę z najlepszymi wynikami dla każdej z powyższych gier. Zapytanie (Query) określające kluczowe atrybuty (UserId oraz GameTitle) byłoby bardzo wydajne. Jeżeli jednak potrzebowaliśmy pobrać dane z atrybutu GameScores tylko na podstawie atrybutu GameTitle musielibyśmy użyć operacji Scan. W miarę dodawania kolejnych elementów do tabeli, skanowanie wszystkich z nich, stałoby się wolne i nieefektywne. Utrudnia nam to udzielenie odpowiedzi na poniższe pytania:
Jaki jest najwyższy wynik jaki kiedykolwiek odnotowano w grze Metor Blaster?
Który użytkownik miał najwyższy wynik w Galaxy Invaders?
Jaki był najwyższy stosunek wygranych do przegranych?
W celu przyśpieszenia zapytania dotyczącego atrybutów nie będących kluczami można utworzyć globalny indeks wtórny (global secondary index). GSI zawiera wskazane atrybuty z tabeli podstawowej ale są one zorganizowane przez klucz główny, który jest inni niż klucz tabeli. Klucz indeksu nie musi posiadać żadnych kluczowych atrybutów z tabeli. Nie musi nawet mieć tego samego schematu klucza co tabela.
Jednym z przykładów może być utworzenie globalnego indeksu wtórnego o nazwie GameTitleIndex z kluczem partycji GameTitle i kluczem sortowania TopScore. Atrybuty klucza głównego tabeli podstawowej są zawsze rzutowane na indeks więc atrybut UserId jest również obecny. Poniższy przykład pokazuje jak wyglądałby indeks GameTitleIndex:
Tym razem możemy zapytać GameTitleIndex i łatwo uzyskać wynik dla Meteor Blasters. Wyniki są uporządkowane według wartości klucz sortowania, tj. TopScore. Jeżeli ustawimy parametr ScanIndexForward na false to wyniki są zwracane w porządku malejącym więc najwyższy wynik jest zwracany jako pierwszy.
Globalny indeks wtórny musi posiadać klucz partycji i może posiadać opcjonalny klucz sortowania. Schemat klucza indeksu może być różny od schematu tabeli podstawowej. Można mieć tabelę z prostym kluczem głównym (klucz partycji) i możesz utworzyć globalny indeks wtórny ze złożonym kluczem głównym (klucz partycji i klucz sortowania) – lub odwrotnie. Atrybuty klucza indeksu mogą składać się z dowolnych atrybutów String, Number lub Binary z tabeli podstawowej. Inne typy skalarne, tj. typy dokumentów i typy zestawów nie są dozwolone (odpowiednio: document types oraz set types).
Można również rzutować inne atrybuty tabeli bazowej do indeksu. Robiąc Query na indeksie DynamoDB pozwoli nam na efektywne rzutowanie tych atrybutów. Warto jednak pamiętać, że globalne zapytania o indeks wtórny nie mogą pobierać atrybutów z tabeli podstawowej. Jeżeli zapytamy GameTitleIndex tak jak pokazano na powyższym schemacie, zapytanie nie jest w stanie uzyskać dostępu do żadnych atrybutów innych niż TopScpre (chociaż kluczowe atrybuty GameTitle oraz UserId byłby automatycznie rzutowane).
W tabeli DynamoDB każda wartość klucza musi być unikalna. Jednakże, wartości kluczy w globalnym indeksie wtórnym nie muszą być unikalne. Aby zilustrować przykład, załóżmy, że gra o nazwie Comet Quest jest szczególnie trudna a wielu nowym użytkownikom nie udaje się uzyskać wyniku powyżej zera. Poniższe przedstawione dane, które mogłoby reprezentować ten przykład:
UserId
GameTitle
TopScore
123
Comet Quest
0
201
Comet Quest
0
301
Comet Quest
0
Kiedy powyższe dane zostaną dodane do tabeli GameScores, DynamoDB propaguje je do GameTitleIndex. Jeżeli następnie zapytamy indeks używając tytułu wspomnianej gry dla GameTitle i 0 dla TopScore zostaną nam zwrócone następujące dane:
W odpowiedzi pojawią się tylko elementy z określonymi wartościami klucza. W ramach tego zestawu danych, pozycje nie są w żadnej kolejności.
Globalny indeks wtórny (GSI) śledzi tylko pozycje danych w których faktycznie istnieją jego kluczowe atrybuty. W ramach kolejnego przykładu dodamy kolejny element to tabeli GameScores - podając jednak tylko wymagane atrybuty klucza głównego:
UserId
GameTitle
400
Comet Quest
Ponieważ nie podaliśmy atrybutu TopScore, DynamoDB nie spropaguje tego elementu do GameTitleIndex. W takim wypadku ponowne odpytanie GameScores dla wszystkich pozycji Comet Quest zwróci poniższe informacje:
W tym wypadku warto odnotować, że podobne zapytanie na GameTitleIndex nadal zwróci nam 3, a nie 4 elementy. Dzieje się tak dlatego, że element z nieistniejącym TopScore nie jest propagowany do indeksu:
Projekcja (potocznie zwana rzutowaniem) jest zestawem atrybutów, które są kopiowane z tabeli do indeksu wtórnego. Klucz partycji i klucz sortowania tabeli są zawsze rzutowane na indeks – można również rzutować inne atrybuty w celu lepszego wspierania zapytań w naszej aplikacji. Wraz z odpytaniem o indeks, DynamoDB uzyskuje dostęp do każdego atrybutu w projekcji tak jakby te atrybuty były w swojej własnej tabeli.
W momencie tworzenia indeksu wtórnego musimy określić atrybuty, które będą rzutowane na indeks. DynamoDB udostępnia trzy różne opcje w tym zakresie:
KEYS_ONLY - każdy element w indeksie składa się tylko z wartości klucza partycji tabeli i klucza sortowania, oraz wartości klucza indeksu. Opcja KEYS_ONLY powoduje powstanie najmniejszego możliwego indeksu wtórnego;
INCLUDE - oprócz atrybutów opisanych w KEYS_ONLY indeks będzie zawierał inne atrybuty nie będące kluczami, które określisz;
ALL - indeks wtórny zawiera wszystkie atrybuty z tabeli źródłowej. Z uwagi na fakt, że wszystkie dane tabeli są powielane w indeksie, rzutowanie ALL powoduje największy możliwy indeks wtórny.
Na poprzednim ‘rzucie tabeli’ GameTitleIndex ma być projektowany (projekcja) tylko jeden atrybut, tj. UserId. W takim wypadku nasza aplikacja jest w stanie sprawnie/efektywnie określić UserId najlepszych graczy pod względem punktacji używając GameTitle oraz TopScore w zapytaniu, nie jest w stanie efektywnie określić najwyższego stosunku zwycięstw do porażek dla najlepszych graczy. W celu osiągniecia takich danych należałoby wykonać dodatkowe zapytanie do tabeli podstawowej, aby pobrać zwycięstwa i porażki dla każdego z najlepszych graczy. Bardziej efektywnym sposobem obsługi zapytań dla tych danych byłoby rzutowanie tych atrybutów z tabeli podstawowej do globalnego indeksu wtórnego, jak możecie zobaczyć poniżej:
Z uwagi na fakt, że kluczowe atrybuty, tj. Wins oraz Losses są rzutowane do indeksu, aplikacja może określić stosunek zwycięstw do porażek dla dowolnej gry lub dowolnej kombinacji gry i identyfikatora użytkownika.
Wybierając atrybuty do projekcji w globalnym indeksie wtórnym należy rozważyć kompromis pomiędzy kosztami przepustowości i kosztami przechowywania:
jeżeli potrzebujemy dostępu tylko do kilku atrybutów z najniższym możliwym opóźnieniem warto rozważyć rzutowanie tych atrybutów do globalnego indeksu wtórnego. Im mniejszy indeks tym mniej kosztowane jest jego przechowywanie oraz mniejsze są koszty zapisu;
jeżeli potrzebujemy częstego dostępu do atrybutów nie będących kluczami powinniśmy rozważyć projekcje tych atrybutów do globalnego indeksu wtórnego. Dodatkowe koszty przechowywania globalnego indeksu wtórnego rekompensują koszty częstego skanowania tabeli;
jeżeli potrzebujemy częstszego dostępu do większości atrybutów nie będących kluczami możemy rzutować te atrybuty – lub nawet całą tabelę podstawową – na globalny indeks wtórny. Takie podejście daje maksymalną elastyczność. Warto jednak mieć na uwadze, że koszt przechowywania danych może wzrosnąć a nawet podwoić się;
jeżeli nasza aplikacja wymaga rzadkich zapytań do tabeli ale musi wykonywać wiele zapisów lub aktualizacji danych w tabeli warto rozważyć opcję KEYS_ONLY - globalny indeks wtórny będzie miał minimalny rozmiar ale nadal będzie dostępny, gdy będzie potrzebny do wykonywania zapytań.
Odczyt danych z globalnego indeksu wtórnego
Możemy pobierać elementy z globalnego indeksu wtórnego za pomocą operacji Scan oraz Query. Z kolei operacje GetItem oraz BatchGetItem nie mogą być użyte na globalnym indeksie wtórnym.
Zapytania na globalnym indeksie wtórnym
Możemy używać Query aby uzyskać dostęp do jednego lub więcej elementów w globalnym indeksie wtórnym. Zapytanie musi określać nazwę tabeli bazowej oraz nazwę indeksu, który chcemy użyć, listę atrybutów, które chcemy zwrócić w wynikach zapytania oraz wszelkie warunki zapytania, które chcemy dodać do Query. DynamoDB może zwrócić wyniki w kolejności rosnącej lub malejącej.
Spójrzmy na poniższy przykład pokazujący dane zwrócone z zapytania, w którym zależy nam na informacjach związanych z danymi o różnych grach:
DynamoDB uzyskuje dostęp do GameTitleIndex używając klucza partycji GameTitle do zlokalizowania indeksu dla gry Meteor Blaster. Wszystkie elementy indeksu z kluczem są przechowywane obok siebie w celu szybkiego wyszukiwania;
w ramach tej gry, DynamoDB używa indeksu aby uzyskać dostęp do wszystkich identyfikatorów użytkowników i najlepszych wyników dla tej gry.
Wyniki są zwracane w porządku malejącym ponieważ parametr ScanIndexForward jest ustawiony na false.
Skanowanie globalnego indeksu wtórnego
Możemy również użyć operacji skanowania w celu pobrania wszystkich danych z globalnego indeksu wtórnego. Musimy podać nazwę tabeli bazowej i nazwę indeksu w tym żądaniu. Podczas skanowania DynamoDB odczytuje wszystkie dane z indeksu i zwraca je do aplikacji. Możemy również zażądać aby tylko niektóre dane zostały zwrócone a inne odrzucone - w tym celu możemy posłużyć się parametrem FilterExpression w operacji Scan.
Synchronizacja danych pomiędzy tabelami i globalnymi indeksami wtórnymi
DynamoDB automatycznie synchronizuje każdy globalny indeks wtórny z jego tabelą bazową. Kiedy aplikacja zapisuje lub usuwa elementy w tabeli wszystkie globalne indeksy wtórne są aktualizowane asynchronicznie używając ostatecznie spójnego modelu. Aplikacje nigdy nie zapisują danych bezpośrednio do indeksu. Ważne jest jednak, abyśmy dobrze rozumieli, jak DynamoDB utrzymuje te indeksy.
Globalne indeksy wtórne dziedziczą tryb pojemności odczytu/zapisu z tabeli podstawowej.
Podczas tworzenia globalnego indeksu wtórnego określamy jeden lub więcej atrybut klucza indeksu oraz ich typy danych. Oznacza to, że gdy zapisujemy element do tabeli podstawowej, typy danych dla tych atrybutów muszą odpowiadać typom danych schematu klucza indeksu. W przypadku GameTitleIndex, klucz partycji GameTitle w indeksie jest określony jako typ danych String. Klucz sortowania TopScore w indeksie jest typu Number. Jeśli próbujemy dodać element do tabeli GameScores i określić innych typ danych dla GameTitle lub TopScore, DynamoDB zwraca wyjątek ValidationException z powodu niedopasowania typu danych.
Kiedy dodajemy lub usuwamy element w tabeli, globalne indeksy wtórne tej tabeli są aktualizowane w sposób spójny. Zmiany danych w tabeli są propagowane do globalnych indeksów wtórnych w ciągu ułamka sekundy w normalnych warunkach. Jednakże, w niektórych, mało prawdopodobnych scenariuszach awarii, mogą wystąpić dłuższe opóźnienia propagacji. Z tego powodu nasze aplikacje muszą przewidywać i obsługiwać sytuacje w których zapytanie na globalnym indeksie wtórnym zwraca wyniki, które nie są jeszcze aktualne.
Jeżeli zapisujemy elementy w tabeli nie musimy określać atrybutów dla żadnego klucza sortowania globalnego indeksu wtórnego. Używając GameTitleIndex jako przykładu, nie musielibyśmy określać wartości atrybutu TopScore, aby zapisać nowy element to tabeli GameScores. W tym przypadku DynamoDB nie zapisuje żadnych danych do indeksu dla tego konkretnego elementu.
Globalny indeks wtórny zawsze będzie używał tej samej klasy tabeli co jego tabela bazowa. Za każdym razem, gdy dodawany jest nowy globalny indeks wtórny dla tabeli, nowy indeks będzie używał tej samej klasy tabeli co jego tabela bazowa. Gdy klasa tabeli jest aktualizowana, wszystkie powiązane z nią globalne indeksy wtórne również są aktualizowane.
Przepustowość globalnych indeksów wtórnych
W momencie tworzenia globalnego indeksu wtórnego na tabeli w trybie provisioned musimy określić jednostki przepustowości odczytu i zapisu dla oczekiwanego obciążenia tego indeksu. Ustawienia przepustowości globalnego indeksu wtórnego są oddzielone od ustawień tabeli podstawowej. Operacja zapytania na globalnym indeksie wtórnym zużywa jednostki pojemności odczytu z indeksu a nie z tabeli podstawowej. Gdy umieszczamy, aktualizujemy lub usuwamy element w tabeli globalne indeksy wtórne tej tabeli są również aktualizowane. Te aktualizacje indeksów zużywają jednostki pojemności zapisu z indeksu a nie z tabeli bazowej.
Jeżeli zapytamy globalny indeks wtórny i przekroczymy jego przepustowość odczytu, nasze żądanie zostanie zdławione (jest to wspomniany we wcześniejszych wpisach throttling). W momencie wykonania zbyt dużej aktywności zapisu na tabeli, w przypadku, gdy globalny indeks wtórny na tej tabeli ma niewystarczającą pojemność zapisu, aktywność zapisu na tej tabeli zostanie zdławiona.
Musimy pamiętać, że jesteśmy w stanie uniknąć (potencjalnego) zdławienia ustalając przewidzianą pojemność zapisu dla globalnego indeksu wtórnego na wartość równą bądź większą niż pojemność zapisu tabeli podstawowej – związane jest to z faktem, że nowe aktualizacje zapisują zarówno do tabeli podstawowej jak i globalnego indeksu wtórnego.
Wartości ustawień przepustowości globalnego indeksu wtórnego możemy wyświetlić używając operacji DescribeTable znanej z poprzednich wpisów – operacja ta zwraca szczegółowe informacje o wszystkich globalnych indeksach wtórnych tabeli.
Jednostki pojemności odczytu
Globalne indeksy wtórne (GSI) obsługują ostatecznie spójne odczyty z których każdy zużywa jedną połowę jednostki pojemności odczytu. Oznacza to, że pojedyncze zapytanie do globalnego indeksu wtórnego może pobrać do 2x4KB = 8KB na jednostkę pojemności odczytu.
W przypadku globalnych zapytań do indeksów wtórnych DynamoDB oblicza aktywność odczytu w taki sam sposób jak w przypadku zapytań do tabel. Jedyną różnicą jest to, że obliczenia są oparte na rozmiarach wpisów indeksu a nie rozmiarze elementu w tabeli podstawowej. Liczba jednostek pojemności odczytu jest sumą wszystkich przewidywanych rozmiarów atrybutów we wszystkich zwróconych elementach. Wynik jest następnie zaokrąglany w górę do następnej granicy 4KB. Więcej informacji na temat sposobu w jaki DynamoDB oblicza wykorzystanie przepustowości typu provisioned możecie znaleźć w oficjalnej dokumentacji AWS dostępnej tutaj: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ProvisionedThroughput.html
Maksymalny rozmiar wyników zwracanych przez operację Query wynosi 1MB. Obejmuje to rozmiary wszystkich nazw atrybutów i wartości we wszystkich zwróconych elementach.
Zanim przejdziemy dalej przeanalizujemy jeden przykład: spójrzmy na globalny indeks wtórny w którym każdy element zawiera 2000 bajtów danych. Załóżmy również, że przygotujemy zapytanie do tego indeksu oraz, że wyrażenie KeyConditionExpression zwróci osiem elementów. Całkowity rozmiar pasujących elementów to 2000 bajtów x 8 elementów = 16 000 bajtów. Wynik ten jest następnie zaokrąglany w górę do najbliższej granicy 4KB. Ponieważ globalne zapytania o indeks wtórny są ostatecznie spójne (eventually consistent), całkowity koszt wynosi 0.5 x (16KB/4KB), lub 2 jednostki pojemności odczytu.
Jednostki pojemności zapisu
W momencie dodawania, aktualizowania lub usuwania elementu z tabeli (a globalny indeks wtórny jest dotknięty tą operacją), globalny indeks wtórny zużywa jednostki pojemności zapisu dla tej operacji. Całkowity koszt przepustowości zapisu składa się z sumy jednostek pojemności zapisu zużywanych przez zapis do tabeli podstawowej i tych zużywanych przez aktualizację globalnych indeksów wtórnych. Jeśli zapis do tabeli nie wymaga aktualizacji globalnego indeksu wtórnego, nie jest zużywana pojemność zapisu z indeksu.
Aby zapis do tabeli powiódł się, ustawienia przepustowości dla tabeli i wszystkich globalnych indeksów wtórnych muszą mieć wystarczającą pojemność zapisu, aby pomieścić zapis. W przeciwnym wypadku zapis do tabeli jest dławiony (throttling).
Koszt zapisu elementu do globalnego indeksu wtórnego zależy od kilku czynników:
jeżeli zapiszemy nowy element do tabeli, który definiuje indeksowany atrybut lub zaktualizujemy istniejący element, aby zdefiniować wcześniej niezdefiniowany indeksowany atrybut, wymagana jest jedna operacja zapisu, aby umieścić dane w tabeli;
jeżeli aktualizacja tabeli zmienia wartość indeksowanego atrybutu klucza (z A na B), wymagane są dwa zapisy – jeden do usunięcia elementu z indeksu i drugi zapis do umieszczenia nowego elementu w indeksie;
jeżeli element był obecny w indeksie, ale zapis do tabeli spowodował usunięcie indeksowanego atrybutu, wymagany jest jeden zapis, aby usunąć stary rzut elementu z indeksu;
jeśli element nie jest obecny w indeksie przed lub po aktualizacji elementu, nie ma dodatkowego kosztu zapisu dla indeksu;
jeżeli aktualizacja tabeli zmienia tylko wartość atrybutów rzutowanych w schemacie klucza indeksu, ale nie zmienia wartości żadnego klucza atrybutu klucza indeksowanego, wymagany jest jeden zapis, aby zaktualizować wartości atrybutów rzutowanych do indeksu.
Wszystkie te czynniki zakładają, że rozmiar każdego elementu w indeksie jest mniejszy lub równy rozmiarowi elementu 1KB do obliczania jednostek pojemności zapisu. Większe pozycje indeksu wymagają dodatkowych jednostek pojemności zapisu. Możemy zminimalizować koszty zapisu poprzez rozważenie, które atrybuty będą musiałby być zwrócone i rzutowanie jedynie tych atrybutów do indeksu.
Rozważania dotyczące przechowywania globalnych indeksów wtórnych (GSI)
W momencie zapisywania elementu to tabeli, DynamoDB automatycznie kopiuje odpowiedni podzbiór atrybutów do wszelkich globalnych indeksów wtórnych w których te atrybuty powinny się pojawić. Nasze konto AWS jest obciążane za przechowywanie elementu w tabeli podstawowej a także za przechowywanie atrybutów we wszelkich globalnych indeksach wtórnych na tej tabeli.
Ilość miejsca używanego przez elementu indeksu jest sumą następujących elementów:
rozmiar w bajtach klucza głównego tabeli bazowej (klucz partycji i klucz sortowania);
rozmiar w bajtach atrybutu klucza indeksu;
rozmiar w bajtach przewidywanych atrybutów (jeśli występują);
100 bajtów kosztów ogólnych dla elementu indeksu.
Aby oszacować wymagania przechowywania dla globalnego indeksu wtórnego możemy oszacować średni rozmiar elementu w indeksie a następnie pomnożyć przez liczbę elementów w tabeli podstawowej, które posiadają atrybuty klucza globalnego indeksu wtórnego.
Jeżeli tabela zawiera element w którym określony atrybut nie jest zdefiniowany ale ten atrybut jest zdefiniowany jako klucz partycji indeksu lub klucz sortowania, DynamoDB nie zapisuje żadnych danych dla tego elementu do indeksu.
To tyle w ramach teorii, w kolejnym wpisie wykorzystamy API AWS SDK dla .NET i przejdziemy
przez praktyczne przykłady.