Paweł Łukasiewicz
2024-05-10
Paweł Łukasiewicz
2024-05-10
Udostępnij Udostępnij Kontakt
Wprowadzenie

DynamoDB usprawnia dokonywanie operacji typu wszystko-albo-nic dla wielu elementów w obrębie jak i pomiędzy tabelami. Transakcje (znane nam już z poprzednich cykli wpisów dotyczących SQL, SQL - transakcje) zapewniają atomowość, spójność, izolację i trwałość (ACID) w DynamoDB pomagając zachować poprawność danych w aplikacjach.

W celu implementacji powyższych ‘zachowań’ możemy wykorzystać interfejs API DynamoDB do przeprowadzenia transakcyjnego odczytu i zapisu do zarządzania złożonymi przepływami biznesowymi, które wymagają dodawania, aktualizowania lub usuwania wielu elementów jako pojedynczej operacji typu wszystko-albo-nic. W ramach przykładu możemy posłużyć się poprawnym aktualizowaniem profilu graczy, gdy Ci, wymieniają przedmioty w grze lub dokonują zakupów.

Przy pomocy interfejsu możemy grupować wiele działań typu Put, Update, Delete oraz ConditionCheck. Taką operację możemy przesłać jako pojedynczą operację TransactWriteItems, która zakończy się sukcesem lub niepowodzeniem jako jedno wykonanie. Podobne zachowanie dotyczy również wielu akcji Get, które możemy zgrupować i przesłać jako pojedynczą operację TransactGetItems.

Włączenie transakcji dla tabel DynamoDB nie wiąże się z dodatkowym kosztem. Płacimy tylko za odczyty i zapisy, które są częścią transakcji. DynamoDB wykonuje dwa podstawowe odczyty lub zapisy każdego elementu w transakcji – jeden w celu przygotowania transakcji i jeden w celu wykonania transakcji. Te dwie bazowe operacje odczytu/zapisu są widoczne w metrykach Amazon CloudWatch.

Jak działają transakcje?

Dzięki wykorzystaniu transakcji DynamoDB pozwala na zgrupowanie wielu akcji i przesłanie ich jako jedną operację typu wszystko-albo-nic. Operacje te możemy grupować wykorzystując TransactWriteItems lub TransactGetItems. W poniższym wpisie skupimy się na różnych zagadnieniach do których możemy zaliczyć możliwości jakie daje nam API, spojrzymy na zarządzanie pojemnością, najlepsze praktyki oraz omówimy szczegóły korzystania z różnych operacji transakcyjnych.

TransactWriteItems

TransactWriteItems jest synchroniczną i idempotentną (o tym za chwilę) operacją zapisu, która grupuje do 100 akcji zapisu w pojedynczej operacji typu wszystko-albo-nic. Działania te mogą być skierowane do 100 różnych elementów w jednej lub więcej tabel DynamoDB w ramach tego samego konta AWS i tego samego regionu. Łączny rozmiar elementów w transakcji nie może przekroczyć 4MB. Akcje są wykonywane atomowo więc albo wszystkie się udają albo żadna z nich się nie udaje.

Musimy jednak pamiętać, że:

  • operacja TransactWriteItems różni się od omawianej wcześniej operacji BatchWriteItem tym, że wszystkie operacje w niej zawarte muszą zakończyć się sukcesem – w przeciwnym razie nie zostaną wprowadzone żadne zmiany. W przypadku operacji BatchWriteItem jest możliwe, że tylko niektóre działania w partii zakończą się sukcesem a pozostałe nie;
  • transakcje nie mogą być wykonywane przy użyciu indeksów.

Nie możemy również kierować na ten sam element wielu operacji w ramach tej samej transakcji, tj. nie możemy wykonać akcji ConditionCheck a także Update na tym samym elemencie w tej samej transakcji.

Do transakcji możemy dodawać następujące typu operacji:

  • Put - inicjacja operacji PutItem w celu utworzenia nowego elementu lub zastąpienia starego elementu nowym elementem, warunkowo lub bez określenia żadnego warunku;
  • Update - inicjacja operacji UpdateItem do edycji atrybutów istniejącego elementu lub dodania nowego elementu do tabeli, jeżeli jeszcze nie istnieje. Tej operacji używamy do dodania, usunięcia lub zaktualizowania atrybutów istniejącego elementu warunkowo lub bez warunku;
  • Delete - inicjacja operacji DeleteItem do usunięcia pojedynczego elementu w tabeli identyfikowanego przez jego klucz główny;
  • ConditionCheck - sprawdzenie czy element istnieje lub sprawdza stan określonych atrybutów elementu.

Po zakończeniu transakcji, zmiany dokonane w ramach tej transakcji są propagowane do globalnych indeksów wtórnych (GSI), strumieni (streams) oraz kopii zapasowych. Ponieważ propagacja jest natychmiastowa, jeżeli dojdzie do przywracania tabeli z kopii zapasowej (RestoreTableFromBackup) lub eksportowania do punktu w czasie (ExportTableToPoinInTime) w trakcie propagacji, tabela może zawierać niektóre, ale nie wszystkie zmiany dokonane podczas ostatniej transakcji.

Idempotencja

Czym jest idempotencja? Jest to rozwiązanie (mam na myśli uczynienie transakcji idempotentną) pomagające zapobiegać błędom aplikacji, jeżeli sama operacja jest składana wielokrotnie z powodu czasu połączenia lub innego problemu z komunikacją. Rozwiązanie takie wymaga dołączenia tokena klienta do wykonania wywoływania operacji TransactWriteItems.

Jeżeli oryginalne wywołanie powyższej operacji zakończyło się sukcesem to kolejne jej wywołania z tym samym tokenem klienta wracają pomyślnie bez wprowadzania jakichkolwiek zmian. Jeżeli ustawiony jest parametr ReturnConsumedCapacity, początkowe wywołanie TransactWriteItems zwraca liczbę jednostek pojemności zapisu zużytych podczas wprowadzania zmian. Kolejne wywołania tej metody z tym samym tokenem klienta zwracają liczbę jednostek pojemności odczytu zużytych do oczytania elementu.

Ważne informacje dotyczące idempotencji o których musimy pamiętać:

  • token klienta jest ważny przez 10 minut po zakończeniu żądania, które go używa. Po 10 minutach każde żądanie, które używa tego samego tokena, jest traktowane jako nowe żądanie. Nie zależy ponownie używać tego samego tokena klienta dla tego samego żądania po upływie 10 minut;
  • jeżeli powtórzymy żądanie z tym samym toknem klienta w ciągu 10-cio minutowego okna idempotencji ale zmienimy inny parametr żądania, DynamoDB zwróci wyjątek IndempotencyParameterMismatch.

Obsługa błędu przy zapisywaniu elementów

Transakcja zapisu nie uda się w następujących okolicznościach:

  • gdy warunek w jednym z wyrażeń warunkowych nie jest spełniony;
  • gdy wystąpił błąd sprawdzania poprawności transakcji ponieważ więcej niż jedno działanie w tej samej operacji TransactWriteItems jest wykonywane na tym samym elemencie;
  • gdy żądanie TransactWriteItems koliduje z trwającą operacją TransactWriteItems na jednym lub więcej elementów w żądaniu TransactWriteItems. W takim wypadku żądanie kończy się niepowodzeniem z wyjątkiem TransactionCanceledException;
  • gdy nie ma wystarczającej pojemności rezerwowej aby transakcja została zakończona;
  • gdy rozmiar elementu staje się zbyt duży (większy niż 400 KB) lub lokalny indeks wtórny (LSI) staje się zbyt duży lub występuje podobny błąd sprawdzania pojemności z powodu zmian wprowadzonych przez transakcję;
  • gdy wystąpił błąd użytkownika taki jak nieprawidłowy format danych.

TransactGetItems API

TransactGetItems to synchroniczna operacja odczytu, która grupuje do 100 akcji Get. Operacje te mogą być skierowane do 100 różnych elementów w jednej lub więcej tabel DynamoDB w ramach tego samego konta AWS i regionu. Łączny rozmiar elementów w transakcji nie może przekroczyć 4MB.

Akcje Get są wykonywane atomowo więc albo wszystkie z nich się powiodą albo wszystkie się nie powiodą:

  • Get inicjuje operacje GetItem w celu pobrania zestawu atrybutów dla elementu o podanym kluczu głównym. Jeżeli nie zostanie znaleziony żaden pasujący element, Get nie zwróci żadnych danych.

Obsługa błędów przy odczycie

Operacje odczytu nie udają się w następujących okolicznościach:

  • gdy żądanie TransactGetItems koliduje z trwającą operacją TransactGetItems na jednym lub więcej elementów w żądaniu TramsactGetItems. W takim przypadku żądanie kończy się niepowodzeniem z rzuconym wyjątkiem TransactionCanceledException;
  • gdy nie ma wystarczającej pojemności rezerwowej, aby transakcja mogła zostać zakończona;
  • gdy wystąpił błąd użytkownika, taki jak nieprawidłowy format danych.

Poziomy izolacji dla transakcji DynamoDB

Poziomy izolacji dla operacji transakcyjnych (TransactWriteItems oraz TransactGetItems) są następujące:

SERIALIZABLE

Ten typ izolacji zapewnia, że wyniki dla wielu współbieżnych operacji są takie same, jak gdyby żadna operacja nie rozpoczęła się do momentu zakończenia poprzedniej.

Istnieje izolacja serializowana pomiędzy następującymi typami operacji:

  • między dowolną operacją transakcyjną a dowolną standardową operacją zapisu, tj. PutItem, UpdateItem lub DeleteItem;
  • między dowolną operacją transakcyjną a dowolną standardową operacją odczytu, tj. GetItem;
  • między operacją TransactWriteItems a operacją TransactGetItems.

Chociaż istnieje serializowana izolacja między operacjami transakcyjnymi i każdym pojedynczym standardowym zapisem w operacji BatchWriteItem, nie ma serializowanej izolacji między transakcją a operacją BatchWriteItem jako jednostką.

Podobnie, poziom izolacji między operacją transakcyjną a poszczególnymi GetItems w operacji BatchItem jest serializowany. Jednakże, poziom izolacji między transakcją a operacją BatchGetItem jako jednostką jest read-committed.

Pojedyncze żądanie GetItem jest serializowane w odniesieniu do żądania TransactWriteItems na jeden z dwóch sposobów, przed lub po żądaniu TransactWriteItems. Wiele żądań GetItem wykonanych na kluczach we współbieżnych żądaniach TransactWriteItems może być uruchamianych w dowolnej kolejności – wyniki są więc read-commited.

Rozjaśnimy nieco powyższe punkty na przykładzie: załóżmy, że żądania GetItem dla elementu A i B są uruchamiane równolegle z żądaniem TransactWriteItems, które modyfikuje zarówno element A jak i element B – w takim wypadku istnieją 4-ry możliwości:

  • oba żądania GetItem są uruchamiane przed żądaniem TransactWriteItems;
  • oba żądania GetItem są uruchamiane po żądaniu TransactWriteItems;
  • żądanie GetItem dla elementu A jest uruchamiane przed żądaniem TransactWriteItems. Dla elementu A GetItem jest uruchamiany po TransactWriteItems;
  • żądanie GetItem dla elementu B jest uruchamiane przed żądaniem TransactWriteItems. Dla elementu A GetItems jest uruchamiany po TransactWriteItems.

Jeżeli preferowany jest serializowany poziom izolacji dla wielu żądań GetItem to należy wykorzystać TransactGetItems.

READ-COMMITTED

Izolacja read-committed zapewnia, że operacje odczytu zawsze zwracają committed (zatwierdzone informacje) dla elementu – odczyt nigdy nie będzie przedstawiał widoku elementu reprezentującego stan z zapisu transakcyjnego, który ostatecznie się nie powiódł. Izolacja typu read-committed nie zapobiega modyfikacjom elementu bezpośrednio po operacji odczytu.

Poziom izolacji jest read-committed pomiędzy każdą operacją transakcyjną a każdą operacją odczytu, która obejmuje wiele standardowych odczytów, tj. BatchGetItem, Query lub Scan. Jeżeli transakcyjny zapis uaktualnia element w środku operacji BatchGetItem, Scan lub Query to kolejna część operacji odczytu zwraca nowo zatwierdzoną wartość (z odczytem typu ConsistentRead) lub ewentualnie wcześniejszą zatwierdzoną wartość (eventually consistent read).

Podsumowanie powyższych operacji

Zanim przejdziemy do kolejnych punktów spójrzmy jeszcze na podsumowanie poziomów izolacji pomiędzy operacją transakcyjną (TransactWriteItems lub TransactGetItems) a operacjami znanymi z poprzednich wpisów:

Operacja Poziom izolacji
DeleteItem Serializable
PutItem Serializable
UpdateItem Serializable
GetItem Serializable
BatchGetItem Read-committed*
BatchWriteItem Not Serializable*
Query Read-committed
Scan Read-committed
Inne operatory transakcyjne Serializable

Poziomy oznaczone gwiazdką (*) dotyczą operacji jako jednostki. Jednakże, indywidualne operacje w ramach tych operacji mają poziom izolacji typu serializable.

Obsługa konfliktów transakcyjnych w DynamoDB

Konflikt transakcyjny może wystąpić podczas współbieżnych żądań na poziomie elementu w ramach transakcji. Konflikty mogą wystąpić w następujących scenariuszach:

  • żądanie PutItem, UpdateItem lub DeleteItem dla elementu koliduje z trwającym żądaniem TransactWriteItems, które obejmuje ten sam element;
  • element w ramach żądania TransactWriteItems jest częścią innego trwającego żądania TransactWriteItems;
  • element w ramach żądania TransactGetItems jest częścią trwającego żadania TransactWriteItems, BatchWriteItem, PutItem, UpdateItem lub DeleteItem.

Zanim pójdziemy do kolejnego punktu musimy zapamiętać, że:

  • jeżeli żądanie PutItem, UpdateItem lub DeleteItem jest odrzucone to żądanie kończy się niepowodzeniem z wyjątkiem TransactionConflictException;
  • jeżeli jakiekolwiek żądanie na poziomie elementu w ramach TransactWriteItems lub TransactGetItems zostanie odrzucone, żądanie nie powiedzie się z wyjątkiem TransactionCanceledException. Jeżeli to żądanie się nie powiedziecie to nie dojdzie do ponowienia próby przez AWS SDK;
  • jeżeli używamy SDK dla Javy wyjątek zwróci nam listę CancellationReasons uporządkowaną zgodnie z listą pozycji w parametrze żądania TransactItems. Dla innych języków zostanie zwrócona reprezentacja listy w postaci łańcucha tekstowego (string) – będzie ona dostępna w komunikacje o błędzie wyjątku;
  • jeżeli trwająca operacja TransactWriteItems lub TransactGetItems koliduje z równoległym żądaniem GetItem obie operacje mogą się nie powieść.

Warto odnotować, że metryka TransactionConflict dostępna z poziomu CloudWatch jest zwiększana dla każdego nieudanego żądania na poziomie elementu.

Użycie transakcyjnych API z DAX

DAX to znany Wam z poprzednich wpisów mechanizm (in-memory cache), który pozwala nawet na 10-cio krotny wzrost wydajności związany z dostępem do danych w DynamoDB.

Operacje TransactWritemItems oraz TransactGetItems są wspierane przez DAX z tymi samymi poziomami izolacji jak w DynamoDB.

TransactWriteItems zapisuje przez DAX, DAX przekazuje wywołanie TransactWriteItems do DynamoDB i zwraca odpowiedź. Aby wypełnić pamięć podręczną po zapisie, DAX wywołuje TransactGetItems w tle dla każdego elementu w operacji TransactWriteItems co zużywa dodatkowe jednostki pamięci odczytu. Funkcjonalność ta pozwala zachować prostą logikę aplikacji i używać DAX zarówno do operacji transakcyjnych jak i nietransakcyjnych.

Wywołania TransactGetItems są przekazywane przez DAX bez lokalnego buforowania elementów. Jest to takie samo zachowanie jak w przypadku silnie spójnych odczytów przy użyciu API w DAX.

Zarządzanie pojemnością dla transakcji

Nie ma żadnych dodatkowych kosztów, aby włączyć transakcje dla naszych tabel w DynamoDB. Płacimy tylko za odczyty lub zapisy, które są częścią transakcji. DynamoDB wykonuje dwa podstawowe odczyty lub zapisy każdego elementu w transakcji: jeden w celu przygotowania transakcji i jeden w celu zatwierdzenia transakcji. Te dwie bazowe operacje odczytu/zapisu są widoczne w metrykach CloudWatch.

Przygotowując nasze tabele DynamoDB powinniśmy planować dodatkowe odczyty i zapisy wymagane przez transakcyjne API - dzięki temu unikniemy potencjalnego throttlingu na tabeli.

Możemy np. założyć, że nasza aplikacja będzie wykonywała jedną transakcję na sekundę a każda z nich zapisze trzy 500-bajtowe elementy w tabeli – każdy element wymaga dwóch jednostek pojemności zapisu WCU: jednej do przygotowania transakcji i jednej do jej wykonania. W takim wypadku powinniśmy dostarczyć 6 jednostek WCU dla naszej tabeli.

Musimy jeszcze pamiętać, że używając DAX wykorzystalibyśmy również dwie jednostki pojemności odczytu RCU dla każdego elementu w wywołaniu TransactWriteItems. Musielibyśmy również dostarczyć 6 dodatkowych RCU dla takiej tabeli.

Podobnie do wspominanego wcześniej zapisu, jeżeli nasza aplikacja wykonują jedną transakcję (tym razem) odczytu a każda transakcja odczytuje 3 500-bajtowe elementy w tabeli to musielibyśmy dostarczyć 6 jednostek pojemności RCU dla tabeli. Odczyt każdego elementu wymaga dwóch jednostek RCU: jednej do przygotowania transakcji i jednej do jej przeprowadzenia.

Ponadto, domyślnym zachowaniem SDK jest ponawianie transakcji w przypadku wyjątku TransactionInProcessException - musimy również pamiętać o dodatkowych jednostkach pojemności odczytu dla tych operacji. Takie samo podejście dotyczy sytuacji w której ponawiamy próby transakcji w swoim własnym kodzie używając ClientRequestToken.

Transakcje: najlepsze praktyki

Podsumujmy powyższy wpis oraz spójrzmy na zalecane praktyki podczas korzystania z transakcji DynamoDB:

  • włączenie automatycznego skalowania na tabelach oraz upewnienie się, że zdefiniowaliśmy wystarczającą przepustowość, aby przeprowadzić dwie operacje odczytu i zapisu dla każdego elementu w transakcji;
  • jeżeli nie używamy SDK dostarczonego przez AWS powinniśmy wykorzystać atrybut ClientRequestToken podczas wywoływania TransactWriteItems w celu upewnienia się, że żądanie jest idempotentne;
  • nie powinniśmy grupować operacji w transakcji, jeżeli nie jest to konieczne, np. jeżeli pojedyncza transakcja składająca się z 10 operacji może być rozbita na wiele transakcji bez naruszenia poprawności działania aplikacji zalecane jest podzielenie transakcji. Prostsze transakcje poprawiają przepustowość i mają większe szanse na powodzenie;
  • wiele transakcji aktualizujących te same elementy jednocześnie może powodować konflikty, które doprowadzą do anulowania transakcji;
  • jeżeli zestaw atrybutów jest często aktualizowany w wielu elementach jako część pojedynczej transakcji powinniśmy rozważyć zgrupowanie atrybutów w pojedynczym elemencie, aby zmniejszyć zakres transakcji;
  • powinniśmy unikać transakcji do masowych zapisów danych – w takim przypadku lepiej wykorzystać BatchWriteItem.