7.1 Dlaczego jest to konieczne
Omówiliśmy szczegółowo wszystkie właściwości ACID, ich przeznaczenie i przypadki użycia. Jak widać, nie wszystkie bazy danych oferują gwarancje ACID, poświęcając je na rzecz lepszej wydajności. Dlatego może się zdarzyć, że w Twoim projekcie zostanie wybrana baza danych, która nie oferuje ACID i konieczne może być zaimplementowanie niektórych niezbędnych funkcjonalności ACID po stronie aplikacji. A jeśli twój system jest zaprojektowany jako mikrousługi lub inny rodzaj aplikacji rozproszonej, to, co byłoby normalną lokalną transakcją w jednej usłudze, stanie się teraz transakcją rozproszoną - i oczywiście straci swój charakter ACID, nawet jeśli baza danych każda pojedyncza mikrousługa będzie ACID.
Nie chcę dawać wyczerpującego przewodnika na temat tworzenia menedżera transakcji, po prostu dlatego, że jest on zbyt duży i skomplikowany, a ja chcę omówić tylko kilka podstawowych technik. Jeśli nie mówimy o aplikacjach rozproszonych, to nie widzę powodu, aby próbować w pełni wdrożyć ACID po stronie aplikacji, jeśli potrzebujesz gwarancji ACID - w końcu łatwiej i taniej pod każdym względem będzie wziąć gotowe rozwiązanie ( czyli baza danych z ACID).
Ale chciałbym pokazać Ci kilka technik, które pomogą Ci w dokonywaniu transakcji po stronie aplikacji. W końcu znajomość tych technik może ci pomóc w różnych scenariuszach, nawet tych, które niekoniecznie wiążą się z transakcjami, i uczyni cię lepszym programistą (mam taką nadzieję).
7.2 Podstawowe narzędzia dla miłośników transakcji
Blokowanie optymistyczne i pesymistyczne. Są to dwa rodzaje blokad niektórych danych, do których można uzyskać dostęp w tym samym czasie.
Optymistazakłada, że prawdopodobieństwo równoczesnego dostępu nie jest tak duże, dlatego robi co następuje: odczytuje żądaną linię, zapamiętuje jej numer wersji (lub znacznik czasu, lub sumę kontrolną / hash - jeśli nie można zmienić schematu danych i dodać kolumnę dla wersji lub znacznik czasu), a przed zapisem zmian do bazy danych dla tych danych sprawdza, czy wersja tych danych nie uległa zmianie. Jeśli wersja się zmieniła, musisz jakoś rozwiązać powstały konflikt i zaktualizować dane („zatwierdzenie”) lub wycofać transakcję („wycofanie”). Wadą tej metody jest to, że stwarza ona sprzyjające warunki dla błędu o długiej nazwie „time-of-check to time-of-use”, w skrócie TOCTOU: stan może się zmieniać w okresie czasu między sprawdzeniem a zapisem. Nie mam doświadczenia z blokowaniem optymistycznym,
Jako przykład znalazłem jedną technologię z codziennego życia programisty, która wykorzystuje coś w rodzaju blokowania optymistycznego - jest to protokół HTTP. Odpowiedź na początkowe żądanie HTTP GET MOŻE zawierać nagłówek ETag dla kolejnych żądań PUT od klienta, którego klient MOŻE użyć w nagłówku If-Match. W przypadku metod GET i HEAD serwer odeśle żądany zasób tylko wtedy, gdy pasuje on do jednego ze znanych mu tagów ET. W przypadku PUT i innych niebezpiecznych metod załaduje zasób tylko w tym przypadku. Jeśli nie wiesz, jak działa ETag, oto dobry przykład użycia biblioteki „feedparser” (która pomaga analizować kanały RSS i inne).
>>> import feedparser
>>> d = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml')
>>> d.etag
'"6c132-941-ad7e3080"'
>>> d2 = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml', etag=d.etag)
>>> d2.feed
{}
>>> d2.debug_message
'The feed has not changed since you last checked, so the server sent no data. This is a feature, not a bug!'
Pesymista z kolei wychodzi z tego, że transakcje często „spotykają się” na tych samych danych i aby uprościć sobie życie i uniknąć niepotrzebnych wyścigów, po prostu blokuje potrzebne mu dane. Aby zaimplementować mechanizm blokowania, musisz albo utrzymywać połączenie z bazą danych dla swojej sesji (zamiast wyciągać połączenia z puli - w takim przypadku najprawdopodobniej będziesz musiał pracować z optymistycznym blokowaniem) lub użyć identyfikatora dla transakcji , z którego można korzystać niezależnie od połączenia. Wadą blokowania pesymistycznego jest to, że jego użycie ogólnie spowalnia przetwarzanie transakcji, ale można być spokojnym o dane i uzyskać prawdziwą izolację.
Dodatkowe niebezpieczeństwo czai się jednak w możliwym impasie, w którym kilka procesów czeka na zablokowane przez siebie zasoby. Na przykład transakcja wymaga zasobów A i B. Proces 1 zajął zasób A, a proces 2 zasób B. Żaden z tych dwóch procesów nie może kontynuować wykonywania. Istnieją różne sposoby rozwiązania tego problemu - nie chcę teraz wchodzić w szczegóły, więc najpierw poczytaj Wikipedię, ale w skrócie istnieje możliwość stworzenia hierarchii zamków. Jeśli chcesz poznać tę koncepcję bardziej szczegółowo, zapraszamy do zastanowienia się nad „Problemem filozofów podczas obiadu” („Problem filozofów w jadalni”).
Oto dobry przykład zachowania obu blokad w tym samym scenariuszu.
Odnośnie implementacji zamków. Nie chcę wchodzić w szczegóły, ale istnieją menedżery blokad dla systemów rozproszonych, na przykład: ZooKeeper, Redis itp., Consul.
7.3 Idempotentność operacji
Kod idempotentny jest ogólnie dobrą praktyką i dokładnie tak jest w przypadku, gdy dobrze byłoby, gdyby programista mógł to zrobić, niezależnie od tego, czy korzysta z transakcji, czy nie. Idempotentność to właściwość operacji polegająca na generowaniu tego samego wyniku, gdy ta operacja zostanie ponownie zastosowana do obiektu. Funkcja została wywołana - dała wynik. Wezwany ponownie po sekundzie lub pięciu - dał ten sam wynik. Oczywiście, jeśli dane w bazie uległy zmianie, wynik będzie inny. Dane w systemach trzecich mogą nie zależeć od funkcji, ale wszystko, co to robi, musi być przewidywalne.
Może istnieć kilka przejawów idempotencji. Jedna z nich to tylko rekomendacja, jak napisać kod. Pamiętasz, że najlepsza funkcja to taka, która robi jedną rzecz? A co by było dobrego napisać testy jednostkowe dla tej funkcji? Jeśli zastosujesz się do tych dwóch zasad, to już zwiększysz szansę, że Twoje funkcje będą idempotentne. Aby uniknąć nieporozumień, wyjaśnię, że funkcje idempotentne niekoniecznie są „czyste” (w sensie „czystości funkcji”). Funkcje czyste to takie funkcje, które działają tylko na danych, które otrzymały na wejściu, nie zmieniając ich w żaden sposób i nie zwracając przetworzonego wyniku. Są to funkcje, które pozwalają skalować aplikację przy użyciu technik programowania funkcyjnego. Ponieważ mówimy o ogólnych danych i bazie danych, jest mało prawdopodobne, aby nasze funkcje były czyste,
To jest czysta funkcja:
def square(num: int) -> int:
return num * num
Ale ta funkcja nie jest czysta, ale idempotentna (proszę nie wyciągać wniosków na temat tego, jak piszę kod z tych fragmentów):
def insert_data(insert_query: str, db_connection: DbConnectionType) -> int:
db_connection.execute(insert_query)
return True
Zamiast mnóstwa słów mogę po prostu opowiedzieć o tym, jak zostałem zmuszony do nauczenia się pisania programów idempotentnych. Jak widać, dużo pracuję z AWS, a istnieje usługa o nazwie AWS Lambda. Lambda pozwala nie zajmować się serwerami, a po prostu wczytać kod, który będzie działał w odpowiedzi na jakieś zdarzenia lub zgodnie z harmonogramem. Zdarzeniem mogą być komunikaty dostarczane przez brokera komunikatów. W AWS tym brokerem jest AWS SNS. Myślę, że powinno to być jasne nawet dla tych, którzy nie pracują z AWS: mamy brokera, który wysyła wiadomości kanałami („tematy”), a mikroserwisy, które subskrybują te kanały, odbierają wiadomości i jakoś na nie reagują.
Problem polega na tym, że SNS dostarcza wiadomości „co najmniej raz” („dostarczenie co najmniej raz”). Co to znaczy? Że prędzej czy później Twój kod Lambda zostanie wywołany dwukrotnie. I to naprawdę się dzieje. Istnieje wiele scenariuszy, w których twoja funkcja musi być idempotentna: na przykład, kiedy pieniądze są wypłacane z konta, możemy oczekiwać, że ktoś wypłaci tę samą kwotę dwukrotnie, ale musimy się upewnić, że są to naprawdę 2 niezależne czasy - innymi słowy, są to 2 różne transakcje, a nie powtórzenie jednej.
Dla odmiany podam inny przykład – ograniczenie częstotliwości zapytań do API („rate limiting”). Nasza Lambda otrzymuje zdarzenie z określonym user_id, dla którego należy sprawdzić, czy użytkownik o tym ID nie wyczerpał swojej liczby możliwych żądań do niektórych naszych API. Moglibyśmy przechowywać w DynamoDB z AWS wartość wykonanych wywołań i zwiększać ją z każdym wywołaniem naszej funkcji o 1.
Ale co, jeśli ta funkcja Lambda zostanie dwukrotnie wywołana przez to samo zdarzenie? Przy okazji, czy zwróciłeś uwagę na argumenty funkcji lambda_handler(). Drugi argument, context w AWS Lambda jest podawany domyślnie i zawiera różne metadane, w tym request_id generowany dla każdego unikalnego wywołania. Oznacza to, że teraz zamiast przechowywać liczbę wykonanych wywołań w tabeli, możemy przechowywać listę request_id i przy każdym wywołaniu nasza Lambda będzie sprawdzać, czy dane żądanie zostało już przetworzone:
import json
import os
from typing import Any, Dict
from aws_lambda_powertools.utilities.typing import LambdaContext # needed only for argument type annotation
import boto3
limit = os.getenv('LIMIT')
def handler_name(event: Dict[str: Any], context: LambdaContext):
request_id = context.aws_request_id
# We find user_id in incoming event
user_id = event["user_id"]
# Our table for DynamoDB
table = boto3.resource('dynamodb').Table('my_table')
# Doing update
table.update_item(
Key={'pkey': user_id},
UpdateExpression='ADD requests :request_id',
ConditionExpression='attribute_not_exists (requests) OR (size(requests) < :limit AND NOT contains(requests, :request_id))',
ExpressionAttributeValues={
':request_id': {'S': request_id},
':requests': {'SS': [request_id]},
':limit': {'N': limit}
}
)
# TODO: write further logic
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"status ": "success"
})
}
Ponieważ mój przykład jest w rzeczywistości zaczerpnięty z Internetu, zostawię link do oryginalnego źródła, zwłaszcza, że daje trochę więcej informacji.
Pamiętasz, jak wspomniałem wcześniej, że coś w rodzaju unikalnego identyfikatora transakcji może służyć do blokowania udostępnianych danych? Teraz dowiedzieliśmy się, że można go również użyć do uczynienia operacji idempotentnymi. Dowiedzmy się, w jaki sposób możesz samodzielnie wygenerować takie identyfikatory.