Головна / Статті / Поширені хибні уявлення про Node.js та бази даних, які спричиняють проблеми в продакшені

Поширені хибні уявлення про Node.js та бази даних, які спричиняють проблеми в продакшені

Дізнайтеся, чому async/await, пулінг з’єднань та ORMs не запобігають автоматично ситуаціям змагання, виснаженню з’єднань чи вставці SQL у додатках Node.js.

1442 слів

У Node.js та базах даних складні взаємини, зумовлені спільною проблемою: створити щось функціональне та запустити його настільки легко, що розробники формують припущення на основі цього першого успіху та більше ніколи їх не ставлять під сумнів. Ці припущення залишаються актуальними, поки справжній трафік, одночасні користувачі чи більші обсяги даних не виявляють розбіжностей між тим, що здавалося правдою, та тим, що насправді відбувалося. Нижче наведено п’ять найбільш шкідливих хибних уявлень разом із поясненням того, що насправді відбувається у кожному з цих випадків.

Припущення: використання async/await автоматично захищає операції з базою даних від ситуацій змагання.

Що є справді правдою: async/await просто дозволяє асинхронному коду виглядати послідовним під час його читання. Він не забезпечує гарантій атомарності для операцій з базою даних, і два окремі запити все одно можуть виконуватися один за одним у такий спосіб, що призведе до неправильного результату.

// 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]
    );
  }
}

Кожен з двох окремих запитів може виконати SELECT, побачити, що місце позначене як available, та продовжити бронювати саме це місце. Це відбувається тому, що await лише призупиняє виконання для запиту, який його викликав — він нічого не робить, щоб запобігти тому, щоб другий, незалежний запит потрапив між операцією читання та відповідною операцією запису. Справжнє рішення — це не те, що можна вирішити на рівні JavaScript; це має відбуватися на рівні бази даних, за допомогою атомарної умовної зміни даних або транзакції з належним блокуванням рядків.

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 – це не що інше, як синтаксичний елемент для роботи з обіцянками. Він ніколи не був розроблений для забезпечення безпеки конкурентності, і саме сприймання його як такого є причиною виникнення помилок подвійного бронювання.

Припущення: оскільки Node працює в одному потоці, пулінг з’єднань не є настільки критичним, як у багатопоточних мовах.

Що насправді є правдою: однопоточна природа Node стосується способу виконання JavaScript, а не того, як база даних обробляє введення/виведення. Один процес Node може легко одночасно виконувати сотні запитів до бази даних, причому кожен з них є справжньою мережевою подорожжю до реальної бази даних, яка мусить відкривати, підтримувати активним та зрештою закривати реальне з’єднання для кожного з них.

// 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];
}

Кожен виклик функції на кшталт createConnection включає процедуру TCP handshake та крок автентифікації, причому майже кожна база даних встановлює жорсткий ліміт на кількість з’єднань, які вона може прийняти одночасно. Пулінг з’єднань зменшує цей навантаження, зберігаючи певну кількість з’єднань у відкритому стані заздалегідь та видавая їх у міру потреби:

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];
}

Саме модель конкурентності Node є причиною, чому пулінг з’єднань має значення, а не причиною для його ігнорування. Один процес Node дійсно може та й намагається виконувати десятки запитів паралельно у будь-який момент часу.

Припущення: ORM повністю усуває необхідність думати про SQL-ін’єкції.

Що є справді правдою: Цей захист діє лише тоді, коли код залишається в межах власного API для створення запитів ORM. Він зникає в момент, коли створюється сирий запит або клозула WHERE формується шляхом об’єднання рядків — що трапляється частіше, ніж очікується, особливо коли запит стає настільки складним, що абстракції ORM починають здаватися обмежувальними.

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

Безпека ORM походить саме від параметризованих запитів, які виконуються під її керуванням, а не від якогось універсального захисту, що супроводжує код куди б він не йшов. Як тільки SQL створюється у вигляді звичайного рядка, цей захист вже втрачається, незалежно від того, чи є ORM над ним:

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

Правило, яке справді є обов’язковим: дані, надані користувачем, ніколи не повинні вставлятися безпосередньо до рядка запиту, незалежно від того, який шар абстракції знаходиться між кодом та сирим SQL.

Припущення: некерована помилка під час виклику бази даних автоматично потрапить у мідлвейр обробки помилок Express.

Що насправді є правдою: вбудована система обробки помилок Express фіксує синхронні винятки, які виникають у обробниках маршрутів, а також помилки, які передаються безпосередньо через next(err). Вона не фіксує автоматично відхилені обіцянки, що надходять від обробника маршруту типу async, якщо лише версія Express, яка використовується, не підтримує цю функціональність вбудовано або не налаштована для ручної обробки таких ситуацій.

// 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);
});

Якщо обіцянка цього запиту відхиляється, і немає нічого, що могло б це перехопити, результатом є некероване відхилення обіцянки — що у поточних версіях Node може призвести до зупинки всього процесу. Це паралізує всі інші запити, які обробляються у той момент, а не лише той, що спричинив помилку.

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
  }
});

Ручне обгортання кожного асинхронного маршруту швидко стає клопітким, саме тому варто на ранньому етапі проекту створити або легкий обгортку-мідлвейр, або версію Express із вбудованою підтримкою асинхронних помилок, замість того щоб сподіватися, що помилки якимось чином будуть правильно оброблятися самі по собі.

Припущення: запит, який швидко виконується під час локальної розробки, буде працювати так само добре у продакшені.

Що є справді правдою: Місцеві бази даних для розробки зазвичай є невеликими, мають поверхневу індексацію та працюють на обладнанні, яке ніхто не піддає суттєвому навантаженню. Запит, який сканує десять тисяч рядків на ноутбуці, та той самий запит, який сканує десять мільйонів рядків у продакшені, за всіма практичними міркуваннями є фактично різними запитами, навіть якщо текст SQL ідентичний.

// 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"
);

Без індексу на customer_email цей запит викликає повний огляд таблиці, і різниця між „миттєвим“ виконанням та „виконанням протягом кількох секунд“ є виключно функцією розміру таблиці — чого локальні середовища розробки майже ніколи не відображають точно. Звичка, яка насправді забезпечує захист, полягає не у іншому написанні коду, а у тестуванні з обсягами даних, схожими на продакшн-середовище, або принаймні у виконанні команди EXPLAIN на таблиці розміром з продакшн перед тим, як припускати, що те, що працює локально, має яке-небудь значення щодо її поведінки під справжньою навантаженням.

Що поєднує все це разом

Кожне з цих хибних уявлень має одну й ту саму причину: щось, здавалося, спрацювало, і цей видимий успіх перетворився на правило замість того, щоб бути визнаним окремим результатом, який просто не зазнав невдачі. Node та база даних — це дві окремі системи, які взаємодіють через мережу; кожна з них має власні гарантії та способи збою, а читабельна синтаксис JavaScript не усуває цих меж лише через те, що робить код зрозумілішим. Фактичні виправлення рідко є складними. Справжня майстерність полягає у тому, щоб спочатку визначити, які припущення варто поставити під сумнів.

Пов’язана література