Главная / Статьи / Распространённые заблуждения относительно Node.js и баз данных, приводящие к ошибкам в производственной среде

Распространённые заблуждения относительно Node.js и баз данных, приводящие к ошибкам в производственной среде

Узнайте, почему async/await, пулы подключений и ORM не предотвращают автоматически ситуации соревнования за ресурсы, исчерпание подключений или внедрение 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 и этап аутентификации, причем практически все базы данных устанавливают строгий лимит на количество одновременно принимаемых соединений. Пулы соединений снижают эту нагрузку, предварительно сохраняя несколько открытых соединений и выдавая их по мере необходимости:

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 не устраняет этого разделения лишь потому, что делает код более понятным. Фактические решения редко бывают сложными. Настоящий навык заключается в умении определить, какое предположение стоит поставить под сомнение в первую очередь.

Связанная литература