Zapytanie, które trwa 200ms w twojej bazie danych, nadal trwa 200ms za milionowym razem, gdy ktoś je uruchamia, a każde takie uruchomienie rywalizuje o te same połączenia, ten sam I/O dysku i ten sam procesor. Redis trzyma odpowiedź w pamięci, na serwerze, który nie robi niczego innego, więc milionowy odczyt kosztuje tyle co jeden przeskok sieciowy, a nie plan zapytania.

Co Redis naprawdę robi jako cache

Redis to key-value store działający w pamięci. Wartości żyją w RAM, więc odczyty i zapisy mierzy się w czasie poniżej milisekundy zamiast dziesiątek czy setek milisekund, które kosztuje zapytanie relacyjne, gdy w grę wchodzą joiny, obciążone indeksy albo zajęty connection pool. Redis stoi obok twojej bazy danych, nie zamiast niej: aplikacja pyta najpierw Redis i sięga do bazy dopiero, gdy Redis nie ma odpowiedzi.

Wzorzec, który to opisuje, nazywa się cache-aside (też “lazy loading”) i to pierwszy, po który sięga większość aplikacji:

  1. Aplikacja pyta Redis o klucz.
  2. Cache hit — Redis ma odpowiedź, zwraca ją. Baza danych w ogóle nie widzi zapytania.
  3. Cache miss — Redis nie ma odpowiedzi. Odpytuje bazę danych, zapisuje wynik w Redis, a potem go zwraca.
1
2
3
4
5
Request → Redis? ──hit──→ zwraca wartość
            miss
          Baza danych → zapisuje w Redis → zwraca wartość

Baza danych pozostaje źródłem prawdy. Redis trzyma szybką, jednorazową kopię tych fragmentów, które czytasz często. Jeśli Redis wystartuje na pusto po restarcie, nic nie ginie: uzupełnia się sam przy kolejnej fali cache missów.

Jak uruchomić Redis w kontenerze Docker

Najszybszy sposób na instancję Redis to oficjalny obraz. Jeśli Docker jest ci nieznany, Co to jest Docker i do czego służy omawia podstawy, zanim pójdziesz dalej.

1
docker run --name redis-cache -d -p 6379:6379 redis:8

To uruchamia Redis 8 w tle, publikując port 6379 na twojej maszynie. Połącz się przez redis-cli, zainstalowane lokalnie albo odpalone z drugiego kontenera w tej samej sieci:

1
docker run -it --rm --network container:redis-cache redis:8 redis-cli
1
2
3
4
5
6
127.0.0.1:6379> SET user:42:name "Elena"
OK
127.0.0.1:6379> GET user:42:name
"Elena"
127.0.0.1:6379> TTL user:42:name
(integer) -1

TTL zwracające -1 oznacza, że klucz nie ma czasu wygaśnięcia: zostaje, dopóki go nie usuniesz albo Redis go nie wyeksmituje pod presją pamięci. Dla cache prawie zawsze chcesz mieć czas wygaśnięcia, żeby nieaktualne dane same się zestarzały.

1
2
3
4
127.0.0.1:6379> SET user:42:name "Elena" EX 300
OK
127.0.0.1:6379> TTL user:42:name
(integer) 297

EX 300 sprawia, że klucz wygasa za 300 sekund. PX robi to samo w milisekundach, a EXPIRE user:42:name 300 ustawia TTL na kluczu, który już istnieje.

Dla wszystkiego, co wykracza poza szybkie ręczne sprawdzenie, uruchom Redis przez Compose razem z resztą swojego stacku — zobacz Czym Jest Docker Compose i Jak Go Używać:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
services:
  redis:
    image: redis:8
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --save 60 1 --maxmemory 256mb --maxmemory-policy allkeys-lru

  api:
    build: .
    depends_on:
      - redis
    environment:
      - REDIS_URL=redis://redis:6379

volumes:
  redis-data:

api dociera do Redis pod hostname redis, bo Compose stawia oba serwisy w tej samej sieci zdefiniowanej przez użytkownika — to mechanizm opisany w Co to Jest Sieć Docker i Jak Jej Używać. Wolumin redis-data ma mniejsze znaczenie dla czystego cache niż dla głównej bazy danych (zobacz Co to jest wolumin Docker i jak go używać): utrata cache oznacza tylko, że kolejne odczyty stają się cache missami. Mimo to zostaw wolumin, jeśli szybszy restart z rozgrzanym cache ma znaczenie dla twojego budżetu opóźnień.

Jak napisać logikę cache-aside w swojej aplikacji

Redis daje ci komendy; twoja aplikacja i tak musi wywołać je we właściwej kolejności. Owinięcie odczytu z bazy w cache-aside wygląda tak:

1
2
3
4
5
6
7
8
9
def get_user(user_id):
    key = f"user:{user_id}"
    cached = redis.get(key)
    if cached is not None:
        return json.loads(cached)

    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.set(key, json.dumps(user), ex=300)
    return user

Dwa szczegóły odpowiadają tu za większość roboty:

  • TTL (ex=300) nie jest opcjonalny. Bez niego wiersz, który zmienia się w bazie danych, wiecznie serwuje starą wartość z Redis. Ustaw TTL zależnie od tego, jak długo nieaktualne dane są akceptowalne dla tego konkretnego klucza, a nie na jedną globalną liczbę dla całej aplikacji.
  • json.dumps/json.loads. Redis przechowuje stringi, plus garść bogatszych typów jak hashe i sorted sety — nigdy obiektów twojego języka. Wszystko, co strukturalne, trzeba zserializować na wejściu i sparsować na wyjściu.

Gdy wiersz w bazie danych się zmienia, usuń klucz zamiast czekać na TTL:

1
2
3
def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.delete(f"user:{user_id}")

Kolejny odczyt po aktualizacji to gwarantowany cache miss, który uzupełnia Redis świeżym wierszem. Cache-aside nigdy nie próbuje utrzymywać cache zsynchronizowanego w czasie rzeczywistym. Sprawia, że nieaktualne wpisy znikają szybko — przez TTL albo przez jawne usunięcie przy zapisie.

Eviction: co się dzieje, gdy Redis wyczerpie pamięć

Redis trzyma wszystko w RAM, więc pamięć jest skończona w sposób, w jaki dysk zwykle nie jest. Ustaw twardy limit przez maxmemory i powiedz Redis, co robić po jego osiągnięciu, przez maxmemory-policy:

PolitykaZachowanieDo czego
noevictionOdrzuca nowe zapisy po zapełnieniu, zwraca błądDatastore, który nie może po cichu tracić danych
allkeys-lruUsuwa najdawniej używany klucz, dowolnyCzysty cache — sensowny wybór domyślny
volatile-lruUsuwa najdawniej używany klucz spośród tych z TTLMieszanie kluczy cache i kluczy stałych w jednej instancji
volatile-ttlUsuwa najpierw klucz z najkrótszym pozostałym TTLRate limitery, krótkotrwałe tokeny

Dla dedykowanej instancji cache allkeys-lru prawie zawsze jest właściwa: każdy klucz jest w niej jednorazowy z definicji, więc pozwól Redis wyrzucać to, czego dotykałeś najdawniej. noeviction to zła polityka dla cache, bo zamienia “Redis jest pełny” w błędy aplikacji zamiast po cichu odrzucać zimne wpisy.

1
2
CONFIG SET maxmemory 256mb
CONFIG SET maxmemory-policy allkeys-lru

Ustaw obie wartości w komendzie startowej, tak jak w pliku Compose powyżej, a nie tylko w runtime. Restart kontenera resetuje CONFIG SET ustawiony tylko w runtime do domyślnych wartości Redis (maxmemory 0, czyli bez limitu, ograniczonego tylko przez hosta).

Kiedy cache Redis to zły wybór

Cache ukrywa wolną ścieżkę; nie naprawia jej. Cztery sytuacje, w których sięgnięcie po Redis jako pierwsze rozwiązanie tylko pogarsza sprawę:

  • Dane i tak zmieniają się przy każdym odczycie. Ticker giełdowy na żywo albo licznik zwiększany przy każdym żądaniu nic nie zyskuje na cache unieważnianym tak szybko, jak jest wypełniany. Dodałeś przeskok sieciowy bez żadnego hit rate.
  • Potrzebujesz silnej spójności. Cache-aside jest z założenia eventually consistent: między zapisem a kolejnym cache missem istnieje okno, w którym wciąż może zostać zwrócona nieaktualna wartość. Dla salda konta czy stanu magazynowego, gdzie to okno powoduje realną szkodę, nie cachuj tego albo cachuj z TTL rzędu sekund i świadomie zaakceptuj kompromis.
  • Prawdziwe wąskie gardło jest gdzie indziej. Jeśli twoja baza danych jest wolna z powodu brakującego indeksu albo zapytania N+1, napraw to najpierw. Cachowanie źle napisanego zapytania tylko przenosi tę samą złą odpowiedź do pamięci szybciej.
  • Thundering herd przy wygaśnięciu. Gdy gorący klucz wygasa, wszystkie równoległe żądania mogą naraz nie trafić w cache i jednocześnie zaatakować bazę danych. Użyj SET key value NX EX 30 jako krótkotrwałej blokady, trzymanej przez to żądanie, które uzupełnia cache, żeby pozostałe czekały na nie zamiast walić w bazę.

Od czego zacząć z cache Redis

Postaw kontener redis:8 przed swoim najwolniejszym, najczęściej powtarzanym odczytem. Owiń go w cache-aside z TTL, który potrafisz uzasadnić, a nie z okrągłą liczbą wybraną na chybił trafił, i ustaw maxmemory-policy allkeys-lru, żeby pełny cache degradował zamiast rzucać błędy. Potem zmierz hit rate. Write-through caching, unieważnianie przez pub/sub i Redis Streams kosztują dodatkową złożoność i żadne z nich nie jest warte zachodu, dopóki prosty setup cache-aside nie pokaże ci dokładnie, gdzie mu brakuje.

Powiązane artykuły