Optymalizacja backendu Fastify dla aplikacji wysokowydajnych: Praktyczne rozwiązania
Tworzenie skalowalnych i wydajnych backendów wymaga głębokiego zrozumienia kompromisów architektonicznych. W tym artykule przyjrzymy się doświadczeniom z rozwoju aplikacji wysokowydajnej opartej na Fastify, koncentrując się na metodach minimalizacji obciążenia bazy danych, efektywnej identyfikacji użytkowników bez tradycyjnej autoryzacji oraz ochronie przed nieuczciwymi żądaniami, wykorzystując takie narzędzia jak filtry Blooma i operacje asynchroniczne.
Strategie generowania identyfikatorów i efektywne zarządzanie danymi
Podczas projektowania systemów operujących na dużej liczbie encji, takich jak użytkownicy czy obrazy, wybór formatu identyfikatora (ID) staje się krytycznie ważny. Standardowe GUID lub UUID, choć zapewniają unikalność, mogą być nadmiernie zasobożerne dla indeksowania w bazach danych i przetwarzania na backendzie. Aby zmniejszyć obciążenie, zastosowaliśmy hybrydowy format ID: 19bc19e00ab-eh5kkeh99q7. Pierwsza część to znacznik czasu (Unix timestamp) przekonwertowany na format szesnastkowy, a druga – losowy ciąg znaków. Myślnik służy wyłącznie poprawie czytelności.
Przykład funkcji generującej takie ID:
function generateID(): string {
const timestamp = Date.now().toString(16);
const randomPart = generateRandomSequence( 11 );
return timestamp + '-' + randomPart;
}
Takie podejście zapewnia „warunkowo inkrementacyjny” ID, ponieważ jego początkowa część zmienia się z każdą milisekundą, co sprzyja bardziej efektywnej pracy indeksów w bazach danych. Dodatkową zaletą jest możliwość dekodowania daty utworzenia encji bezpośrednio z ID, co pozwala zrezygnować z oddzielnego pola created_at w tabelach. Skraca to objętość przechowywanych danych i liczbę operacji zapisu, choć nakłada niewielkie ograniczenia na wygodę konstruowania zapytań według daty.
Skalowanie z wykorzystaniem filtrów Blooma i bezloginowej autoryzacji
Rozwój aplikacji zaprojektowanej dla tysięcy równoczesnych sesji i dziesiątek tysięcy obrazów wymaga podejść minimalizujących zużycie zasobów procesora i pamięci. Tradycyjne zapytania SQL do sprawdzania obecności wyświetlonych obrazów lub istniejących użytkowników stają się wąskim gardłem. W tym kontekście nieocenioną rolę odgrywają filtry Blooma.
Filtr Blooma to probabilistyczna struktura danych, która pozwala z wysoką efektywnością (np. z 99,9999% prawdopodobieństwem) określić, czy element nie znajduje się w zbiorze. Jego kluczową zaletą jest niezwykle niskie zużycie pamięci i CPU w porównaniu z klasycznymi strukturami danych podczas sprawdzania przynależności. Główne zastosowania filtrów Blooma w naszym projekcie:
- Śledzenie wyświetlonych obrazów: Dla każdego użytkownika utrzymywany jest filtr Blooma, przechowujący ID już pokazanych zdjęć. Pozwala to szybko sprawdzić, czy obraz został już zaproponowany użytkownikowi, i wykluczyć duplikaty z nowych serii.
- Identyfikacja istniejących użytkowników: Przy starcie backendu wszystkie ID zarejestrowanych użytkowników są ładowane do wspólnego filtra Blooma. Zapewnia to natychmiastową weryfikację, czy przychodzący
device_idjest już znany systemowi.
Aby zwiększyć wygodę użytkowników i uprościć moderację w sklepach z aplikacjami, zaimplementowano system identyfikacji bez jawnej autoryzacji. Po zainstalowaniu aplikacji generowany jest unikalny device_id, który następnie służy do identyfikacji użytkownika na backendzie. W celu ochrony przed generowaniem fałszywych device_id i nadużyciami stosuje się wielostopniową weryfikację:
- Walidacja schematu JSON w Fastify: Wstępna weryfikacja formatu
device_idodbywa się na poziomie Fastify z wykorzystaniem schematu JSON, który definiujeminLength,maxLengthipattern.
```javascript
fastify.post( '/backend', {
schema: {
body: {
type: 'object',
required: [ 'device_id' ],
properties: {
device_id: {
type: 'string',
minLength: 23,
maxLength: 23,
pattern: '^[a-z0-9]{11}-[a-z0-9]{11}$'
}
}
}
}
} );
```
- Filtr Blooma użytkowników: Po przejściu schematu Fastify,
device_idjest sprawdzany pod kątem obecności w filtrze Blooma istniejących użytkowników. - Weryfikacja poprawności ID:
* Data utworzenia: Pierwsza część ID, kodująca znacznik czasu, musi odpowiadać okresowi między uruchomieniem projektu a bieżącą datą.
* Entropia części losowej: Druga część ID, reprezentująca losową sekwencję, jest sprawdzana pod kątem entropii. Zapobiega to używaniu prostych, przewidywalnych ID (np. aaaaaaaaaaaa). Entropia musi być wyższa niż określony próg (np. 2).
```javascript
function getEntropyString( str: string ): number {
const frequencies = new Map();
for (const char of str) {
frequencies.set(char, (frequencies.get(char) || 0) + 1); }
return [...frequencies.values()].reduce((acc, count) => {
const p = count / str.length;
return acc - p * Math.log2(p);
},
0);
}
```
- Asynchroniczne wstawianie użytkownika: Jeśli ID zostanie uznane za nowe i poprawne, jest dodawane do filtra Blooma istniejących użytkowników, a następnie asynchronicznie wysyłane jest zapytanie
INSERTdo bazy danych. Brakawaitpozwala nie blokować przetwarzania żądania, znacząco zmniejszając obciążenie backendu. Takie podejście dopuszcza niezwykle małe prawdopodobieństwo utraty danych o nowym użytkowniku w przypadku nagłego restartu backendu, co jest akceptowalnym kompromisem dla tego typu aplikacji, ponieważ „utrata” oznacza jedynie zresetowanie historii interakcji dla tego użytkownika.
Optymalizacja interakcji z klientem i ochrona API
Efektywna interakcja między aplikacją mobilną a backendem jest krytyczna dla wydajności. Do serwowania treści statycznych, takich jak obrazy, używany jest Nginx, działający na oddzielnej domenie. Pozwala to maksymalnie odciążyć serwer Fastify, który koncentruje się na logice biznesowej, i efektywnie buforować treści.
Aplikacja mobilna pobiera obrazy seriami po 10 sztuk. ID tych obrazów są również dodawane do filtra Blooma „Wyświetlone zdjęcia” dla konkretnego użytkownika. Takie podejście minimalizuje liczbę zapytań do API: aplikacja ładuje dwie serie na starcie, a następnie żąda nowej serii po każdych 10 polubieniach. Zapewnia to ciągłość strumienia treści, dopóki użytkownik wchodzi w interakcję z bieżącą serią.
W celu ochrony API przed nadmiernym obciążeniem i nadużyciami stosuje się ograniczenie liczby żądań (rate limiting) za pomocą wtyczki rateLimit dla Fastify. W zależności od architektury, może ona używać Redis do rozproszonego przechowywania danych lub działać w pamięci aplikacji Node.js dla pojedynczych instancji.
Ważnym aspektem jest również weryfikacja reakcji użytkowników (polubień/niepolubień), które są wysyłane w partiach po 10 sztuk. Proces ten obejmuje kilka poziomów walidacji:
- Walidacja schematu Fastify: ID użytkownika, ID obrazu i sama reakcja (jako
enum'like' lub 'dislike') są sprawdzane pod kątem zgodności ze schematem JSON. - Filtr Blooma użytkowników: Potwierdzenie istnienia
user_idza pośrednictwem filtra Blooma „Istniejący użytkownicy”. - Filtr Blooma wyświetlonych zdjęć: Sprawdzenie, czy
image_idfaktycznie został pokazany bieżącemu użytkownikowi, używając jego osobistego filtra Blooma „Wyświetlone zdjęcia”. - Weryfikacja wiarygodności partii reakcji: Zaimplementowano funkcję
trustLikeInBatch10, która analizuje wzorce w partii 10 reakcji. Jeśli wszystkie reakcje są takie same (wszystkie polubienia lub wszystkie niepolubienia) lub obserwuje się zbyt częste przełączanie (np. 8 lub więcej przełączeń), partia jest uznawana za niewiarygodną i ignorowana. Pomaga to filtrować zautomatyzowane lub nieuczciwe działania.
```javascript
function trustLikeInBatch10( batch: string[] ): boolean {
if( batch.length != feedLimit ) return false;
const likes = batch.filter( v => v === 'like' ).length;
if( likes === 0 || likes === feedLimit ) return false;
const switches = batch.slice(1).reduce( ( acc, val, i ) => acc + ( val !== batch[ i ] ? 1 : 0 ), 0 );
if( switches >= 8 ) return false;
return true;
}
```
- Asynchroniczny zapis reakcji: Podobnie jak w przypadku nowych użytkowników, reakcje są asynchronicznie zapisywane w bazie danych bez oczekiwania na zakończenie operacji i bez użycia ograniczeń
FOREIGN KEY. To podejście, choć nietypowe, znacząco zmniejsza obciążenie bazy danych przy wysokiej intensywności strumieni danych, poświęcając ścisłą spójność na rzecz przepustowości. W tym kontekście potencjalna utrata kilku reakcji nie jest krytyczna dla ogólnych statystyk i systemu rekomendacji.
W celu zwiększenia bezpieczeństwa i zapobiegania exploitom związanym ze szczegółami walidacji API, błędy generowane przez Fastify w przypadku niezgodności ze schematem JSON są maskowane. Zamiast szczegółowego opisu problemu, backend zwraca uogólnioną odpowiedź. Utrudnia to atakującym inżynierię wsteczną schematów danych.
fastify.setErrorHandler((
error: FastifyError,
request: FastifyRequest,
reply: FastifyReply ) =>
{
console.error( 'setErrorHandler', error );
countErrorStatistics();
reply.code(200).send( 'Hello World!' );
});
Klient musi być przygotowany na obsługę takich uogólnionych odpowiedzi, co jest częścią ogólnego podejścia do bezpieczeństwa.
Typowanie w TypeScript dla niezawodności kodu
Użycie TypeScript znacząco zwiększa niezawodność i łatwość utrzymania kodu. W szczególności, do pracy z ustalonymi zestawami stanów tekstowych (np. statusów obrazów) można elegancko stworzyć typ z tablicy stringów. Zapewnia to ścisłe typowanie zmiennych, eliminując błędy związane z literówkami lub użyciem niedozwolonych wartości.
// Usually it's like this:
const imageStateArray1 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ];
type tpImageState1 = 'new' | 'broken' | 'gpt' | 'ready' | 'download' | 'done' | 'bad';
// or better yet:
const imageStateArray2 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ] as const;
type tpImageState2 = ( typeof imageStateArray2 )[ number ];
Drugie podejście z as const i (typeof array)[number] jest bardziej preferowane, ponieważ automatycznie wyprowadza typ literałowy z tablicy, zapewniając synchronizację między tablicą wartości a jej typem bez duplikowania.
Co ważne
- Zastosowanie filtrów Blooma znacząco zmniejsza obciążenie bazy danych podczas sprawdzania istnienia elementów, co jest krytycznie ważne dla systemów wysokowydajnych.
- Niestandardowe generowanie ID, zawierające znacznik czasu, pozwala wbudować metadane i zoptymalizować indeksowanie, a także zrezygnować z duplikujących się pól
created_at. - Asynchroniczne operacje zapisu w bazie danych i rezygnacja z
FOREIGN KEYzwiększają przepustowość backendu kosztem potencjalnej, ale akceptowalnej utraty danych w przypadku awarii w aplikacjach o wysokiej intensywności operacji. - Szczegółowa walidacja przychodzących żądań na poziomie Fastify i sprawdzanie entropii danych chronią API przed nadużyciami i niepoprawnymi danymi.
- Maskowanie błędów walidacji Fastify zwiększa bezpieczeństwo API, ukrywając wewnętrzną strukturę schematów i utrudniając inżynierię wsteczną potencjalnym atakującym.
— Editorial Team
Brak komentarzy.