Strona główna / Artykuły / Powszechne błędne wyobrażenia na temat Node.js i baz danych, które powodują błędy w produkcji

Powszechne błędne wyobrażenia na temat Node.js i baz danych, które powodują błędy w produkcji

Dowiedz się, dlaczego async/await, connection pooling oraz ORMs nie zapobiegają automatycznie sytuacjom wyścigu zegarów, wyczerpaniu połączeń czy iniekcjom SQL w aplikacjach Node.js.

1442 słów

Node.js i bazy danych mają trudne relacje, oparte na wspólnym pułapku: uruchomienie czegoś funkcjonalnego jest tak proste, że deweloperzy wychodzą z założeń wynikających z tego pierwszego sukcesu i już nigdy ich nie kwestionują. Te założenia sprawdzają się dobrze, dopóki rzeczywisty ruch, jednoczesni użytkownicy lub większe ilości danych nie ujawnią różnicy między tym, co wydawało się prawdą, a tym, co faktycznie się działo. Poniżej przedstawiono pięć najbardziej szkodliwych błędnych przekonań wraz z wyjaśnieniem, co tak naprawdę dzieje się w każdym z tych przypadków.

Założenie: użycie async/await automatycznie chroni operacje bazodanowe przed warunkami wyścigu.

Co jest naprawdę prawdą: async/await pozwala jedynie kodowi asynchronicznemu wyglądać sekwencyjnie podczas czytania. Nie gwarantuje on atomowości operacji w bazie danych, a dwa oddzielne żądania mogą nadal się przeplatać w taki sposób, że doprowadzi to do błędnego wyniku.

// looks sequential, isn't safe under concurrency
async function reserveSeat(eventId, seatNumber) {
  const seat = await db.query(
    "SELECT status FROM seats WHERE event_id = $1 AND number = $2",
    [eventId, seatNumber]
  );
  if (seat.status === "available") {
    await db.query(
      "UPDATE seats SET status = 'reserved' WHERE event_id = $1 AND number = $2",
      [eventId, seatNumber]
    );
  }
}

Dwa oddzielne żądania mogą każde wykonać polecenie SELECT, każde zobaczyć miejsce oznaczone jako available i każde z nich spróbować zarezerwować to samo miejsce. Dzieje się tak, ponieważ await tylko wstrzymuje wykonywanie żądania, które je wywołało — nie robi nic, by zapobiec temu, by drugie, niezwiązane żądanie wtrąciło się pomiędzy operację odczytu a odpowiadającą jej operacją zapisu. Prawdziwe rozwiązanie nie może polegać na poprawce na poziomie JavaScriptu; musi zostać zrealizowane na warstwie bazy danych, przy użyciu albo atomowej aktualizacji warunkowej, albo transakcji z odpowiednim blokowaniem wierszy.

async function reserveSeat(eventId, seatNumber) {
  const result = await db.query(
    `UPDATE seats SET status = 'reserved'
     WHERE event_id = $1 AND number = $2 AND status = 'available'
     RETURNING *`,
    [eventId, seatNumber]
  );
  return result.rowCount > 0; // false means someone beat you to it
}

async/await to nic więcej niż „cukier składniowy” służący do pracy z obietnicami. Nigdy nie został zaprojektowany tak, by gwarantować bezpieczeństwo konkurencyjności, a traktowanie go w ten sposób jest dokładnie przyczyną występowania błędów podwójnego rezerwowania.

Założenie: ponieważ Node działa na pojedynczej nitce, zarządzanie zasobami połączeń nie jest tak kluczowe jak w językach wieloniciowych.

Prawda: pojedynczona nita w Node dotyczy sposobu wykonywania kodu JavaScript, a nie sposobu, w jaki baza danych obsługuje operacje wejścia/wyjścia. Jeden proces Node może łatwo jednocześnie wykonywać setki zapytań do bazy danych, a każde z nich to rzeczywista transmisja danych przez sieć do prawdziwej bazy danych, która musi otworzyć, utrzymać aktywne oraz ostatecznie zamknąć rzeczywiste połączenie dla każdego z nich.

// a new connection per query, under real traffic, this collapses fast
async function getUser(id) {
  const conn = await mysql.createConnection(config);
  const [rows] = await conn.query("SELECT * FROM users WHERE id = ?", [id]);
  await conn.end();
  return rows[0];
}

Każde wywołanie funkcji takiej jak createConnection obejmuje procedurę TCP handshake oraz krok autoryzacji, a praktycznie każda baza danych ustala sztywny limit liczby połączeń, które może przyjąć jednocześnie. Pooling połączeń zmniejsza ten obciążenie poprzez utrzymywanie z góry otwartych połączeń w grupach i ich udostępnianie w momencie potrzeby:

const pool = mysql.createPool({ ...config, connectionLimit: 10 });
async function getUser(id) {
  const [rows] = await pool.query("SELECT * FROM users WHERE id = ?", [id]);
  return rows[0];
}

Model równoległości w Node jest właśnie powodem, dla którego pooling ma znaczenie, a nie powodem do jego pomijania. Jedyny proces Node rzeczywiście może i faktycznie próbuje wykonywać dziesiątki zapytań równolegle w dowolnym momencie.

Założenie: ORM eliminuje całkowitą potrzebę myślenia o iniekcjach SQL.

Co jest naprawdę prawdą: Ochrona ta obowiązuje tylko wtedy, gdy kod pozostaje w ramach własnego interfejsu API do budowania zapytań ORM. Znika natychmiast, gdy zostanie napisane surowe zapytanie lub klauzula WHERE zostanie utworzona poprzez łączenie ciągów znaków — co zdarza się częściej, niż się spodziewa, szczególnie gdy zapytanie staje się na tyle złożone, że abstrakcje ORM zaczynają wydawać się ograniczające.

// still vulnerable, ORM or not
const results = await sequelize.query(
  `SELECT * FROM users WHERE email = '${userInput}'`
);

Bezpieczeństwo ORM wynika konkretnie z parametryzowanych zapytań działających pod jego osłoną, a nie z jakiegoś uniwersalnego zabezpieczenia, które towarzyszy kodowi wszędzie, dokąd się udaje. Gdy tylko SQL jest tworzony jako zwykły ciąg znaków, ochrona ta zostaje porzucona, niezależnie od tego, czy nad nim znajduje się ORM:

const results = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email: userInput }, type: QueryTypes.SELECT }
);

Zasada, która rzeczywiście obowiązuje: dane wprowadzone przez użytkownika nigdy nie powinny być bezpośrednio łączone z ciągiem zapytania, niezależnie od tego, jaka warstwa abstrakcji znajduje się pomiędzy kodem a surowym SQL.

Założenie: błąd nieobsłużony podczas wywołania bazy danych automatycznie trafi do mechanizmu obsługi błędów w Express.

To, co faktycznie jest prawdą: wbudowana obsługa błędów w Express przechwytuje wyjątki synchroniczne rzucane wewnątrz obsługowników tras, a także błędy przekazywane wyraźnie za pomocą next(err). Nie przechwytuje automatycznie odrzuconych obietnic pochodzących z obsługownika trasy async, chyba że wersja Express obsługuje to natywnie lub została skonfigurowana do ręcznej obsługi takich przypadków.

// on many Express setups, a rejected promise here never reaches your error handler
app.get("/users/:id", async (req, res) => {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
  res.json(user);
});

Jeśli obietnica związana z tą zapytaniem zostanie odrzucona, a nie ma nic, co mogłoby to złapać, rezultatem jest nierozwiązane odrzucenie obietnicy — co w aktualnych wersjach Node może spowodować awarię całego procesu. To powoduje przerwanie przetwarzania wszystkich innych zapytań w tym momencie, a nie tylko tego, które wywołało błąd.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
    res.json(user);
  } catch (err) {
    next(err); // now Express's error handler actually sees it
  }
});

Ręczne otaczanie każdej asynchronicznej ścieżki staje się szybko uciążliwe, dlatego właśnie warto już na wczesnym etapie projektu zainstalować albo lekki wrapper middleware, albo wersję Express z wbudowanym obsługą błędów asynchronicznych, zamiast zakładać, że błędy same w jakiś sposób będą kierowane we właściwy sposób.

Założenie: zapytanie, które szybko działa podczas lokalnego rozwoju, będzie działać równie dobrze w środowisku produkcyjnym.

Co jest naprawdę prawdą: Bazy danych używane do rozwoju lokalnego zazwyczaj są małe, maksymalnie pobieżnie indeksowane i działają na sprzęcie, który wcale nie jest obciążany. Zapytanie przeszukujące dziesięć tysięcy wierszy na laptopie oraz to samo zapytanie przeszukujące dziesięć milionów wierszy w środowisku produkcyjnym to w praktyce zupełnie różne zapytania, mimo że ich tekst w SQL jest identyczny.

// fine with 500 test rows, a real problem with 5 million production rows
const orders = await db.query(
  "SELECT * FROM orders WHERE customer_email = $1 ORDER BY created_at DESC"
);

Branie pod uwagę braku indeksu dla customer_email, ta zapytanie powoduje przeszukanie całej tabeli, a różnica pomiędzy „natychmiastowym” działaniem a „czekaniem kilku sekund” zależy wyłącznie od rozmiaru tabeli — co środowiska lokalnego rozwoju prawie nigdy nie odzwierciedlają w sposób dokładny. Praktyką, która rzeczywiście zapewnia ochronę, nie jest zmiana sposobu pisania kodu, lecz testowanie z danymi o wielkości zbliżonej do tych w środowisku produkcyjnym lub przynajmniej uruchamianie polecenia EXPLAIN na tabeli o rozmiarze produkcyjnym, zanim założy się, że to, co działa lokalnie, ma jakiekolwiek znaczenie dotyczące zachowania w rzeczywistym obciążeniu.

Co łączy te pięć kwestii

Każde z tych błędnych przekonań ma swoje źródło w tej samej przyczynie: coś wydawało się działać, a ten pozorny sukces został przekształcony w zasadę, zamiast być uznany za pojedynczy wynik, który po prostu nie zawiodł. Node i baza danych to dwa odrębne systemy komunikujące się przez sieć, każdy z własnymi gwarancjami oraz sposobami awarii, a czytelna składnia JavaScript nie znosi tych granic tylko dlatego, że ułatwia zrozumienie kodu. Rzeczywiste naprawy rzadko są skomplikowane. Prawdziwa umiejętność polega na rozpoznaniu, które założenie należy najpierw zakwestionować.

Literatura pokrewna