Strona główna / Artykuły / 20 wzorców Node.js, które zapobiegają przerwom w działaniu serwerów produkcyjnych

20 wzorców Node.js, które zapobiegają przerwom w działaniu serwerów produkcyjnych

Naucz się 20 praktycznych wzorców Node.js – od obsługi błędów po płynne wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim staną się konieczne ponowne uruchomienia.

4280 słów

Zawieszenie serwera prawie nigdy nie zaczyna się od jednego katastrofalnego wydarzenia.

Zazwyczaj przyczyną jest coś niewielkiego i niepozornego.

Połączenie z bazą danych nigdy nie wraca do puli dostępnych połączeń.

Użycie pamięci stopniowo rośnie, aż proces osiągnie swój limit.

Jedna trasa, która wcześniej reagowała w ciągu 200 milisekund, zaczyna potrzebować pięciu sekund.

Proces wdrażania rozrywa połączenia, które nadal były aktywne.

Zadanie asynchroniczne zawodzi, a nikt nie zauważa błędu.

Nagły wzrost ruchu zmusza każdą przychodzącą prośbę do wysłania kolejnego kosztownego zapytania do bazy danych.

Początkowo nic z tego nie wygląda alarmująco.

Następnie czasy odpowiedzi zaczynają się pogarszać.

Potem prośby zaczynają wygasać.

Następnie proces całkowicie przestaje działać.

I wtedy pojawiają się pytania:

„Dlaczego serwer nie działa?”

To, co jest tak frustrujące, to fakt, że większość z tych sytuacji dało się uniknąć.

W wielu systemach produkcyjnych powtarzające się rozwiązania dla Node.js wciąż okazują się naprawdę skuteczne, właśnie dlatego, że adresują przyczynę źródłową, a nie tylko przywracają serwer do pracy po awarii.

W tym artykule omówiono te najważniejsze.

1. Przestań pozwalać, by jedna zła prośba sparaliżowała proces

Jednym z najbardziej ryzykownych nawyków w kodzie Node.js jest pozwolenie, by rzucona wyjątek lub nierozwiązana odrzucenie obietnicy dotarły aż do samego procesu.

Weźmy ten przykład:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

Na pierwszy rzut oka wydaje się w porządku.

Ale co się stanie, jeśli połączenie z bazą danych zostanie przerwane w trakcie prośby?

A może ORM wywoła błąd?

Lub nieoczekiwana wartość wejściowa zepsuje logikę wewnątrz obsługiwanego elementu?

Bез spójnego radzenia sobie z błędami proces może znaleźć się w uszkodzonym stanie, a w zależności od konkretnego błędu oraz ustawień środowiska działania, może zostać całkowicie wyłączony.

Bardziej odpornym podejściem jest wyraźne kierowanie błędów zamiast pozwalać im uciec:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

Stamtąd wszystko kieruje się do jednego centralnego obsługiwacza błędów:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

Nie chodzi tu o ukrywanie problemów.

Chodzi o decydowanie dokładnie, gdzie trafiają błędy.

Zamiast aby każda ścieżka improwizowała własne sposoby radzenia sobie z awariami, cała aplikacja postępuje zgodnie z jedną spójną, przewidywalną ścieżką.

2. Nie traktuj ponownego uruchamiania procesu jako rozwiązania

Gdy proces Node.js przestaje działać, instynktownym rozwiązaniem jest zazwyczaj po prostu:

npm start

Lub, w przypadku zespołów używających PM2:

pm2 restart app

To pozwala szybko przywrócić usługę do działania.

To, czego nie robi, to rozwiązanie przyczyny, która spowodowała jego awarię od samego początku.

Jeśli twoja aplikacja przestaje działać co sześć godzin z powodu wycieku pamięci, jej ponowne uruchamianie według tego harmonogramu nie oznacza stabilnego systemu — to tylko tymczasowe rozwiązanie.

Narzędzia takie jak PM2, Docker, Kubernetes lub zarządzana platforma runtime mogą automatycznie ponownie uruchomić awaryjny proces, co jest naprawdę przydatne.

Na przykład z PM2:

pm2 start app.js --name api
pm2 save

Jednak automatyczne ponowne uruchomienie powinno być postrzegane jako kontrola szkód, a nie rozwiązanie podstawowego problemu.

Nadal potrzebujesz odpowiedzi na pytania takie jak:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

Ponowne uruchomienie przywraca dostępność usługi dla twoich użytkowników.

Tylko właściwa możliwość obserwacji pozwala ustalić, dlaczego usługa została najpierw utracona.

Oba elementy są konieczne.

3. Napraw problemów z wyciekami pamięci, zanim doprowadzą do awarii z powodu braku pamięci

Node.js polega na zbieraniu śmieci, ale to nie gwarantuje, że pamięć zostanie poprawnie oczyszczona sama z siebie.

Możliwe jest, że twój kod będzie przechowywał referencje do obiektów, które już tak naprawdę nie są potrzebne.

Oto proste ilustracje:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

Każda przychodząca prośba dodaje więcej danych do cache.

Ponieważ aplikacja nadal przechowuje referencje do tych wpisów, zbieracz śmieci nie ma możliwości ich zwolnienia.

Z upływem czasu wygląda to tak:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

Dokładne liczby będą się różnić w zależności od Twojej konfiguracji i ustawionych ograniczeń, ale kluczowy jest podstawowy wzorzec.

Uważaj na takie rzeczy jak:

  • Tablice globalne, które ciągle rosną
  • Caches bez ograniczenia wielkości
  • Wielkie obiekty przechowywane w pamięci
  • Słuchacze zdarzeń, które nigdy nie są odłączane
  • Timerzy, które nigdy nie są wykluczane
  • Strumienie pozostawione otwarte
  • Duże ilości danych przechowywane w zamykaniach
  • Kolejki bez górnego ograniczenia
  • Jeśli twoja aplikacja przechowuje dane w pamięci, ten cache wymaga wyraźnej polityki zarządzania.

    Naprzимер:

    Maximum entries: 10,000
    TTL: 10 minutes
    Eviction: LRU
    

    Cache bez żadnych ograniczeń tak naprawdę nie funkcjonuje jak cache.

    To problem pamięci, który wkrótce się pojawi.

    4. Ustawianie czasów wygaśnięcia dla zapytań zewnętrznych

    Wywoływanie innego usługi bez ustawienia czasu wygaśnięcia to jeden z najszybszych sposobów na sparaliżowanie twojej API.

    Pomyśl o takim wywołaniu:

    const response = await fetch("https://external-service.com/api");
    

    A co, jeśli ta zewnętrzna usługa nagle zacznie bardzo wolno odpowiadać?

    Twoje własne zapytanie może musieć czekać znacznie dłużej, niż powinno.

    A teraz wyobraź sobie 1 000 jednoczesnych użytkowników korzystających z tego samego endpointu.

    Nagle masz setki lub tysiące zapytań, które wszystkie utknęły w oczekiwaniu na usługę, która nie odpowiada.

    To nagromadzenie zadań może ostatecznie przytłoczyć również twój własny serwer.

    Zawsze implementuj wyraźne mechanizmy obsługi czasu wygaśnięcia.

    Naprzимер, używając AbortController:

    const controller = new AbortController();
    
    const timeout = setTimeout(() => {
      controller.abort();
    }, 5000);
    
    try {
      const response = await fetch(
        "https://external-service.com/api",
        {
          signal: controller.signal
        }
      );
    
      return await response.json();
    } finally {
      clearTimeout(timeout);
    }
    

    Teraz twoja aplikacja nie będzie czekać w nieskończoność.

    Zasada jest prosta:

    Nigdy nie pozwól, aby zewnętrzna zależność określała, jak długo twój serwer jest gotów czekać bez żadnych ograniczeń.

    To ma takie samo znaczenie, niezależnie od tego, czy masz do czynienia z bramką płatności, usługą dostawy e-maili, ogólnym punktem końcowym HTTP, Redis, własną warstwą bazy danych, API firmy trzeciej, czy innym wewnętrznym mikrosługą w twojej infrastrukturze.

    5. Naprawa wyczerpania połączeń z bazą danych

    API w Node.js może wyglądać całkowicie poprawnie, podczas gdy prawdziwy wąskie gardło znajduje się na warstwie połączeń z bazą danych.

    Załóżmy, że twoja aplikacja spotyka się z taką sytuacją:

    500 concurrent requests
    

    a każda z tych żądań próbuje nawiązać własne, nowe połączenie z bazą danych.

    W pewnym momencie baza danych osiąga ustalony limit połączeń.

    Potem nowe żądania zaczynają bezpowrotnie zawodzić.

    API może odpowiadać błędami takimi jak:

    Too many connections
    Connection refused
    Connection timeout
    

    Pool połączeń istnieje właśnie po to, by zapobiec nawiązywaniu przez każde przychodzące żądanie zupełnie nowego połączenia.

    Wizualnie wygląda to mniej więcej tak:

    ┌── Connection 1
                  ├── Connection 2
    API ── Pool ──┼── Connection 3 ── Database
                  ├── Connection 4
                  └── Connection 5
    

    Pool pełni rolę strażnika, ograniczając liczbę otwartych połączeń z bazą danych w danym momencie.

    Jednak prawidłowe doborowanie rozmiaru tego poolu jest równie ważne jak jego istnienie.

    Konfiguruj pool starannie.

    Ustaw go zbyt mało:

    Requests
       ↓
    Waiting for connections
       ↓
    High latency
    

    Ustaw go zbyt dużo:

    Application
       ↓
    Too many connections
       ↓
    Database overloaded
    

    Odpowiednia liczba zależy od tego, jak dużym obciążeniem może sprostać twoja baza danych, jak konwergencyjna jest twoja aplikacja, jaki wygląd mają twoje zapytania oraz jak skonfigurowana jest twoja infrastruktura.

    Niezależnie od tego, co robisz, nie reaguj na błędy połączenia poprzez ciągłe zwiększanie maksymalnej wielkości puli połączeń bez żadnych ograniczeń.

    6. Wyeliminowanie zapytań typu N+1

    Kolejnym częstym sposobem, w jaki zespoły przypadkowo obciążają swoją bazę danych, jest wzorzec zapytań typu N+1.

    Wyobraź sobie punkt końcowy, który zwraca listę wpisów:

    const posts = await Post.findAll();
    
    
    for (const post of posts) {
      post.author = await User.findByPk(post.authorId);
    }
    

    Gdy w tabeli znajduje się 1000 wpisów, sytuacja wygląda tak:

    1 query → retrieve posts
    
    1,000 queries → retrieve authors
    
    Total: 1,001 queries
    

    Gdy jest dziesięć wpisów, punkt końcowy odpowiada niemal natychmiast i nikt nie zauważa żadnych problemów.

    Następnie zbiór danych się powiększa.

    I ten sam punkt końcowy staje się nieznośnie wolny.

    Ostatecznie może zużyć tyle zasobów bazy danych, że wpłynie to negatywnie również na inne, niespowiązane z nim punkty końcowe.

    Kilka powszechnych rozwiązań:

    JOINy

    SELECT
      posts.id,
      posts.title,
      users.name
    FROM posts
    JOIN users
      ON users.id = posts.author_id;
    

    Ładowanie eager w ORM

    Większość ORM oferuje możliwość pobrania powiązanych rekordów od razu w ramach jednej kontrolowanej operacji, zamiast wysyłać osobną zapytanie dla każdego wiersza.

    Zapytania zbiorcze

    Zamiast wysyłać prośby pojedynczo, jak:

    Get user 1
    Get user 2
    Get user 3
    ...
    

    pobierz wszystkich potrzebnych użytkowników w jednym złożonym zapytaniu.

    Ile oddzielnych zapytań do bazy danych wysyła ten endpoint, gdy zwraca 10 000 rekordów?

    Jeśli uczciwa odpowiedź brzmi:

    "10 001."

    to jest to wyraźny sygnał, że coś wymaga naprawy.

    7. Dodaj paginację, zanim baza danych stanie się ogromna

    Ten fragment kodu to bomba zegarowa:

    const users = await User.findAll();
    
    res.json(users);
    

    Działa doskonale w fazie rozwoju, gdy tabela zawiera:

    37 users
    

    Sześć miesięcy później ta tabela zawiera już:

    2,400,000 users
    

    Ten jeden niezmieniony punkt końcowy może teraz:

    • Pobierać miliony wierszy z bazy danych
    • Wykorzystywać zasoby bazy danych
    • Wykorzystywać pamięć procesu Node.js
    • Serializować ogromny payload JSON
    • Wyczerpywać przepustowość
    • Znacznie wydłużać czasy odpowiedzi
    • Narażać na przekroczenie czasu oczekiwania

    Rozwiązaniem jest paginacja.

    Na przykład należy przyjąć żądanie w takim kształcie:

    GET /users?page=1&limit=50
    

    i obsłużyć je w ten sposób:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    
    const page = Number(req.query.page) || 1;
    
    const offset = (page - 1) * limit;
    

    równie ważne jest ustalenie górnej granicy tego, o co mogą prosić klienci.

    Nie pozwólaj klientowi wysyłać:

    ?limit=100000000
    

    i zmuszaj swoją API do próby wykonania zapytania o takiej wielkości.

    8. Przestań wykonywać niezależne operacje sekwencyjnie

    Załóżmy, że endpoint musi zebrać trzy dane:

    User
    Settings
    Notifications
    

    Naiwna implementacja mogłaby wyglądać tak:

    const user = await getUser();
    const settings = await getSettings();
    const notifications = await getNotifications();
    

    Jeśli każde wezwanie trwa 200 ms, oczekiwanie na nie po kolei daje łącznie mniej więcej:

    200 + 200 + 200 = 600 ms
    

    Gdy te wezwania nie zależą od siebie, wykonywaj je równocześnie:

    const [
      user,
      settings,
      notifications
    ] = await Promise.all([
      getUser(),
      getSettings(),
      getNotifications()
    ]);
    

    Teraz wszystkie trzy żądania są realizowane jednocześnie.

    Całkowity czas spada do poziomu naj wolniejszego pojedynczego wezwania:

    max(200, 200, 200) ≈ 200 ms
    

    Chodzi tu nie tylko o skrócenie czasu odpowiedzi o kilka milisekund.

    W skali większej dodatkowa opóźnienie na żądanie oznacza, że zasoby serwera są zajmowane dłużej niż to konieczne.

    Żądania, które kończą się szybciej, zwalniają zasoby równie szybko.

    Mimo to nie należy automatycznie używać Promise.all we wszystkich przypadkach.

    Jeśli jedna funkcja rzeczywiście potrzebuje wyniku innej, jak w tym przypadku:

    const user = await getUser();
    const orders = await getOrders(user.id);
    

    wtedy te funkcje muszą z natury być wykonywane sekwencyjnie.

    9. Dodanie płynnego wyłączania

    Jednym z najprostszych sposobów na spowodowanie niepotrzebnego przestoju podczas wdrażania jest nagłe zatrzymanie procesu.

    Rozważmy następującą sekwencję:

    User → POST /checkout
                  ↓
           Processing payment
                  ↓
             Deployment starts
                  ↓
              Process killed
    

    W takiej sytuacji użytkownik może zobaczyć jedynie:

    Connection reset
    

    mimo że operacja w tle mogła już częściowo zostać zakończona.

    Płynne wyłączanie rozwiązuje ten problem, pozwalając serwerowi przestać przyjmować nowe połączenia, ale jednocześnie umożliwiając naturalne zakończenie bieżących żądań.

    Minimalna wersja wygląda tak:

    const server = app.listen(PORT);
    
    process.on("SIGTERM", () => {
      console.log("SIGTERM received");
    
    server.close(() => {
        console.log("HTTP server closed");
    
        process.exit(0);
      });
    });
    

    W systemie produkcyjnym płynne wyłączanie często oznacza również zamknięcie:

    • Pooli połączeń do bazy danych
    • Połączeń z Redis
    • Konsumentów kolejki wiadomości
  • Procesy w tle
  • Otwarte połączenia WebSocket
  • Każde inne zasoby zajmowane
  • Ogólny cel sprowadza się do:

    Stop receiving new work
            ↓
    Finish current work
            ↓
    Close resources
            ↓
    Exit
    

    Proces rozruchu nie powinien przypominać sytuacji, gdy ktoś wyciągnął kabel zasilający ze ściany.

    10. Używaj kontrol zdrowia i gotowości

    Całkowicie możliwe jest, że proces Node.js nadal działa, podczas gdy aplikacja za nim faktycznie przestała funkcjonować.

    Wyobraź sobie taki stan:

    Node.js process → Running
    Database → DOWN
    API → Practically unusable
    

    Jeśli jedynym sygnałem twojego balansera obciążeń jest to, czy port TCP odpowiada, może on nadal kierować ruch do instancji, która nie jest w stanie dostarczyć niczego przydatnego.

    Koniecpunkt gotowości daje ci rzeczywisty sygnał do sprawdzenia:

    app.get("/ready", async (req, res) => {
      try {
        await db.query("SELECT 1");
    
       res.status(200).json({
          status: "ready"
        });
      } catch (error) {
        res.status(503).json({
          status: "not_ready"
        });
      }
    });
    

    To pozwala twojej infrastrukturze odróżnić dwie zupełnie różne sytuacje:

    Process is alive
    

    w przeciwieństwie do:

    Process is ready to serve traffic
    

    Różnica ta ma ogromne znaczenie podczas wdrażania nowego kodu lub przy naprawianiu awarii.

    11. Ochrona serwera przed nagłymi wzrostami ruchu

    Załóżmy, że twój serwis bez problemu radzi sobie z:

    100 requests/second
    

    Wtedy twój produkt zostaje udostępniony gdzieś szerokiej publiczności.

    Nagle stajesz przed sytuacją:

    2,000 requests/second
    

    Bez żadnych zabezpieczeń taki wzrost ruchu może całkowicie przytłoczyć API.

    Kilka popularnych sposobów na radzenie sobie z nagłymi wzrostami ruchu:

    • Ograniczenie liczby żądań, które klient może wysłać w określonym czasie
    • Umieszczenie proxy odwrotnego przed serwerami aplikacji
    • Rozdzielenie przychodzącego ruchu między kilka instancji
    • Caching odpowiedzi, aby uniknąć powtarzania pracy
    • Dostarczanie treści statycznych za pośrednictwem CDN
    • Odkładanie zadań do kolejki zamiast przetwarzania ich bezpośrednio
  • Ograniczenie liczby jednoczesnych połączeń z bazą danych
  • Automaticzne skalowanie liczby instancji wraz ze wzrostem zapotrzebowania
  • Odrzucanie żądań, które trwają zbyt długo
  • Nie musisz koniecznie korzystać z każdego punktu z tej listy.

    Jednak każdy system produkcyjny powinien mieć jasną odpowiedź na pytanie:

    Co się stanie, jeśli ruch nagle wzrośnie dziesięciokrotnie?

    12. Przeniesienie kosztownych operacji poza żądanie HTTP

    Weźmy punkt końcowy, który tworzy użytkownika i wysyła e-mail powitalny w tym samym żądaniu:

    await createUser();
    await sendWelcomeEmail();
    res.json({ success: true });
    

    Jeśli usługa e-mail potrzebuje trzech sekund na odpowiedź, to przez te trzy sekundy użytkownik musi czekać.

    Jeszcze gorsze jest to, że błąd na etapie wysyłki e-maila może spowodować niepowodzenie całego żądania, mimo że konto użytkownika zostało utworzone pomyślnie.

    Projekt oparty na kolejkach unika takiego powiązania:

    HTTP Request
         ↓
    Create User
         ↓
    Return response
         ↓
    Queue email job
         ↓
    Background Worker
         ↓
    Send Email
    

    API nie musi już czekać, aż zakończy się wolna praca w tle.

    To wzorzec jest szczególnie przydatny w sytuacjach takich jak:

    • Wysyłanie e-maili potwierdzających lub powitalnych
    • Tworzenie raportów na żądanie
    • Zmienianie rozmiaru lub transformacja przesłanych obrazów
    • Kodowanie lub transkodowanie wideo
    • Wysyłanie powiadomień typu push
    • Generowanie eksportów danych masowych
    • Obsługa przesyłanych danych przez webhooki
    • Wykonywanie złożonych obliczeń numerycznych lub analitycznych

    Zasada brzmi tak: obsługa żądań HTTP powinna wykonywać tylko minimalną ilość pracy potrzebną do przygotowania odpowiedzi.

    13. Umożliwienie bezpiecznego ponawiania zadań w tle

    Umieszczenie pracy w kolejkach rodzi nowe pytanie:

    Co się dzieje, gdy sam proces nie udaje się w trakcie wykonywania?

    Rozważmy ten proces:

    Worker receives payment notification
            ↓
    Starts processing
            ↓
    Crashes
    

    System kolejek prawdopodobnie automatycznie spróbuje wykonać zadanie ponownie.

    Zazwyczaj jest to dobra rzecz.

    Jednak jeśli zadanie nie jest idempotentne, ponowna próba może wywołać te same skutki uboczne po raz drugi.

    Naprzимер:

    Send payment
    Send payment again
    

    lub:

    Create order
    Create duplicate order
    

    Z tego powodu zadania w tle muszą być projektowane z uwzględnieniem:

    • Ponownych prób
    • Backoffu
    • Zarządzania wiadomościami błędowymi
    • Idempotentności
    • Maksymalnej liczby prób
    • Nadzoru

    Bез idempotentności mechanizm ponownych prób może przekształcić jedną niepowodzenie w kilka niepożądanych skutków ubocznych.

    14. Przestań logować informacje poufne

    Gdy próbuje się naprawić błąd w środowisku produkcyjnym, kusi nas zapisywanie wszystkiego, co jest pod ręką.

    Powszechny przykład:

    console.log(req.body);
    

    Ciało tej żądania może łatwo zawierać takie elementy jak:

    Password
    Access token
    Credit-card information
    Personal information
    

    Gdy to się stanie, system logowania przechowuje już dane wrażliwe.

    Bardziej bezpiecznym podejściem jest logowanie tylko tych pól, które faktycznie są potrzebne:

    logger.info({
      userId,
      requestId,
      endpoint: req.path
    }, "Login attempt");
    

    Unikajmy pułapki myślenia:

    "To jest tylko w logach."

    Logi często przetrwają tygodnie, miesiące, a nawet dłużej, i często są kopiowane do miejsc takich jak:

    • Systemy logowania w chmurze
    • Płatformy monitoringu
    • Systemy SIEM
    • Miejsca przechowywania kopii zapasowych
    • Paneli deweloperów

    Rozwiązywanie problemu w środowisku produkcyjnym nie powinno skutkować powstaniem odrębnego incydentu bezpieczeństwa.

    15. Dodawaj ID żądań przed ich potrzebą

    Gdy coś się zepsuje w środowisku produkcyjnym, awaria często dotyczy kilku systemów:

    Client
      ↓
    Nginx
      ↓
    Node.js API
      ↓
    Redis
      ↓
    Database
      ↓
    Payment API
    

    Branie bez sposobu na połączenie tych zdarzeń, odtworzenie tego, co się stało, może być powolnym i uciążliwym procesem.

    Rozwiązaniem jest wcześniejsze utworzenie identyfikatora żądania:

    request_id = 7f4e9c21
    

    a następnie dołączenie go do każdego istotnego wpisu z dziennika lub odpowiedzi:

    API log
    Database-related log
    Payment log
    Error response
    

    Na przykład:

    {
      "requestId": "7f4e9c21",
      "method": "POST",
      "path": "/orders",
      "status": 500
    }
    

    Gdy to zostanie wdrożone, można śledzić jedno żądanie w całym systemie.

    Dlatego gdy klient zgłasza:

    "Moje zamówienie nie powiodło się."

    można po prostu poprosić o jego identyfikator żądania zamiast przeszukiwać miliony wierszy z dziennika w poszukiwaniu dopasowania czasów.

    16. Zapobieganie tworzeniu duplikatów danych przez powtarzające się żądania

    Przerwy w działaniu sieci zdarzają się cały czas, a klienci muszą sobie z nimi radzić.

    Załóżmy taką sekwencję:

    Client
      ↓
    POST /orders
      ↓
    Server creates order
      ↓
    Response lost
    

    Klient nie ma sposobu, aby dowiedzieć się, czy żądanie rzeczywiście zakończyło się pomyślnie.

    Dlatego naturalnie próbuje ponownie:

    POST /orders
      ↓
    Server creates another order
    

    Rezultat:

    Order #1001 → original request
    Order #1002 → retry
    

    To jest dokładnie sytuacja, którą mają rozwiązywać klucze idempotencji.

    Klient dołącza nagłówek w następujący sposób:

    Idempotency-Key: 5e7b2d91-...
    

    Serwer pamięta wynik związany z tym kluczem. Jeśli ten sam klucz pojawi się ponownie, po prostu zwraca oryginalny wynik zamiast tworzyć drugi zasób.

    To ma największe znaczenie w przypadku operacji takich jak:

    Payments
    Orders
    Subscriptions
    Account creation
    Resource provisioning
    

    Każda API produkcyjna powinna być zaprojektowana z założeniem, że klienci będą próbować ponownie wysyłać żądania, czasami więcej niż raz.

    17. Używaj transakcji, gdy dane muszą pozostać spójne

    Pomyśl o takiej sekwencji:

    Create order
    Decrease inventory
    Create payment record
    

    A teraz wyobraź sobie, że jeden z tych kroków zawiedzie w trakcie wykonywania:

    Create order → SUCCESS
    Decrease inventory → SUCCESS
    Create payment record → FAIL
    

    Twoja baza danych zawiera teraz niezakończoną operację.

    Każdy raz, gdy kilka zapisów do bazy danych musi zostać wykonanych pomyślnie jako całość, umieść je w transakcji.

    Idea wygląda mniej więcej tak:

    await db.transaction(async (transaction) => {
      const order = await createOrder(transaction);
    
      await decreaseInventory(transaction);
    
      await createPaymentRecord(transaction);
    });
    

    A jeśli któryś krok wywoła błąd:

    Rollback
    

    wszystko zostanie cofnięte, zamiast pozostawić niespójne rekordy.

    Transakcje nie rozwiążą wszystkich problemów, z którymi spotykasz się w systemie rozproszonym, ale w ramach jednej bazy danych są jednymi z najbardziej niezawodnych narzędzi do utrzymania spójności danych.

    18. Nie pozwól, by jedno wolne zapytanie zniszczyło bazę danych

    Jedno źle napisane zapytanie może pochłonąć nieproporcjonalnie dużo zasobów bazy danych.

    Weźmy ten przykład:

    SELECT *
    FROM orders
    WHERE customer_id = ?
    ORDER BY created_at DESC;
    

    Jeśli customer_id nie ma indeksu, a tabela zawiera miliony wierszy, to zapytanie może zmusić bazę danych do wykonywania znacznie większej ilości pracy, niż powinno.

    Użyj

    EXPLAIN
    

    do sprawdzania istotnych zapytań.

    Zwracaj uwagę na sygnały ostrzegawcze, takie jak:

    Full table scan
    Huge number of rows examined
    Unexpected joins
    Poor index usage
    Expensive sorting
    

    Następnie opieraj optymalizację na tym, co faktycznie pokazują dane.

    Nie polegaj na domysłach.

    Zapytanie, które lokalnie zwraca wyniki w 50 milisekund, może potrzebować kilku sekund podczas wykonywania na danych w skali produkcyjnej.

    19. Ustawianie ograniczeń zasobów

    Serwer produkcyjny wymaga ograniczeń.

    Bez nich pojedynczy złośliwy lub błędnie sformatowany żądanie, albo jeden niekontrolowany proces, może zużyć wszystkie dostępne zasoby.

    Rozważ ustawienie ograniczeń dla takich elementów jak:

    Request body size
    File upload size
    Database connections
    HTTP request duration
    Cache size
    Queue size
    Worker concurrency
    Pagination size
    Memory usage
    

    Na przykład:

    app.use(express.json({
      limit: "1mb"
    }));
    

    i:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    

    Nie istnieje uniwersalna liczba, która pasowałaby we wszystkich przypadkach.

    Jednak podstawowa zasada zawsze obowiązuje.

    Każdy zasób powinien mieć rozsądny limit.

    Zostawienie czegoś bez ograniczeń rzadko jest dobrą decyzją w środowisku produkcyjnym.

    20. Monitoruj przed incydentem, a nie po nim

    Nie będziesz w stanie zapobiec każdemu awarii, jaka kiedykolwiek wystąpi.

    To, co możesz kontrolować, to szybkość wykrycia problemu oraz tempo jego naprawy.

    Jako minimum obserwuj:

    HTTP 5xx rate
    Request latency
    CPU
    Memory
    Database connections
    Database latency
    Event-loop health
    Queue depth
    External API failures
    Restart count
    

    Na przykład:

    09:00 → 0.2% errors
    09:10 → 0.3%
    09:20 → 0.5%
    09:30 → 4.8% 🚨
    

    Jeśli nikt nie obserwuje tych wartości, dowiesz się o problemie dopiero wtedy, gdy klienci zaczną skarżyć się.

    Skuteczne systemy alerting zmieniają sytuację z:

    "Klienci mówią nam, że API nie działa.

    na:

    „Wskaźnik błędów typu 5xx przekroczył ustalony próg pięć minut temu.”

    To opóźnienie w czasie reakcji naprawdę ma znaczenie.

    Wzorzec stojący za większością tych rozwiązań

    Przyglądając się wszystkim tym naprawom, widać wspólny mianownik.

    Niezawodne systemy Node.js nie działają przy założeniu, że wszystko pójdzie gładko.

    Zamiast tego są projektowane z założeniem, że:

    Requests will fail.
    Databases will become slow.
    Networks will timeout.
    Clients will retry.
    Dependencies will go offline.
    Traffic will spike.
    Deployments will restart processes.
    Developers will make mistakes.
    

    a wokół każdego z tych założeń wprowadzane są mechanizmy ochronne.

    Wynikająca z tego architektura wygląda mniej więcej tak:

                        ┌───────────────┐
                        │     Client    │
                        └───────┬───────┘
                                │
                                ▼
                        ┌───────────────┐
                        │ Reverse Proxy │
                        │ Rate Limiting │
                        └───────┬───────┘
                                │
                                ▼
                      ┌───────────────────┐
                      │    Node.js API    │
                      │                   │
                      │ Validation        │
                      │ Timeouts          │
                      │ Error Handling    │
                      │ Request IDs       │
                      └───────┬───────────┘
                              │
                 ┌────────────┼────────────┐
                 ▼            ▼            ▼
            ┌────────┐   ┌────────┐   ┌─────────┐
            │Database│   │ Redis  │   │ Queue   │
            └────────┘   └────────┘   └────┬────┘
                                          │
                                          ▼
                                   ┌──────────────┐
                                   │Worker / Jobs │
                                   └──────────────┘
    

    Każda warstwa ma jasno określone zadanie.

    Każda zewnętrzna zależność ma określoną strategię radzenia sobie z awariami.

    Każda kosztowna operacja jest ograniczona określonym limitem.

    Każda istotna akcja może być obserwowana i mierzona.

    To połączenie sprawia, że system jest odporny.

    Najważniejsza lekcja

    Największe poprawy w niezawodności nie zawsze wynikają z dodawania więcej infrastruktury.

    Czasami rozwiązaniem jest jedna zmieniona linijka kodu.

    Czasami chodzi o dodanie brakującego indeksu.

    Czasami polega to na umieszczeniu wywołania zewnętrznego API w ramach mechanizmu timeoutu.

    Czasami chodzi o przekształcenie 1 001 zapytań do bazy danych w dwa.

    Czasami polega to na umieszczeniu serii operacji zapisu w ramach transakcji.

    Czasami wystarczy upewnić się, że proces zamyka się prawidłowo.

    To, co odróżnia kruchy API od niezawodnego, zwykle wynika z serii małych decyzji inżynieryjnych.

    Żadna z nich sama w sobie nie wydaje się szczególnie imponująca.

    Jednak razem sprawiają, że zwykły dzień w środowisku produkcyjnym nie przeradza się w sytuację awaryjną.

    Zatem następnym razem, gdy twoja API w Node.js będzie działać dokładnie tak, jak oczekiwano w środowisku rozwojowym, nie poprzestawaj tylko na pytaniu:

    ">Czy to działa?"

    Zadaj zamiast tego:

    ">Co się dzieje, gdy nie działa?"

    To pytanie stanowi prawdziwy punkt wyjścia dla niezawodności w środowisku produkcyjnym.

    A najlepszym rodzajem incydentu przerywającego działanie jest taki, którego użytkownicy w ogóle nie zauważają, ponieważ naprawiłeś awarię zanim przerodziła się w przerwę w dostępności.

    Literatura pokrewna

  • Zrozumienie kluczy idempotency w Node.js endpunktach POST — Wyjaśnia, dlaczego żądania POST zawodzą w nieprzewidywalny sposób przy ponownych próbach, oraz jak klucze idempotency generowane przez klienta pozwalają API Node.js bezpiecznie radzić sobie z duplikowanymi żądaniami.
  • Wzmacnianie bezpieczeństwa Node.js: ataki DoS, injection oraz obrona contenerów — Poznaj praktyczne techniki ochrony usług Node.js przed atakami typu DoS i ReDoS w pętli zdarzeń, atakami injection oraz niebezpiecznymi contenerami za pomocą ograniczeń szybkości i narzędzia Helmet.
  • Budowanie rozwiązań do obsługi błędów w stopniu produkcyjnym w aplikacjach Node.js — Dowiedz się, jak klasyfikować błędy w Node.js, projektować spersonalizowaną hierarchię błędów, centralizować obsługę błędów asynchronicznych oraz zabezpieczać ślady wywołań dla odporności aplikacji w warunkach produkcyjnych.
  • Wykrywanie prawdziwego wąskiego gardła w wolnym punkcie końcowym Node.js — Poznaj systematyczną metodę śledzenia opóźnień w backendzie wzdłuż ścieżki żądania — od kodu Node.js po zapytania do bazy danych — przy użyciu pomiarów czasu i narzędzia EXPLAIN ANALYZE.