- 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ę:
- Powtarzające się klucze: każdy obiekt w tablicy ma te same klucze (
"id","code","nameCore") - Powtarzające się wartości: obiekty z tłumaczeniami mają tę samą strukturę we wszystkich wpisach
- Dużo tekstu: nazwy i opisy produktów to tekst w języku naturalnym
- 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:
- Uszkodzone wpisy cache: jeśli dane w Redisie się zepsują, dekompresja poleci
- Okres migracji: stare, nieskompresowane wpisy nie dadzą się zdekompresować (traktujemy to jako cache miss)
- Brak pamięci: kompresja bardzo dużych obiektów teoretycznie może się nie powieść
- Ł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:
| Metryka | Przed | Po | Poprawa |
|---|---|---|---|
| Średni rozmiar wpisu | ~200 KB | ~40 KB | 80% mniej |
| Wpisy w 1 GB | ~5 000 | ~25 000 | 5x więcej |
| Zużycie pamięci | 92% | 45% | spadek o 47% |
| Eksmisje LRU na godzinę | ~150 | ~10 | 93% mniej |
Rozmiary wpisów w praktyce
Oto co zaobserwowaliśmy dla różnych typów endpointów:
| Endpoint | Nieskompresowany | Skompresowany | Współczynnik |
|---|---|---|---|
/api/public/products (50 pozycji) | 180 KB | 28 KB | 84% |
/api/public/global (nawigacja) | 520 KB | 78 KB | 85% |
/api/public/search (złożony) | 340 KB | 45 KB | 87% |
/api/public/categories/*/details/* | 8 KB | 1,2 KB | 85% |
/api/public/landings/* (ciężki) | 2,1 MB | 310 KB | 85% |
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:
| Operacja | Czas | Wpływ |
|---|---|---|
| Kompresja (JSON 200 KB) | 3-5 ms | Doliczone do zapisów do cache |
| Dekompresja (gzip 40 KB) | 1-2 ms | Doliczone do odczytów z cache |
| Kodowanie/dekodowanie base64 | ~1 ms | Pomijalne |
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:
- Wyczyścić Redisa i zacząć od zera: proste, ale wywołuje lawinę cache missów
- 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:
- Dane są już skompresowane: obrazy, PDF-y, wstępnie skompresowane assety
- Wpisy są malutkie: wpisy poniżej 1 KB mogą po zakodowaniu base64 wręcz urosnąć
- Wąskim gardłem jest CPU: jeśli już jesteś CPU-bound, kompresja dołoży obciążenia
- 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
- JSON kompresuje się rewelacyjnie (70-85% dla typowych odpowiedzi API)
- Narzut kompresji jest minimalny (~2-5 ms) w porównaniu z kosztem cache missa (~100-500 ms)
- Obsługa błędów jest niezbędna — nigdy nie pozwól, by awaria cache wywróciła API
- Type safety da się zachować dzięki generykom
- Zacznij od poziomu kompresji 6 — to złoty środek dla większości zastosowań
- 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.