Cześć.
Miałem chwilę przerwy od tej serii, ale już wracam. Poprzednie wpisy to solidna dawka podstawowej wiedzy na temat Redisa i jego użycia z poziomu Pythona. Tak po prawdzie, to można się pokusić o stwierdzenie, że ostatnio opublikowany artykuł wykracza ponad poziom podstawowy. Nie oznacza to jednak że kończmy. Powiem więcej – jeszcze trochę przed nami. Seria artykułów o Pythonie i Redisie to już 6 wpisów:
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-6/
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-5/
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-4/
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-3/
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-2/
- https://mmazurek.dev/redis-i-python-dobrze-dobrana-para-1/
Więc jeśli któryś pominąłeś to sugeruję nadrobić.
Zaczynajmy.
Pipelining
Czyli „przetwarzanie potokowe”. Sytuacja kiedy mamy do wykonania na kluczach jakąś sekwencję operacji nie jest sytuacją rzadką. Do komunikacji z Redisem wykorzystywany jest protokół TCP, zapewnia on niezawodność w dostarczaniu danych i ewentualne retransmisje. Jest dobrym wyborem twórców. Warto pamiętać jednak że przesyłanie danych przez sieć zawsze trwa. Szczególnie właśnie w sytuacji, kiedy wykonujemy kilka (tysięcy) operacji następujących po sobie. Aby skrócić RTT (Round Trip Time) Redis udostępnia mechanizm pipeling. Pozwala on wysłać wiele komend do Redisa „za jednym razem” – oszczędzamy tym samym czas którego potrzebujemy żeby przesłać dane przez sieć.
Zerknij na poniższy „benchmark”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | from timeit import timeit setup = """from redis import Redis from timeit import timeit redis_connection = Redis(decode_responses=True, db=0) key = "test" redis_connection.set(key, 0) """ stmt1 = """ i = 10000 while i >= 0: redis_connection.incr(key) i -= 1 """ stmt2 = """ i = 10000 with redis_connection.pipeline() as pipe: while i >= 0: pipe.incr(key) i -= 1 pipe.execute() """ print(timeit(stmt1, setup=setup, number=10)) print(timeit(stmt2, setup=setup, number=10)) |
Prezentuje on dwa podejścia, pierwsze, pokazane w stmt1 to podejście naiwne, a stmt2 to podejście używające pipeling’u. Efekt wykonania się tego programu jest niedeterministyczny, ale myślę że sam rząd wielkości już sporo powie:
stmt1: 9.824363377003465
stmt2: 1.3603699850209523
To co trzeba tu koniecznie zapamiętać to zagrożenie wysycenia pamięci RAM. Redis w momencie pipe.execute() zwraca rezultat każdej operacji, więc oczywistym jest że owe wyniki wszystkich komend musi sobie kolejkować w pamięci. Sugestia ode mnie jest jedna: z głową dobieraj maksymalną ilość komend uruchamianych pod pipeline’em.
Dodatkowym atutem tego podejścia jest to, że przekłada się na wzrost wydajności samego serwera Redisa. Wynika to oczywiście z faktu że dla Redisa koszt wyciągnięcia jakieś danej z klucza jest niski, ale odesłanie jej via TCP – już niekoniecznie. Pipeline sprawia, że Redis odpowiada „raz, a dobrze”.
Transakcje
Transakcje to jedno z podstawowych pojęć współczesnych systemów baz danych. Umożliwiają one współbieżny dostęp do zawartości bazy danych.
Istotą transakcji jest integrowanie kilku operacji w jedną niepodzielną całość.
Temat ten pojawiał się w tym wpisie nie przez przypadek. Wspomniane wcześniej pipeline’y idealnie się łączą z ideą transakcyjności. Co więcej, kawałek kodu który pokazałem wyżej jest w pełni transakcyjny. Metoda pipeline, która zwraca context managera może przyjąć dodatkowy parametr o nazwie transaction mający domyślną wartość ustawioną na True.
Transakcje w Redisie opierają się o kilka komend: MULTI, EXEC, DISCARD i WATCH i zapewniają, że:
- komendy pod transakcją wykonują się sekwencyjnie i nie ma możliwości by taki ciąg komend został przerwany przez innego klienta tego samego serwera,
- transakcje zapewniają podejście „wszystko albo nic”, czyli ciąg komend pod transakcją jest atomowy. Albo wszystkie komendy zostaną wykonane albo żadna (zakładając brak błędów, o czym później)
Komenda MULTI rozpoczyna transakcję. Wszystko po niej jest objęte ową transakcją której wykonanie rozpoczyna się wraz z wykonaniem EXEC. Zwraca ona rezultat wykonanych komend. Aby anulować transakcję można użyć komendy DISCARD.
Transakcja może się nie udać z dwóch powodów:
- przed wykonaniem EXEC np. składnia komendy jest niepoprawna
- po wykonaniu EXEC np. efekt wykonania się komendy nie jest poprawny (np. komenda niedopasowana do typu)
W pierwszym przypadku cała transakcja zostanie odrzucona, w drugim – zostanie wykonana ta część transakcji która się udała. Zachowanie w drugim przypadku nie jest spójne z bazami SQL, gdzie taka transakcja zostałaby odrzucona. Tłumaczenie się twórców w tym kontekście nie do końca mnie przekonuje.
Jest jeszcze jedna komenda o której nie można nie wspomnieć, poruszając wątek transakcji, a jest nią WATCH. Pozwala ona na detekcję tego, czy klucze nie zmieniły się przypadkiem od czasu kiedy zaczęliśmy je obserwować. Jeśli się zmieniły to komenda EXEC zakończy się błędem:
1 2 3 4 5 6 7 8 9 10 11 12 | 127.0.0.1:6379> WATCH key OK // tutaj zmieniamy wartość klucza "key" z poziomu innego klienta 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> GET key QUEUED 127.0.0.1:6379> EXEC (nil) // informacja o niepowodzeniu 127.0.0.1:6379> |
Pipeline i transakcje
Parametr transaction o którym wspomniałem wcześniej nie robi nic nadzwyczajnego – dodaje on po prostu komendy które powołują do życia transakcję, czyli MULTI, EXEC itp. Jest do standardowe użycie pipeline i transakcji – zapewniamy że raz wysłana paczka komend wykona się atomowo. Spójność tego rozwiązania jest piękna.
Podsumowując
Przeszliśmy sobie przez temat pipeline’ów i transakcji. Wyjaśniliśmy gdzie jest zysk z używaniach tego pierwszego mechanizmu i czego spodziewać się po drugim. Oba rozwiązania używane razem, tak jak to sugeruje biblioteka, dostarczają spójny mechanizm pozwalający na atomowa paczkowanie komend.
Mateusz Mazurek