Projektowanie systemu backendowego według Bottleneck: od skracacza URL do e-commerce
Podejście oparte na wymaganiach do projektowania backendów w Node.js: kiedy dodawać balansery obciążenia, Redis, repliki, kolejki i ograniczenia prędkości oraz ile każde z nich kosztuje.
Źródła: System Design for Backend Engineers: A Practical Guide With Real Examples
Wielu inżynierów podchodzi do projektowania systemów, tworząc diagram: balanser obciążeń, Redis, Kafka, bramkę API oraz tuzin mikrosług. Wynik wygląda imponująco, ale nic nie wyjaśnia. Czy to podczas rozmowy kwalifikacyjnej, czy w rzeczywistym produkcie, projektowanie to praktyka wyboru odpowiednich komponentów do konkretnego problemu oraz umiejętność uzasadnienia każdego z nich. Ten przewodnik pokazuje ten proces rozumowania na przykładzie dwóch konkretnych systemów – skracacza URL i małego backendu e-commerce – aby można było rozwijać architekturę krok po uzasadnionym kroku i określić kompromisy, które każdy z tych kroków wprowadza.
Zaczynaj od wymagań, a nie od technologii
Najczęstszym błędem na początku jest zaczynanie od narzędzi. Ktoś opisuje problem, a pierwsze myśli to „Redis czy nie? Kafka? Microservices?” Ten porządek jest błędny. Dopóki nie wiemy, co system musi robić i w jakiej skali, nie mamy podstaw do wyboru czegokolwiek.
Weźmy na przykład skracacz URL-ów. Zanim narysujemy choćby jeden element, potrzebujemy odpowiedzi na takie pytania:
- Ile będzie użytkowników i ile URL-ów będą tworzyć?
- Jaki wskaźnik zapytań powinien być obsługiwany w szczycie obciążenia?
- Jak długo linki pozostają ważne i czy mogą wygasnąć?
- Czy użytkownicy potrzebują analizy kliknięć?
- Ile czasu przestoju jest do przyjęcia?
- Jak szybki musi być przekierowanie?
Każda odpowiedź kształtuje projekt. Usługa obsługująca tak duży ruch:
1,000 requests/day
ma prawie nic wspólnego z tą, która musi obsłużyć mniejszy ruch:
10 million requests/secon
Pierwsza wersja mieści się wygodnie na pojedynczym małym serwerze. Druga zmusza już od samego początku do podejmowania decyzji dotyczących cache’ingu, replikacji i lokalizacji.
Zacznij od najprostszej działającej architektury
Dla małego skracacza URL pierwsza wersja może być niemal banalnie prosta:
Client
↓
Node.js API
↓
PostgreSQL
API generuje krótki kod i zwraca link w takim formacie:
https://example.com/abc123
Następnie przechowuje pojedyncze powiązanie w PostgreSQL:
short_code → original_url
Gdy przeglądarka żąda ścieżki podobnej do poniżej, API wyszukuje oryginalny URL i odpowiada przekierowaniem:
/abc123
Dla umiarkowanego obciążenia to jest kompletny, poprawny system. Nic w nim nie wymaga użycia typowych rozwiązań.
Kafka
Redis
Kubernetes
Microservices
Dodawanie takich elementów tylko dlatego, że są dostępne, powoduje wzrost kosztów operacyjnych, większą liczbę możliwych awarii oraz konieczność nauki nowych umiejętności przez każdego nowego członka zespołu. Lepiej stworzyć coś, co faktycznie działa, to zmierzyć i dopiero potem rozszerzyć te elementy, które okazują się wąskimi gardłami.
Rozszerzanie gdy jeden serwer nie wystarcza
Załóżmy, że narzędzie do skracania linków stanie się popularne i jego pojedynczy proces API zostanie przeładowany:
1 API server
Można dalej kupować większe maszyny, ale skalowanie wertykalne ma swoje ograniczenia, a pojedyncza maszyna stanowi jeden punkt awarii. Częściej stosuje się rozwiązanie polegające na uruchamianiu kilku identycznych instancji za pomocą balansera obciążenia:
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
Prośby są teraz rozkładane między kilka instancji aplikacji. To jest skalowanie horyzontalne: zamiast polegać na
1 huge server
polegasz na
multiple smaller servers
Horyzontalne skalowanie działa tylko wtedy, gdy instancje są wymienne między sobą, a to rodzi pytania, których wcześniej nie było:
- Gdzie znajduje się stan sesji, jeśli kolejna prośba użytkownika może trafić do innej instancji?
- Czy każda instancja jest rzeczywiście bezstanowa?
- Gdzie trafiają załadowane pliki, skoro dysk lokalny nie jest już współdzielony?
- Jak każda instancja uzyskuje dostęp do wspólnej infrastruktury, takiej jak baza danych?
Skalowanie jednego komponentu zwykle powoduje pojawienie się wymagań w innych miejscach; w tym przypadku stan musi zostać przeniesiony do wspólnych magazynów.
Dodaj cache tylko wtedy, gdy naprawdę masz problem z szyfrowaniem
Popularne krótkie linki są żądane wielokrotnie:
/abc123
/abc123
/abc123
/abc123
/abc123
Nie ma powodu, by każda z tych żądań kierowała się do PostgreSQL. Cache umieszczony przed bazą danych dostarcza szybko klucze z pamięci, przechodząc do bazy danych w przypadku braku danych i przechowując wynik na później:
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
Taki model z wykorzystaniem cache może znacząco zmniejszyć obciążenie bazy danych przy nietypowych zapytaniach, takich jak przekierowania linków. Stwarza on jednak nowe pytanie: jeśli cel krótkiego linku zostanie zmieniony lub usunięty, jak cache ma się o tym dowiedzieć? W takiej sytuacji potrzebna jest strategia unieważniania danych – czy to poprzez usuwanie klucza przy każdej zmianie, ustawienie czasu ważności, czy oba te rozwiązania jednocześnie. Aby dowiedzieć się więcej na temat tych opcji, zapoznaj się z naszym przewodnikiem po wzorcach i pułapkach cacheingu w Redis.
Każde ulepszenie w tym przewodniku wiąże się z takim kosztem. Umiejętność określenia tego kosztu odróżnia projekt od diagramu.
Napraw bazę danych przed dodawaniem kolejnych
W pewnym momencie PostgreSQL może okazać się wąskim gardłem. Zanim dodasz nową infrastrukturę, sprawdź, dlaczego jest wolny. Do częstych przyczyn należą:
- Nieefektywne zapytania lub połączenia
- Wzorce zapytań N+1 w aplikacji
- Zwracanie znacznie większej liczby wierszy lub kolumn niż to konieczne
- Słaba paginacja, np. duże offsety
- Nieprawidłowo skonfigurowany lub wyczerpany zasób połączeń
Często rozwiązaniem jest pojedyncze polecenie:
CREATE INDEX ...
To jest tańsze i bezpieczniejsze niż nowy komponent. Zasada, którą należy pamiętać: napraw wąskie gardło, które zmierzyłeś, zanim dodasz wokół niego architekturę.
Rеплики do odczytu dla obciążeń skupionych na odczycie
Wiele aplikacji odczytuje znacznie więcej danych, niż zapisuje, na przykład:
90% reads
10% writes
W takiej sytuacji baza danych główna może zostać przytłoczona ruchem odczytu. Jednym z rozwiązań jest dodanie replik do odczytu: baza główna przyjmuje wszystkie zapisy, a repliki przejmują część operacji odczytu.
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
Koszt opóźnienia w replikacji
W większości konfiguracji repliki są aktualizowane asynchronicznie, więc zapis trafia do bazy głównej przed tym, jak dotrze do repliki:
Write → Primary
↓
Replication
↓
Read Replica
Jeśli żądanie zapisze dane, a następnie natychmiast odczyta je z repliki, może dostrzec przestarzałe dane przez krótki czas. Dlatego aplikacja musi wiedzieć, które operacje odczytu wymagają najnowszego stanu. Powszechnym podejściem jest wysyłanie żądań typu „przeczytaj własne zapisy”, np. ładowanie profilu zaraz po jego zapisaniu, do bazy głównej, a pozostałe żądania kierowanie do replik.
Zprzesuwanie prac wolnych do zadań w tle
Wiele operacji nie musi zostać zakończonych, zanim użytkownik otrzyma odpowiedź:
- Wysyłanie e-maili
- Generowanie faktur
- Dostarczanie powiadomień
- Tworzenie raportów
- Wywoływanie wolnych usług zewnętrznych
Zamiast zmuszać użytkownika do czekania, API rejestruje to, co musi się wykonać, umieszcza zadanie w kolejce i natychmiast odpowiada:
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
Odrębny proces roboczy przejmuje zadanie i je wykonuje:
Queue
↓
Worker
↓
Process Job
W praktyce jedna kolejka może zaopatrywać procesy robocze obsługujące kilka typów zadań:
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
To utrzymuje niską opóźnioność żądań i zapewnia kontrolowane warunki dla prób ponownych oraz ograniczeń przepustowości. Stwarza to również własny zestaw problemów do rozwiązania:
- Zadanie może zostać wykonywane więcej niż raz, więc mechanizmy obsługi muszą radzić sobie z duplikatami.
Skalowalność nigdy nie jest bezkosztowa. Jeśli budujesz takie rozwiązanie w Node.js, nasze przewodnictwo po systemach zadań w tle przy użyciu BullMQ i Redis omawia te kwestie w kodzie.
Projektowanie z myślą o awariach jeszcze przed ich wystąpieniem
Ciekawe prace projektowe zaczynają się wtedy, gdy przestajemy pytać tylko o to, co się dzieje, gdy wszystko działa poprawnie, a zaczynamy pytać o to, co się dzieje, gdy coś się zepsuje.
Elastyczne radzenie sobie z awarią zależności
Załóżmy, że Redis stanie się niedostępny. Czy cała API powinna przestać działać? Jeśli Redis jest używany wyłącznie jako pamięć cache, prawdopodobnie nie powinna. API może pominąć pamięć cache i odczytywać dane z PostgreSQL – to wolniejsze, ale nadal poprawne rozwiązanie:
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
Jeśli Redis służy również do obsługi kolejki zadań lub mechanizmu ograniczania szybkości, awaria jest znacznie poważniejsza, ponieważ te funkcje nie mają prostego rozwiązania awaryjnego. Projekt powinien jasno określić rolę każdego komponentu, ponieważ to właśnie ona decyduje o zachowaniu systemu w przypadku awarii komponentu.
Ustaw czas wygaśnięcia dla każdego zewnętrznego połączenia
Załóżmy, że API wywołuje zewnętrzną usługę płatności bez ustawionego czasu wygaśnięcia:
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
Podczas oczekiwania serwer trzyma zajęte sockety, pamięć oraz zasoby potrzebne do obsługi żądań od zależności, która może nigdy nie odpowiedzieć. Przy dużej obciążeniu takie zablokowane żądania mogą wyczerpać cały proces. Dobrze zaprojektowany system ustala termin realizacji każdego połączenia:
API
↓
Payment Service
↓
5 second timeout
↓
Failure
Gdy minie termin, aplikacja musi zdecydować, co robić dalej, a właściwy wybór zależy od operacji:
- Ponowić próbę wywołania
- Zwrócić błąd do klienta
- Umieścić operację w kolejce na później
- Użyć rozwiązania awaryjnego
- Oznaczyć operację jako w toku i sprostować ją później
Ponawianie prób jest bezpieczne tylko wtedy, gdy operacje są idempotentne
Gdy próba dokonania płatności wygaśnie, ponowna próba wydaje się oczywista. Jednak wygaśnięcie terminu nie informuje nas, czy płatność się nie udała. Płatność mogła się powieść, a jedynie odpowiedź została utracona. Ślepe ponawianie prób mogłoby dwa razy naliczyć opłatę klientowi.
Rozwiązaniem jest idempotentność: należy do operacji dodać stabilny identyfikator, aby strona odbierająca mogła rozpoznać powtórzenie.
payment_id = order_123
Dostawca płatności używa tego klucza, aby stwierdzić, że żądanie zostało już obsłużone, i zwraca oryginalny wynik zamiast naliczać opłatę ponownie:
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
Dodanie prób ponownych jest możliwe przy użyciu jednej linii kodu. Zapewnienie ich bezpieczeństwa wymaga współpracy całego systemu. Nasz artykuł na temat kluczy idempotentnych w punktach końcowych POST w Node.js pokazuje, jak samodzielnie zaimplementować takie rozwiązanie po stronie odbierającej.
Ochrona systemu za pomocą ograniczeń szybkości
Załóżmy teraz, że jeden klient zaczyna wysyłać tak dużą ilość ruchu:
100,000 requests/minute
Przyjęcie całego tego ruchu mogłoby umożliwić jednemu użytkownikowi pogorszenie jakości usług dla wszystkich. Ogranicznik szybkości znajduje się przed API i odrzuca nadmiarowe żądania za pomocą kodu HTTP 429:
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
Przy kilku instancjach API liczniki muszą być współdzielone, dlatego Redis jest popularnym magazynem tła do rozproszonego ograniczania szybkości żądań: każda instancja zwiększa te same klucze.
Ograniczenia powinny odzwierciedlać funkcje poszczególnych punktów końcowych. Punkt końcowy do logowania jest celem ataków typu brute-force, więc ścisłe ograniczenie jak to jest rozsądne:
Login:
10 requests/minute
Z kolei publiczna lista produktów może tolerować znacznie większe wartości:
GET /products:
1000 requests/minute
Ustalaj ograniczenia na podstawie kosztu operacji oraz modelu zagrożeń, zamiast stosować jedną globalną wartość.
Projektowanie systemów to głównie kompromisy
Rzadko istnieje jedna idealna architektura. Większość decyzji to wybór pomiędzy opcjami, z których każda jest dobra w innych sytuacjach.
SQL kontra NoSQL
Baza danych relacyjna oferuje zaawansowane modelowanie relacji, transakcje oraz wydajne zapytania. Magazyn typu NoSQL może zapewniać inne właściwości skalowania, elastyczne schematy oraz korzyści dla określonych obciążeń pracy. Kształt Twoich danych oraz wzorce dostępu decydują o tym, który rozwiązanie jest odpowiednie.
Pamięć cache versus baza danych
Pamięć cache jest szybka, ale tymczasowa, a jej używanie dodaje złożoności w kwestii unieważniania danych. Baza danych jest trwała i pozostaje źródłem prawdy, chociaż może być wolniejsza przy ciągłym odczytywaniu danych.
Monołit versus mikrosłużby
Monołit jest prostszy, łatwiejszy do wdrożenia i debugowania we wczesnych etapach. Mikrosłużby umożliwiają niezależne wdrożenie i skalowanie oraz wyraźniejsze granice pomiędzy zespołami, ale przy tym znacznie większą złożoność systemów rozproszonych.
Żadna z opcji nie jest ogólnie poprawna – decydują wymagania.
Niech monolit modularny zastąpi wczesne mikrosługi
To wybór wymagający osobnego rozdziału, ponieważ to właśnie tutaj nadprojektowanie jest najdroższe. Nowy produkt nie musi zaczynać jako zbiór takich usług:
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
Lepszym standardem jest pojedyncza aplikacja nadająca się do wdrożenia:
Node.js Application
zorganizowana wewnętrznie w moduły o wyraźnych granicach:
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
W ten sposób mamy jeden kod, jedno wdrożenie oraz wyraźne rozdzielenie domen. Jeśli później jakiś moduł będzie wymagał skalowania lub niezależnego wdrożenia, czyste granice ułatwią jego wyodrębnienie.
Mikrosługi rzeczywiście rozwiązują prawdziwe problemy organizacyjne i związane ze skalowalnością. Jednocześnie powodują awarie sieciowe pomiędzy usługami, konieczność rozproszonego śledzenia operacji, mechanizmy odkrywania usług, bardziej skomplikowane procesy wdrażania, trudności z zachowaniem spójności danych pomiędzy różnymi usługami, a także potrzebę intensywniejszego monitorowania i zarządzania infrastrukturą. Podejmij te koszty tylko wtedy, gdy naprawdę potrzebujesz ich korzyści.
Rozwiąż każdy problem na najniższym możliwym poziomie
Rozważ prosty system trzech warstw z wolną bazą danych:
Client
↓
API
↓
Database
Istnieje szereg możliwych rozwiązań, od najtańszych po te najbardziej zakłócające normalną pracę systemu. Jeśli określona zapytanie jest wolne, optymalizuj je:
Slow query
↓
Optimize query
Jeśli operacje wyszukiwania przeszukują całą tabelę, dodaj indeks:
Missing index
↓
Add index
Jeśli te same dane są pobierane wielokrotnie, dodaj pamięć cache:
Repeated reads
↓
Add cache
Jeśli operacje odczytu stanowią większość aktywności, dodaj repliki danych:
Read-heavy workload
↓
Read replicas
Tylko w przypadku naprawdę dużych skali decyduje się na zmiany strukturalne:
Massive scale
↓
Partitioning / sharding / architectural changes
Kluczowe jest właśnie to uporządkowanie. Każdy krok jest podejmowany dopiero wtedy, gdy poprzedni już nie wystarcza, a każdy z nich dodaje więcej złożoności niż ten poprzedni.
Proces powtarzalny podczas rozmów projektowych
To samo rozumowanie odnosi się do rozmów dotyczących projektowania systemów. Solidna sekwencja wygląda tak:
- Ustalenie wymagań funkcjonalnych i niefunkcjonalnych.
- Ocena ruchu i przestrzeni przechowywania.
- Zaproponowanie najprostszej architektury, która je spełnia.
- Wyznaczenie miejsc, w których system najpierw ulegnie awarii.
- Specyficzne rozwiązanie tych wąskich gardeł.
- Rozpatrzenie scenariuszy awarii.
- Dyskusja na temat dokonywanych kompromisów.
- Uzasadnienie każdego dodanego komponentu.
Taki podejście jest o wiele bardziej przekonujące niż natychmiastowe narysowanie dwudziestu pól. Dopracowany diagram bez uzasadnienia nie stanowi dobrego projektu.
Kompletowanie całości: mały backend e-commerce
Zastosowanie tych koncepcji w sklepie internetowym daje rozsądną architekturę startową w następującym wydaniu:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
To, co czyni ją odporną na krytykę, to fakt, że każdy komponent odpowiada na konkretne pytanie:
- Redis zajmuje się cache’owaniem, danymi krótkotrwałymi, licznikami ograniczeń szybkości oraz infrastrukturą kolejek.
- PostgreSQL przechowuje trwałe dane biznesowe i zapewnia transakcje relacyjne dla zamówień oraz płatności.
- Kolejka obsługuje wolne, asynchroniczne zadania.
- Wielokrotne instancje API umożliwiają skalowanie poziome oraz redundancję.
- Balanser obciążenia rozdziela ruch pomiędzy te instancje.
Należy zauważyć, że Redis pełni tu kilka ról. Jest to efektywne, ale oznacza również, że awaria Redis wpływa jednocześnie na cacheowanie, ograniczanie szybkości żądań oraz przetwarzanie zadań, co jest dokładnie tym rodzajem zależności, który powinien zostać ujawniony w powyższej analizie awarii.
Sześć pytań do zadań przed finalizacją projektu
- Jaki problem to rozwiązuje? Jeśli nie możesz tego jasno sformułować, architektura nie jest gotowa.
- Jaka skalowalność jest wymagana? Liczba użytkowników, szybkość żądań, objętość danych, cele dotyczące przechowywania i opóźnień.
Odpowiednia złożoność jest lepsza od imponującej
Największą pułapką w nauce projektowania systemów jest przekonanie, że bardziej skomplikowana architektura musi być lepsza. Tak nie jest; jest po prostu bardziej skomplikowana. Dobry projekt dąży do odpowiedniej złożoności.
Jeśli jedna aplikacja Node.js i baza danych PostgreSQL spełniają wymagania, to jest to doskonała architektura. Gdy ruch rośnie, dodaj nowe instancje. Gdy odczyty stają się wąskim gardłem, zoptymalizuj je. Gdy przyda się cache, dodaj Redis. Gdy praca może zostać wykonana później, dodaj kolejkę. Gdy moduł naprawdę wymaga niezależnego skalowania lub wdrożenia, rozważ jego wyodrębnienie. Architektura ewoluuje wraz z produktem.
Główne wnioski
- Najpierw określ wymagania i przybliżone szacunki mocy obliczeniowej; wybory technologiczne wynikają z nich.
- Zacznij od najprostszego działającego systemu i skaluj tylko to, co według pomiarów stanowi wąskie gardło.
- Każde dodanie ma swoją cenę: wspólny stan przy skalowaniu poziomym, unieważnianie danych w cache’ach, opóźnienia u replik, duplikaty w kolejkach.
- Jasno zaplanuj radzenie sobie z awariami za pomocą mechanizmów awaryjnych, czasów wygaśnięcia, powtórzeń idempotentnych i ograniczeń szybkości.
Literatura pokrewna
- Projektowanie API w Node.js w warstwach: od grubych kontrolerów do czystej architektury — Dowiedz się, jak przeredagować API w Node.js na warstwy kontrolerów, usług i dostępu do danych, aby rozwiązać problemy z skomplikowaną logiką biznesową, niejednolitymi błędami oraz trudnościami w skalowaniu.
- Potknięcia w architekturze backendu, które kolidują z zespołami React-first — Wyjaśnia pięć typowych błędów w projektowaniu backendu występujących w projektach opartych na React – od niewłaściwego wykorzystania paradygmatu API po kruche implementacje – oraz rozwiązania architektoniczne zapewniające niezawodność na poziomie produkcyjnym.