Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP13 min czytania3028 słów

Ź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.
  • Agresywne próby ponownego uruchomienia w przypadku awarii zależności mogą przerodzić się w burzę prób.
  • Nieudane zadania potrzebują miejsca, gdzie mogą zostać przechowane, oraz osoby, która będzie nimi zajmować się.
  • Część procesów wymaga gwarancji określonej kolejności wykonywania.
  • Procesory powinny być idempotentne.
  • Głębokość kolejki oraz wskaźniki awarii wymagają monitorowania.
  • 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:

    1. Ustalenie wymagań funkcjonalnych i niefunkcjonalnych.
    2. Ocena ruchu i przestrzeni przechowywania.
    3. Zaproponowanie najprostszej architektury, która je spełnia.
    4. Wyznaczenie miejsc, w których system najpierw ulegnie awarii.
    5. Specyficzne rozwiązanie tych wąskich gardeł.
    6. Rozpatrzenie scenariuszy awarii.
    7. Dyskusja na temat dokonywanych kompromisów.
    8. 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.
  • Pracownicy przetwarzają zadania w tle, takie jak e-maile i faktury, niezależnie od żądań API.
  • Zewnętrzne API służące do płatności i dostawy są wywoływane z ustawieniami timeoutu oraz kluczami idempotencji.
  • 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

    1. Jaki problem to rozwiązuje? Jeśli nie możesz tego jasno sformułować, architektura nie jest gotowa.
    2. Jaka skalowalność jest wymagana? Liczba użytkowników, szybkość żądań, objętość danych, cele dotyczące przechowywania i opóźnień.
  • Co się dzieje, gdy jeden z elementów zawodzi? Baza danych, Redis, instancja API, sieć, dostawca zewnętrzny.
  • Co musi działać szybko? Czytanie, zapisywanie, wyszukiwanie lub komunikacja w czasie rzeczywistym.
  • Cóż może być asynchroniczne? E-maile, raporty, powiadomienia, analizy.
  • Jaki kompromis przyjmuję? Każda ważna decyzja powinna mieć swoje uzasadnienie.
  • 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.
  • Monolit modularny jest dobrym standardem; mikrosługi są rozwiązaniem na konkretne potrzeby, a nie punktem wyjścia.
  • Celem jest najprostszy system, który niezawodnie spełnia dzisiejsze wymagania i może się rozwijać wraz z ich zmianami.
  • Literatura pokrewna