Kamil Owczarek
Opublikowano

6x więcej pojemności cache: kompresja Redisa na serwerze 1 GB

Autorzy

Problem: kończy się miejsce w cache

Nasza platforma e-commerce działa na instancji Redisa o rozmiarze 1 GB na DigitalOcean (15 dolarów miesięcznie). Na początku było dobrze, ale wraz ze wzrostem ruchu i liczby cache'owanych endpointów zaczęliśmy uderzać w limity pamięci.

Objawy były jednoznaczne:

  • Zużycie pamięci Redisa stale powyżej 90%
  • Zbyt częste eksmisje LRU
  • Strony krytyczne dla SEO wypadały z cache, zanim ktokolwiek zdążył je ponownie odpytać
  • Rosnąca liczba zimnych cache missów w szczycie ruchu

Mieliśmy dwie opcje: przejść na instancję 2 GB (30 dolarów miesięcznie) albo zoptymalizować to, co mamy.

Wybrałem optymalizację. Oto jak osiągnęliśmy 6x większą pojemność cache bez zmiany infrastruktury.

Jak wygląda struktura cache

Nasza warstwa cache'owania opakowuje endpointy API w Nuxt 3 własnym handlerem, który zapisuje odpowiedzi w Redisie. Struktura wyglądała tak:

type CacheEntry<T> = {
    data: T;           // The actual API response
    createdAt: number; // Timestamp for cache invalidation
};

Każda cache'owana odpowiedź trafia do tej koperty i jest zapisywana w Redisie jako JSON. Problem? JSON jest rozwlekły, zwłaszcza gdy Twoje odpowiedzi zawierają:

  • Tłumaczenia wielojęzyczne (11 języków: PL, EN, DE, UK, RU, HU, RO, FR, SL, ES, IT)
  • Zagnieżdżone dane produktów z powtarzającymi się nazwami pól
  • Duże tablice podobnych obiektów

Typowa odpowiedź z listingiem produktów mogła wyglądać tak (w uproszczeniu):

{
  "data": {
    "products": [
      {
        "id": 12345,
        "code": "QUATRO-860",
        "nameCore": {
          "translations": {
            "pl": "Zlewozmywak granitowy",
            "en": "Granite sink",
            "de": "Granitspüle",
            "uk": "Гранітна мийка"
          }
        },
        "price": { "gross": 1299.00, "net": 1056.10 },
        "mainPhotoFullPath": "/products/quatro-860/main.jpg"
      }
    ],
    "count": 1847,
    "pagesCount": 93
  },
  "createdAt": 1735405200000
}

Pomnóż to przez 50 produktów na stronę, 93 strony i 11 wariantów językowych... sam widzisz, o co chodzi.

Dlaczego kompresja tak dobrze działa na JSON-ie

JSON ma cechy, które czynią go wyjątkowo podatnym na kompresję:

  1. Powtarzające się klucze: każdy obiekt w tablicy ma te same klucze ("id", "code", "nameCore")
  2. Powtarzające się wartości: obiekty z tłumaczeniami mają tę samą strukturę we wszystkich wpisach
  3. Dużo tekstu: nazwy i opisy produktów to tekst w języku naturalnym
  4. Przewidywalne wzorce: sama składnia JSON-a ({, }, ", :) jest powtarzalna

Kompresja gzip wykorzystuje te wzorce przy pomocy algorytmu DEFLATE, który łączy LZ77 (wyszukiwanie powtarzających się sekwencji) z kodowaniem Huffmana (krótsze kody dla częstych wzorców).

Na naszych danych JSON konsekwentnie widzimy współczynniki kompresji rzędu 70-85%.

Implementacja

Oto kompletna warstwa kompresji, którą dołożyliśmy do naszego cache'owego utilsa:

import { defineEventHandler as originalDefineEventHandler, type H3Event } from 'h3';
import { getCurrentUTC } from '~/utils/timezone.utils';
// eslint-disable-next-line import/default
import pako from 'pako';

const TTL = 259200; // 3 days in seconds
const FULL_RESET_FLAG_KEY = 'cache:flag:full';
const PARTIAL_RESET_FLAG_KEY = 'cache:flag:partial';

type CacheEntry<T> = {
    data: T;
    createdAt: number;
};

function compressData<T>(entry: CacheEntry<T>): string {
    const json = JSON.stringify(entry);
    const compressed = pako.gzip(json, { level: 6 });
    return Buffer.from(compressed).toString('base64');
}

function decompressData<T>(compressed: string): CacheEntry<T> {
    const buffer = Buffer.from(compressed, 'base64');
    const decompressed = pako.ungzip(buffer, { to: 'string' });
    return JSON.parse(decompressed) as CacheEntry<T>;
}

Dlaczego akurat takie wybory?

Poziom kompresji 6: to złoty środek między współczynnikiem kompresji a zużyciem CPU. Poziom 9 daje minimalnie lepszą kompresję, ale trwa wyraźnie dłużej. Poziom 1 jest szybki, ale zostawia sporo rozmiaru na stole.

Level 1: Fast, ~60% compression
Level 6: Balanced, ~78% compression  <-- Our choice
Level 9: Slow, ~82% compression

Kodowanie base64: Redis wydajnie przechowuje stringi, a base64 gwarantuje, że nasze binarne wyjście z gzipa da się bezpiecznie serializować. Około 33% narzutu z base64 jest z nawiązką rekompensowane przez ponad 75% kompresji.

Biblioteka pako: to standardowa implementacja zlib w JavaScripcie, używana przez JSZip i wiele innych bibliotek. Sprawdzona na produkcji, szybka i już obecna w naszym drzewie zależności.

Integracja z handlerem cache

Handler cache wymagał minimalnych zmian. Oto sedno przepływu:

export const defineCustomCacheEventHandler = <T>(
    handler: (event: H3Event) => T | Promise<T>,
) => {
    return originalDefineEventHandler(async (event: H3Event) => {
        // Skip cache in development
        if (process.env.NODE_ENV === 'development') {
            return await handler(event);
        }

        const url = getRequestURL(event);
        const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
        const storage = useStorage('cache');

        // Fetch cache entry and invalidation flags in parallel
        const [compressed, fullResetRaw, partialResetRaw] = await Promise.all([
            storage.getItem<string>(cacheKey),
            storage.getItem<number>(FULL_RESET_FLAG_KEY),
            storage.getItem<number>(PARTIAL_RESET_FLAG_KEY),
        ]);

        // Decompress with error handling
        let cachedEntry: CacheEntry<T> | null = null;
        if (compressed) {
            try {
                cachedEntry = decompressData<T>(compressed);
            } catch (error) {
                console.error(`[Cache] Decompression failed - ${cacheKey}:`, error);
                // Treat as cache miss, will regenerate
            }
        }

        // ... cache hit/miss logic ...

        // On cache miss: generate, compress, store
        const result = await handler(event);

        try {
            const entry: CacheEntry<T> = {
                data: result,
                createdAt: getCurrentUTC().getTime()
            };
            const compressedData = compressData(entry);
            await storage.setItem(cacheKey, compressedData, { ttl: TTL });
        } catch (error) {
            console.error(`[Cache] Compression/storage failed - ${cacheKey}:`, error);
            // Continue to return result even if caching failed
        }

        return result;
    });
};

Obsługa błędów jest krytyczna

Zwróć uwagę na bloki try-catch wokół kompresji i dekompresji. To niezbędne, bo:

  1. Uszkodzone wpisy cache: jeśli dane w Redisie się zepsują, dekompresja poleci
  2. Okres migracji: stare, nieskompresowane wpisy nie dadzą się zdekompresować (traktujemy to jako cache miss)
  3. Brak pamięci: kompresja bardzo dużych obiektów teoretycznie może się nie powieść
  4. Łagodna degradacja: API zawsze zwraca dane, nawet jeśli cache'owanie zawiedzie

Kluczowy wniosek: nieudana operacja na cache nigdy nie powinna wywrócić API. Użytkownicy dostają swoje dane; my po prostu logujemy błąd i lecimy dalej.

Wyniki

Po wdrożeniu na produkcję i odczekaniu, aż cache odbuduje się na skompresowanych wpisach:

MetrykaPrzedPoPoprawa
Średni rozmiar wpisu~200 KB~40 KB80% mniej
Wpisy w 1 GB~5 000~25 0005x więcej
Zużycie pamięci92%45%spadek o 47%
Eksmisje LRU na godzinę~150~1093% mniej

Rozmiary wpisów w praktyce

Oto co zaobserwowaliśmy dla różnych typów endpointów:

EndpointNieskompresowanySkompresowanyWspółczynnik
/api/public/products (50 pozycji)180 KB28 KB84%
/api/public/global (nawigacja)520 KB78 KB85%
/api/public/search (złożony)340 KB45 KB87%
/api/public/categories/*/details/*8 KB1,2 KB85%
/api/public/landings/* (ciężki)2,1 MB310 KB85%

Największą wygraną były landing page'e. Zawierają 15 kolekcji produktowych po ponad 50 produktów każda, wszystkie z tłumaczeniami na wiele języków. Skompresowanie odpowiedzi ważącej 2 MB do 310 KB robi robotę.

Narzut wydajnościowy

Kompresja nie jest darmowa. Oto co zmierzyliśmy:

OperacjaCzasWpływ
Kompresja (JSON 200 KB)3-5 msDoliczone do zapisów do cache
Dekompresja (gzip 40 KB)1-2 msDoliczone do odczytów z cache
Kodowanie/dekodowanie base64~1 msPomijalne

Przy cache hicie dokładamy około 2 ms dekompresji. Ale weź pod uwagę, że:

  • Zapytanie do bazy o te same dane zajmuje 50-200 ms
  • Latencja sieciowa do bazy to 5-10 ms
  • Skompresowana odpowiedź szybciej wędruje z Redisa na serwer aplikacji

Efekt netto: cache hity nadal są dramatycznie szybsze od cache missów, a mniejsza presja na pamięć oznacza mniej cache missów w ogóle.

Type safety na całej długości

Jedną z obaw przy kompresji jest utrata type safety w TypeScripcie. Nasze podejście zachowuje ją w całości:

// The generic type T flows through the entire chain
function compressData<T>(entry: CacheEntry<T>): string
function decompressData<T>(compressed: string): CacheEntry<T>

// Usage in the handler maintains type inference
const cachedEntry = decompressData<T>(compressed);
// cachedEntry.data is typed as T

const entry: CacheEntry<T> = { data: result, createdAt: ... };
const compressedData = compressData(entry);
// compressedData is string, entry.data is T

Warstwa kompresji jest przezroczysta dla reszty aplikacji. Handlery zwracają otypowane dane, konsumenci dostają otypowane dane, a kompresja i dekompresja dzieją się niewidocznie pomiędzy.

Obsługa migracji

Kiedy wdrażaliśmy tę zmianę, mieliśmy w cache tysiące istniejących wpisów w starym, nieskompresowanym formacie. Do wyboru były dwie drogi:

  1. Wyczyścić Redisa i zacząć od zera: proste, ale wywołuje lawinę cache missów
  2. Wsteczna kompatybilność: wykrywać i obsługiwać oba formaty

Wybraliśmy opcję 1 (czyszczenie), ale przy naszej obsłudze błędów opcja 2 zadziałałaby automatycznie:

// Old format: { data: {...}, createdAt: 123 } (object)
// New format: "H4sIAAAAAAAA..." (base64 gzip string)

// When decompressData receives an object instead of string,
// Buffer.from() throws, catch block fires, treated as cache miss

Stare wpisy naturalnie wygasają (3-dniowy TTL) albo zostają nadpisane skompresowanymi wersjami przy kolejnym żądaniu. Logowanie błędów pomogło nam monitorować postęp migracji.

Wskazówki do konfiguracji Redisa

Żeby maksymalnie wykorzystać kompresję, upewnij się, że Twój Redis jest poprawnie skonfigurowany:

1. Polityka eksmisji

# Check current policy
CONFIG GET maxmemory-policy

# Set LRU eviction (recommended)
CONFIG SET maxmemory-policy allkeys-lru

Przy allkeys-lru Redis usuwa klucze najdawniej używane, kiedy zabraknie pamięci. Dzięki temu gorące (często odwiedzane) strony zostają w cache, a wylatują rzadko używane kombinacje filtrów.

2. Limit pamięci

Na zarządzanym Redisie od DigitalOcean pamięć jest z góry ustalona. Ale jeśli hostujesz samodzielnie:

# Leave ~10% headroom for Redis overhead
CONFIG SET maxmemory 950mb  # for a 1GB instance

3. Monitoruj pamięć

# Check memory usage
INFO memory

# Key metrics to watch:
# - used_memory_human
# - used_memory_peak_human
# - evicted_keys

Kiedy NIE kompresować

Kompresja nie zawsze jest właściwym wyborem. Odpuść ją, gdy:

  1. Dane są już skompresowane: obrazy, PDF-y, wstępnie skompresowane assety
  2. Wpisy są malutkie: wpisy poniżej 1 KB mogą po zakodowaniu base64 wręcz urosnąć
  3. Wąskim gardłem jest CPU: jeśli już jesteś CPU-bound, kompresja dołoży obciążenia
  4. Wymagana jest ultraniska latencja: wymagania poniżej milisekundy mogą nie znieść 2 ms narzutu

W naszym przypadku (odpowiedzi API średnio 50-500 KB, ograniczona pamięć, tolerancja latencji rzędu 50 ms i więcej) kompresja to czysta wygrana.

Szerszy obraz: kompresja kontra inne optymalizacje

Zanim wdrożyliśmy kompresję, rozważyliśmy alternatywy:

Alternatywa 1: większy Redis (30 dolarów miesięcznie zamiast 15)

  • Plus: zero zmian w kodzie
  • Minus: nie rozwiązuje źródłowej nieefektywności, tylko ją odsuwa w czasie
  • Minus: podwaja koszt infrastruktury

Alternatywa 2: cache'owanie mniejszej liczby endpointów

  • Plus: zmniejsza rozmiar cache
  • Minus: większe obciążenie bazy, wolniejsze odpowiedzi
  • Minus: strony ważne dla SEO muszą być cache'owane

Alternatywa 3: krótsze TTL-e

  • Plus: wpisy szybciej wygasają, mniej zużytej pamięci
  • Minus: więcej cache missów, większe obciążenie bazy
  • Minus: stale-while-revalidate traci na skuteczności

Alternatywa 4: cache per język

  • Plus: cache'ujesz tylko język potrzebny użytkownikowi (90% mniej na wpis)
  • Minus: 11x więcej kluczy cache
  • Minus: wymaga refaktoru kluczy cache

Wybraliśmy kompresję, bo:

  • Jest przezroczysta dla aplikacji
  • Zachowuje całe dotychczasowe zachowanie
  • Daje 5-6x większą pojemność
  • Wymaga minimalnych zmian w kodzie
  • Nie wymaga zmian w infrastrukturze

Kod: kompletna implementacja

Dla porządku, oto kompletny util cache'owy razem z kompresją:

import { defineEventHandler as originalDefineEventHandler, type H3Event } from 'h3';
import type { InternalApi } from 'nitropack';
import { getServerSession } from '#auth';
import { getCurrentUTC } from '~/utils/timezone.utils';
// eslint-disable-next-line import/default
import pako from 'pako';

const TTL = 259200;
const FULL_RESET_FLAG_KEY = 'cache:flag:full';
const PARTIAL_RESET_FLAG_KEY = 'cache:flag:partial';

type CacheEntry<T> = {
    data: T;
    createdAt: number;
};

function compressData<T>(entry: CacheEntry<T>): string {
    const json = JSON.stringify(entry);
    const compressed = pako.gzip(json, { level: 6 });
    return Buffer.from(compressed).toString('base64');
}

function decompressData<T>(compressed: string): CacheEntry<T> {
    const buffer = Buffer.from(compressed, 'base64');
    const decompressed = pako.ungzip(buffer, { to: 'string' });
    return JSON.parse(decompressed) as CacheEntry<T>;
}

export const defineCustomCacheEventHandler = <T>(
    handler: (event: H3Event) => T | Promise<T>,
) => {
    return originalDefineEventHandler(async (event: H3Event) => {
        if (process.env.NODE_ENV === 'development') {
            return await handler(event);
        }

        const url = getRequestURL(event);
        const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
        const storage = useStorage('cache');

        const [compressed, fullResetRaw, partialResetRaw] = await Promise.all([
            storage.getItem<string>(cacheKey),
            storage.getItem<number>(FULL_RESET_FLAG_KEY),
            storage.getItem<number>(PARTIAL_RESET_FLAG_KEY),
        ]);

        let cachedEntry: CacheEntry<T> | null = null;
        if (compressed) {
            try {
                cachedEntry = decompressData<T>(compressed);
            } catch (error) {
                console.error(`[Cache] Decompression failed - ${cacheKey}:`, error);
            }
        }

        const fullReset = fullResetRaw || 0;
        const partialReset = partialResetRaw || 0;
        const entryCreatedAt = cachedEntry?.createdAt || 0;

        // Fresh cache hit
        if (cachedEntry && entryCreatedAt > fullReset && entryCreatedAt > partialReset) {
            return cachedEntry.data;
        }

        const session = await getServerSession(event);

        // Stale-while-revalidate for non-authenticated users
        if (!session?.user && cachedEntry && entryCreatedAt > fullReset) {
            const waitUntil = event.context.waitUntil || event.context.cloudflare?.ctx?.waitUntil;
            const revalidate = async () => {
                try {
                    const result = await handler(event);
                    const entry: CacheEntry<T> = { data: result, createdAt: getCurrentUTC().getTime() };
                    const compressedData = compressData(entry);
                    await storage.setItem(cacheKey, compressedData, { ttl: TTL });
                } catch (error) {
                    console.error(`[Cache] Revalidation failed - ${cacheKey}:`, error);
                }
            };

            if (waitUntil) {
                waitUntil(revalidate());
            } else {
                revalidate().catch(() => {});
            }

            return cachedEntry.data;
        }

        // Cache miss - generate fresh data
        const result = await handler(event);

        try {
            const entry: CacheEntry<T> = { data: result, createdAt: getCurrentUTC().getTime() };
            const compressedData = compressData(entry);
            await storage.setItem(cacheKey, compressedData, { ttl: TTL });
        } catch (error) {
            console.error(`[Cache] Compression/storage failed - ${cacheKey}:`, error);
        }

        return result;
    });
};

type PublicKeys<T> = {
    [K in keyof T]: K extends `${string}public${string}` ? K : never;
}[keyof T];

export const cleanCachePartially = async (_partsOfKeyRaw: PublicKeys<InternalApi>[]) => {
    const storage = useStorage('cache');
    await storage.setItem(PARTIAL_RESET_FLAG_KEY, getCurrentUTC().getTime());
};

export const cleanCacheCompletely = async () => {
    const storage = useStorage('cache');
    await storage.setItem(FULL_RESET_FLAG_KEY, getCurrentUTC().getTime());
};

Najważniejsze wnioski

  1. JSON kompresuje się rewelacyjnie (70-85% dla typowych odpowiedzi API)
  2. Narzut kompresji jest minimalny (~2-5 ms) w porównaniu z kosztem cache missa (~100-500 ms)
  3. Obsługa błędów jest niezbędna — nigdy nie pozwól, by awaria cache wywróciła API
  4. Type safety da się zachować dzięki generykom
  5. Zacznij od poziomu kompresji 6 — to złoty środek dla większości zastosowań
  6. Skonfiguruj politykę eksmisji Redisa, żeby maksymalnie wykorzystać cache

Na koniec

Ta optymalizacja zajęła jakieś 2 godziny wdrożenia i testów. Efekt? Nasz Redis za 15 dolarów miesięcznie obsługuje ruch, który wymagałby instancji za 30 dolarów. Ważniejsze jednak, że strony krytyczne dla SEO zostają w cache, a użytkownicy dostają szybsze odpowiedzi.

Czasem najlepsza optymalizacja infrastruktury to nie dokładanie zasobów, tylko lepsze wykorzystanie tych, które już masz.

Podejście z kompresją jest szczególnie wartościowe przy:

  • Aplikacjach wielojęzycznych (mnóstwo powtarzalnych struktur z tłumaczeniami)
  • Katalogach e-commerce (powtarzające się, podobne obiekty produktów)
  • Dowolnym API zwracającym tablice podobnych obiektów
  • Środowiskach z ograniczoną pamięcią (serverless, małe VPS-y)

Jeśli uderzasz w limity pamięci Redisa, a Twoje cache'owane dane to JSON, spróbuj kompresji. Implementacja jest prosta, korzyści natychmiastowe, a ryzyko przy porządnej obsłudze błędów minimalne.