Галоўная / Артыкулы / Пасуючыя уявленні пра Node.js і базах дадзеных, які спрычыняюць багі ў працоўным сераверы

Пасуючыя уявленні пра Node.js і базах дадзеных, які спрычыняюць багі ў працоўным сераверы

Дазвольце даклэ расказаць, чаму async/await, connection pooling і 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 толькі паўзае выконанне для запиту, які яго выдаў — ён нічога не робіць, каб запобiec таму, калі другі, некалякі запит зайдзе межы чытання і адпаведнага запісу. Праўныя рашэнні — гэта не тое, што можа быць рашана на рэвэлі 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, а не спосабу, яким база дадзеных обрабоцвае I/O. Адны процес 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-рукостановлення і крок аутентыкаціі, прычаму практычна кожная база дадзеных встановляе жорсткі ліміт на колькісць з’ўязкаў, якія ёна можа прыйняць адразу. Пулінг з’ўязкаў дапамагае скорачыць гэты накладны расход, зберагаючы пачаткова канектаваныя з’ўязкі і выдаваючы іх па меры патрэбы:

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 injection.

Што ўсё-такі правда: Гэты захад дзейнае толькі тады, калі код застаецца ў межах сэрвіса 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 не знімае гэтых меж, проста ўскладнюючы спрытнае разумеенне коду. Фактычныя рашэння рэдка калі бываюць складнымі. Асёледзяецца навык у тым, каб спачатку з’ясавіць, якое заўважэнне адпавядае праву на сумнев.

Супаўзвязаная літэратура