Redis: Optymalizacja pamięci i przesunięcie replikacji

30 Sep 2020

database  debugging  nosql  performance  redis  replication 

Share on:

W tym wpisie chciałbym omówić zalecenia i dobre praktyki odnoszące się do zarządzania pamięcią a także przedstawić czym jest i jakie znaczenie ma przesunięcie replikacji.

Zarządzanie i optymalizacja pamięci #

Z racji tego, że Redis przechowuje wszystkie swoje dane w pamięci, ważne jest, aby zoptymalizować jej wykorzystanie i odpowiednio dbać o jej zużycie. Jednak pamiętaj, że wszystko tak naprawdę zależy od konkretnego przypadku.

Redis umożliwia wykonanie wielu złożonych operacji na danych i manipulowania nimi zapewniając obsługę wielu ich typów, stąd moim zdaniem, jedną z ważniejszych umiejętności podczas pracy z nim jest odpowiednia dbałość o rodzaj tych operacji. Ponadto zrozumienie, dlaczego nagle procesy Redisa zaczynają pochłaniać nieoczekiwanie duże ilości pamięci, jest równie ważne. Przydatna może być również wiedza na temat tego, w jaki sposób przechowywane są różne struktury, w jaki sposób są zaimplementowane i jak działają, zwłaszcza że programiści jak i administratorzy często nie rozumieją specyfiki pracy Redisa z pamięcią RAM oraz tego, za co i kiedy trzeba zapłacić cenę wysokiej wydajności.

Stosowanie odpowiednich struktur danych jest kluczowe z punktu widzenia wydajności i optymalizacji pamięci. Dlatego tak istotne jest, aby już na etapie projektowania ułatwić sobie pracę poprzez pewną optymalizacją i wdrożenie zaleceń. Temat jest niezwykle szeroki i to, co przedstawię poniżej, jest tylko pewną jego częścią. Myślę jednak, że może być dobrym punktem startowym do dalszych rozważań i analizy.

Jeżeli nie wiesz, za pomocą jakich poleceń możesz tworzyć struktury danych i jakie typy wykorzystywać, koniecznie przeczytaj poniższe artykuły:

Natomiast po prosty i w miarę wyczerpujący opis typów danych używanych w Redisie odsyłam do książki Redis 4.x Cookbook.

Jedną z największych zalet Redisa w porównaniu z innymi tego typu systemami pamięci jest bogaty zestaw dostępnych struktur danych. Uporządkowane listy, uporządkowane skróty i posortowane zestawy są szczególnie przydatnymi narzędziami do buforowania. Pamiętaj, że buforowanie to coś więcej niż upychanie wszystkiego w łańcuchy. Dokładne informacje o komendach powiązanych z daną strukturą znajdziesz w oficjalnej dokumentacji. Są one pogrupowane według typu danych:

Praca do wykonania niestety nie leży tylko w gestii administratora, ponieważ to, jak wykorzystywana będzie pamięć, zależy w dużej mierze od architekta i tego, jakie techniki przechowywania zastosuje. Jako administratorzy mamy jednak ogromny wpływ na działanie uruchomionych usług, ponieważ praca, którą wykonamy na początkowym etapie, ma zawsze niebagatelne znaczenie związane z ich działaniem, pracą serwera jak i całego środowiska. Z punktu widzenia operatora istnieją trzy niezwykle ważne rzeczy, o których należy pamiętać:

Od odpowiedniego doboru powyższych elementów zależy, ile pamięci zostanie faktycznie wykorzystane. Aby maksymalnie skrócić temat, poniżej znajdują się pewne sugestie i zalecenia, na podstawie zasobów, które kiedyś znalazłem w sieci oraz moich doświadczeń. Jeżeli będziesz miał jakiekolwiek wątpliwości, w pierwszej kolejności posiłkuj się oficjalnym dokumentem Memory Optimization for Redis.

Zachęcam Cię mocno do przeczytania zaleceń dotyczących zarządzania i optymalizacji pamięci. Repozytorium z wytycznymi znajduje się tutaj. Koniecznie zerknij także do oficjalnego repozytorium i rodziałów Memory Optimization i Memory allocation, rozdziału Chapter 9: Reducing memory use książki Redis in Action, świetnego dokumentu Memory management best practices z zasobów GCloud oraz artykułu Redis RAM Ramifications – Part I.

Aby przechowywać klucze, Redis przydziela co najwyżej tyle pamięci, na ile pozwala ustawienie maxmemory, jednak są możliwe niewielkie dodatkowe alokacje. Jest kilka rzeczy, na które należy zwrócić uwagę, jak Redis zarządza pamięcią:

Jeżeli wykorzystujesz Redisa, weź pod uwagę poniższe zalecenia:

Dodatkowo poniżej znajduje się krótki, ale bardzo konkretny cheatsheet, który znalazłem jakiś czas temu, badając temat optymalizacji pamięci:

Wspomnę jeszcze o poleceniu DEBUG OBJECT, które wyświetla informacje m.in. o kodowaniu obiektów:

Wiele typów danych w Redisie jest kodowanych w bardzo wydajny sposób i zoptymalizowanych tak, aby zajmowały jak najmniej miejsca. Parametry konfiguracji, które się do tego odnoszą i które możesz zoptymalizować to:

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
set-max-intset-entries 512

Jeśli specjalnie zakodowana wartość przekracza skonfigurowany maksymalny rozmiar, Redis automatycznie skonwertuje ją na normalne kodowanie. Ta operacja jest bardzo szybka w przypadku małych wartości, ale jeśli zmienisz ustawienie, aby użyć specjalnie zakodowanych wartości dla znacznie większych typów, sugeruje się wykonanie niektórych testów porównawczych w celu sprawdzenia czasu konwersji. Dlatego nie zalecam zmiany w ciemno i proponuję posiłkować się oficjalną dokumentacją. Na przykład zwiększenie wartości set-max-intset-entries zwiększa opóźnienie operacji na zestawach (SET), a także zwiększa się wykorzystanie procesora.

Niezwykle ważnym poleceniem pomocnym w przypadku badania wykorzystania pamięci jak i występujących z nią problemów jest komenda INFO memory:

127.0.0.1:6379> INFO memory
# Memory
used_memory:2111424
used_memory_human:2.01M
used_memory_rss:4734976
used_memory_rss_human:4.52M
used_memory_peak:6191800
used_memory_peak_human:5.90M
used_memory_peak_perc:34.10%
used_memory_overhead:2058370
used_memory_startup:791616
used_memory_dataset:53054
used_memory_dataset_perc:4.02%
allocator_allocated:2557080
allocator_active:2969600
allocator_resident:8212480
total_system_memory:2095890432
total_system_memory_human:1.95G
used_memory_lua:37888
used_memory_lua_human:37.00K
used_memory_scripts:0
used_memory_scripts_human:0B
number_of_cached_scripts:0
maxmemory:1024000000
maxmemory_human:976.56M
maxmemory_policy:noeviction
allocator_frag_ratio:1.16
allocator_frag_bytes:412520
allocator_rss_ratio:2.77
allocator_rss_bytes:5242880
rss_overhead_ratio:0.58
rss_overhead_bytes:-3477504
mem_fragmentation_ratio:2.29
mem_fragmentation_bytes:2664568
mem_not_counted_for_evict:0
mem_replication_backlog:1048576
mem_clients_slaves:33844
mem_clients_normal:183998
mem_aof_buffer:0
mem_allocator:jemalloc-5.1.0
active_defrag_running:0
lazyfree_pending_objects:0

Metryka mem_fragmentation_ratio pokazuje stosunek pamięci przydzielonej przez system operacyjny (used_memory_rss) do pamięci używanej (used_memory). W tym przypadku used_memory i used_memory_rss będą już zawierały zarówno same dane, jak i koszty przechowywania wewnętrznych struktur. Redis traktuje RSS (ang. Resident Set Size) jako ilość pamięci przydzielonej przez system operacyjny, w której oprócz danych użytkownika (i kosztu ich wewnętrznej reprezentacji), koszty fragmentacji są brane pod uwagę, gdy sam system operacyjny fizycznie przydziela pamięć.

W praktyce, jeśli wartości mem_fragmentation_ratio wykraczają poza granice 1-1.5, oznacza to, że coś jest nie tak. Co w takim wypadku zrobić? Najprostszym rozwiązaniem jest restart instancji Redis — im dłużej proces, do którego aktywnie piszesz, działa bez ponownego uruchamiania, tym wyższy będzie mem_fragmentation_ratio. Na przykład wartość 2.1 mówi nam, że używamy 210% więcej pamięci, niż potrzebujemy. Wartość mniejsza niż 1 wskazuje, że pamięć się skończyła i system operacyjny się zamieni.

Współczynnik fragmentacji nie jest wiarygodny, gdy maksymalne użycie pamięci jest znacznie większe niż obecnie używana pamięć. Fragmentacja jest obliczana jako faktycznie wykorzystana pamięć fizyczna (wartość RSS, która odzwierciedla szczytową pamięć) podzielona przez ilość aktualnie używanej pamięci (jako suma wszystkich alokacji). Gdy używana pamięć jest niska, np. z powodu zwolnienia kluczy/wartości, ale RSS jest wysoki, stosunek RSS/mem_used będzie bardzo wysoki.

Tak naprawdę, jeśli metryka wskaźnika wykorzystania pamięci przekracza 80%, oznacza to, że jesteśmy blisko całkowitego wykorzystania pamięci. Jeśli nie podejmiesz żadnych działań, a użycie pamięci będzie nadal rosło, ryzykujemy awarię z powodu niewystarczającej ilości pamięci. Jeśli metryka szybko wzrasta do 80% i nadal rośnie, być może została użyta jedna z operacji intensywnie wykorzystujących pamięć. Na przykład wykonanie komendy BGSAVE, która wykorzystuje kopiowanie przy zapisie, w zależności od rozmiaru danych, objętości zapisu, może wymagać dwukrotnie więcej pamięci niż miejsca zajmowanego przez dane. Widzimy, że parametr fragmentacji jest kluczowym parametrem, który powinniśmy monitorować.

Drugą przydatną komendą jest INFO commandstats, która wyświetla statystyki komend i liczbę wywołań od momentu uruchomienia serwera lub ostatniego wywołania CONFIG RESETSTAT:

localhost:6379> INFO commandstats
# Commandstats
cmdstat_get:calls=2015,usec=5867,usec_per_call=2.91
cmdstat_set:calls=2085,usec=19719,usec_per_call=9.46
cmdstat_setex:calls=89703,usec=1249687,usec_per_call=13.93
cmdstat_del:calls=88530,usec=1537560,usec_per_call=17.37
cmdstat_select:calls=302400,usec=577069,usec_per_call=1.91
cmdstat_keys:calls=1,usec=300,usec_per_call=300.00
cmdstat_scan:calls=1,usec=6,usec_per_call=6.00
cmdstat_dbsize:calls=2,usec=5,usec_per_call=2.50
cmdstat_auth:calls=6853034,usec=22901637,usec_per_call=3.34
cmdstat_ping:calls=12538371,usec=15151843,usec_per_call=1.21
cmdstat_multi:calls=7,usec=31,usec_per_call=4.43
cmdstat_exec:calls=28,usec=26823,usec_per_call=957.96
cmdstat_psync:calls=2,usec=1725,usec_per_call=862.50
cmdstat_replconf:calls=22,usec=36,usec_per_call=1.64
cmdstat_flushdb:calls=29,usec=984,usec_per_call=33.93
cmdstat_info:calls=7688890,usec=230663501,usec_per_call=30.00
cmdstat_debug:calls=1,usec=22344,usec_per_call=22344.00
cmdstat_subscribe:calls=26,usec=106,usec_per_call=4.08
cmdstat_publish:calls=8137206,usec=62551238,usec_per_call=7.69
cmdstat_client:calls=58,usec=58,usec_per_call=1.00
cmdstat_eval:calls=2015,usec=101008,usec_per_call=50.13
cmdstat_command:calls=2,usec=1898,usec_per_call=949.00

Już na sam koniec inne ciekawe zasoby:

Przesunięcie replikacji #

Jednym z najważniejszych etapów procesu replikacji jest synchronizacja danych. Redis w nowszych wersjach wykorzystuje polecenie PSYNC, które służy do synchronizacji danych między instancjami. Polecenie to wymaga obsługi kilku komponentów, w tym przesunięcia replikacji (ang. replication offset). Jest to taki parametr, który mówi, jak daleko w aktualności danych są od siebie Master i Slave. Przy okazji zerknij do świetnego artykułu An in-depth explanation of redis master-slave replication principle, który bardzo dokładnie wyjaśnia synchronizację danych i replikację w Redisie.

Instancja główna po przetworzeniu poleceń zapisu, podczas ustanawiania replikacji, najpierw zrzuca swoją pamięć do pliku RDB (domyślnie), a następnie wysyła dane do swoich instancji podrzędnych w celu ich zsynchronizowania. Kiedy Slave zakończy odbieranie pliku RDB, ładuje go do swojej pamięci. Podczas tych kroków wszystkie polecenia zapisu do instancji głównej będą buforowane w specjalnym buforze i są wysyłane raz jeszcze do replik po ich załadowaniu.

Dobrze, w takim razie, jakie warunki muszą zostać spełnione, aby replikacja w ogóle została rozpoczęta i jaki związek z całym procesem ma wspomniane przesunięcie? Z punktu widzenia mistrza, musi on stwierdzić dostępność instancji podrzędnych. W tym celu wysyłane są pingi w ustalonych odstępach czasu. Można dostosować ten interwał, ustawiając odpowiednią wartość w parametrze repl-ping-slave-period (domyślna wartość to 10 sekund) w pliku konfiguracyjnym lub z poziomu konsoli. Natomiast z punktu widzenia repliki, wysyła ona REPLCONF ACK {offset} co sekundę, aby zgłosić swoje przesunięcie replikacji. Zarówno dla potwierdzenia PING, jak i REPLCONF ACK istnieje limit czasu określony przez limit czasu replikacji, a jego domyślną wartością jest 60 sekund. Jeśli przerwa między dwoma pingami lub REPLCONF ACK jest dłuższa niż ten limit, lub nie ma ruchu danych między instancjami główną a podrzędną w ramach takiego limitu czasu replikacji, połączenie replikacji zostanie przerwane. Tym sposobem Slave będzie musiał zainicjować kolejne żądanie replikacji.

W rzeczywistym środowisku produkcyjnym wartość repl-ping-slave-period musi być mniejsza niż wartość repl-timeout. W przeciwnym razie limit czasu replikacji zostanie osiągnięty za każdym razem, gdy będzie niewielki ruch między węzłami nadrzędnymi i podrzędnymi. Zwykle operacja blokowania może spowodować przekroczenie limitu czasu replikacji, ponieważ silnik przetwarzania poleceń serwera Redis jest jednowątkowy. Aby zapobiec przekroczeniu limitu czasu replikacji, należy postarać się unikać używania długich poleceń blokujących wykorzystując np. potoki. W większości przypadków wystarczająca jest domyślna wartość limitu równa 60 sekund.

Przesunięcie replikacji jest czymś naturalnym i pojawia się na przykład wtedy, kiedy ilość synchronizowanych danych nie jest taka sama na instancji głównej i podrzędnej. Pozwala ono ocenić, czy dane znajdujące się na każdym węźle są spójne. Może też jednak wskazywać, że instancja nadrzędna nie jest wystarczająco szybka lub występują problemy sieciowe, tj. sieć jest niskiej jakości albo jest po prostu przeciążona. Może też być kombinacją obu przypadków.

Przejdźmy może od razu do przykładów:

# Replication
role:master
connected_slaves:1
slave0:ip=192.168.10.20,port=6379,state=online,offset=121483,lag=0
slave1:ip=192.168.10.30,port=6379,state=online,offset=121483,lag=0
master_repl_offset:121483
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:121482

Interesują nas dwie wartości: przedostatni element parametru slave0 i slave1 oraz wartość parametru master_repl_offset. W tym przykładzie widzimy, że mają one taką samą wartość równą 121483, co oznacza, że obie repliki są idealnie wyrównane.

Jeżeli mielibyśmy taką sytuację:

slave0:ip=192.168.10.20,port=6379,state=online,offset=121483,lag=0
slave1:ip=192.168.10.30,port=6379,state=online,offset=121490,lag=0
master_repl_offset:121490

To replika slave0 byłaby za instancją główną o 7 bajtów i jest to różnica między wartością przesunięcia parametru master_repl_offset a wartością offsetu w wierszu slave0. Liczba przesunięć może się różnić w zależności od danego środowiska i warunków, jakie w nim panują. Idąc za tym, każda z instancji podrzędnym może mieć własną wartość przesunięcia, co jest zrozumiałe. Ostatni parametr, tj. lag określa czas w sekundach, kiedy replika odesłała „potwierdzenie” (ACK). Wskazuje on na opóźnienie replikacji, oraz że instancje podrzędne starają się nadążyć za zmianami, jakie zachodzą w węźle głównym. Może to być spowodowane zbyt dużą szybkością zmian lub zbyt dużym obciążeniem.

Podczas przełączania awaryjnego, jeśli instancja podrzędna nie jest zgodny z PSYNC, czasami poprosi o pełną synchronizację danych od mistrza. Jeśli zestaw danych jest dość duży, załadowanie całego zestawu danych i nowego elementu głównego zajmie trochę czasu, aby działał.

Powodem wzrostu wartości parametru master_repl_offset mogą być sytuacje, gdy:

W celu weryfikacji synchronizacji możesz wywołać polecenie CLIENT LIST podczas synchronizacji. Zwraca ono m.in. informacje o replikacji, wywołanej komendzie (cmd = sysc / psysc i odpowiednia flaga) czy ilości pamięci używanej przez bufor klienta.

Jeżeli chodzi o wyjście polecenia INFO, to mówiąc dokładniej, różnica między przesunięciem master_repl_offset a offsetem repliki jest ilością danych, które nie są replikowane (lub potwierdzone) w bajtach. Jeśli liczba jest duża, na przykład w przypadku nieprawidłowego wyłączenia mistrza, może nastąpić częściowa utrata danych. Parametr repl_backlog jest przeznaczony tylko dla polecenia PSYNC. Natomiast parametr repl_backlog_size to pojemność bufora (pamięci do śledzenia ostatnich zmian) przechowującego dane dla PSYNC. Ten bufor jest używany przez repliki do szybkiego nadrobienia zaległości po ponownym połączeniu zamiast przesyłania całej bazy danych. Parametr repl_backlog_histlen to ilość rzeczywistych danych w buforze i może wzrosnąć tylko do rozmiaru repl_backlog_size, więc bardzo często wartości obu parametrów są równe.

Pojawia się jeszcze jeden parametr, tzw. przesunięcie pierwszego bajtu zaległości przechowywane w repl_backlog_first_byte_offset, który jest równy maksymalnemu rozmiarowi bufora (repl_backlog_size), który to jest również równy aktualnie zapełnionym danym bufora (repl_backlog_histlen). Idąc za tym, master_repl_offset - repl_backlog_first_byte_offset = repl_backlog_size powinien oznaczać dokładny offset danych. Natomiast na intancjach Slave możesz zauważyć jeszcze jeden ciekawy parametr, tj. master_sync_in_progress, który wskazuje status synchronizacji mistrza z repliką.

Rzeczywiste opóźnienie jest więc różnicą między każdym przesunięciem na instancji podrzędnej a przesunięciem master_repl_offset. Tak więc gdyby na jednej replice przesunięcie wyniosło 616524735501 a na Masterze 616524769598 to całkowita wartość danych, których brakuje replice do osiągnięcia stanu replikacji mistrza wyniosłaby 34097 bajty (34 KB).

Wiemy już, że dane replikacji są wysyłane z instancji nadrzędnej do instancji podrzędnych asynchronicznie, a repliki okresowo odsyłają pakiety zwrotne w celu potwierdzenia otrzymanych danych. Możemy zadać pytanie, czy przesunięcie replikacji można zoptymalizować? Zerknijmy najpierw na fragment źródeł znajdujący się w pliku replication.c:

void replicationCron(void) {
...
    if (server.masterhost && server.master &&
        !(server.master->flags & CLIENT_PRE_PSYNC))
        replicationSendAck();
...
}

Powyższa metoda odpowiada za wysyłanie od czasu do czasu potwierdzeń do mistrza, który musi obsługiwać częściową synchronizację oraz przesunięcia replikacji. Natomiast wywołanie tej funkcji odbywa się z poziomu głównego pliku źródłowego serwera, tj. server.c:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
    run_with_period(1000) replicationCron();
...
}

Powoduje to ponowne łączenie się z mistrzem, wykrywanie ewentualnych błędów transferu czy rozpoczynania transferów RDB w tle. Metoda repliationCron() jest wywoływana N razy na sekundę z makrem run_with_period, które dodaje pewien interwał liczony w milisekundach. Dlatego im krótsza jest ta przerwa, tym mniejsza powinna być luka przesunięcia replikacji. Aby skrócić przesunięcie, należy zmienić wartość parametru server.hz, którego wartość pochodzi z opcji hz konfiguracji i domyślnie wynosi 10 sekund. Zgodnie z tym czas połączenia z serwerem nadrzędnym wykonywany jest co 10 sekund. Jednak przed przystąpieniem do modyfikowania tej wartości koniecznie zajrzyj do pliku konfiguracyjnego, w którym wyjaśniono, do czego może doprowadzić jej modyfikacja i jakie wartości są zalecane.

To, jak działa replikacja w Redisie zostało dokładnie opisane w rozdziale How Redis replication works oficjalnej dokumentacji dlatego bardzo zachęcam do zapoznania się z nim. W przypadku problemów, Redis dostarcza specjalny tryb, w którym mierzone są wszelkie opóźnienia. Aby z niego skorzystać, musisz przy uruchamianiu podać parametr --latency. Istnieje też potężne polecenie, które zgłasza różne problemy związane z opóźnieniami i informuje o możliwych środkach zaradczych. Jeżeli chcesz z niego skorzystać, wykonaj LATENCY DOCTOR w konsoli Redisa. Dokładne informacje o debugowaniu problemów z opóźnieniami i replikacji znajdziesz w poniższych zasobach:

Jeżeli zależy Ci na monitorowaniu tych wszystkich parametrów, to moim zdaniem idealnie nada się do tego Zabbix. Po więcej informacji zerknij tutaj.

Natomiast jeśli chcesz przeprowadzić testy replikacji czy opóźnień i potrzebujesz wygenerować dużą ilość danych, zapoznaj się z projektem redis-random-data-generator. Możesz także użyć innej metody. Jeżeli chcesz wygenerować wiele kluczy, możesz wykonać jedną z poniższych komend z poziomu konsoli. Jednak uważaj! Wykonanie jednego z poniższych skryptów doprowadzi do niedostępności Redisa i w przypadku działania Sentinela dojdzie do rozpoczęcia procesu przełączania awaryjnego, co doprowadzi w konsekwencji do nadpisania tych danych danymi znajdującymi się w nowym mistrzu. Dlatego wykonuj je na izolowanym środowisku:

127.0.0.1:6379> eval "for i=0,1000000,1 do redis.call('set', i, i) end" 0
(nil)
(10.54s)

127.0.0.1:6379> eval "for i=0,1000000,1 do local bucket=math.floor(i/500); redis.call('hset', bucket, i, i) end" 0
(nil)
(10.41s)

127.0.0.1:6379> eval "for i=0,1000000,1 do local b=math.floor(i/500); redis.call('hset', 'usernames:' ..b, i, i) end" 0
(nil)
(10.38s)