MySQL и Node.js: подключение, пул и защита от инъекций

Содержание

Подключить MySQL к Node — задача на пять строк. Проблемы начинаются на шестой: когда соединение обрывается, когда запросов становится много и когда в запрос попадает то, что ввёл пользователь.

О проверке кода. Примеры в этой статье не выполнялись на живом сервере MySQL — в отличие от MongoDB и Redis, где мы поднимали настоящую базу и приводили фактический вывод. Здесь код приведён по документации mysql2. Мы предпочли сказать это прямо, а не ставить бейдж «проверено» там, где проверки не было.

Драйвер: mysql2, а не mysql

Исторически пакетов два, и выбор сегодня однозначен:

Пакет Статус
mysql оригинальный, практически не развивается
mysql2 выбор по умолчанию: совместим по API, быстрее, промисы и подготовленные выражения
npm install mysql2

Ключевая деталь при импорте — промис-версия лежит по отдельному пути:

import mysql from 'mysql2/promise';   // ← так
// import mysql from 'mysql2';        // ← колбэчный API

Тот же приём, что у node:fs/promises: один пакет, два интерфейса, разные пути импорта.

Пул вместо одиночного соединения

Классический пример из туториалов — createConnection. Для реального приложения это ошибка.

Одно соединение против пула

createConnection

Годится для скрипта, не для сервера

  • Запросы выполняются строго по очереди
  • Обрыв = приложение сломано до перезапуска
  • MySQL закрывает простаивающие соединения сам
  • Переподключение пишете руками

createPool

Выбор для приложения

  • Несколько соединений, запросы параллельно
  • Битое соединение заменяется само
  • Лимит connectionLimit бережёт сервер
  • Соединение берётся и возвращается автоматически
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: 'shop',
  connectionLimit: 10,
  waitForConnections: true,
});

const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [42]);

Три вещи в этом коде важнее, чем кажется:

  • Пароль из process.env, а не из кода. Строка подключения в репозитории — классическая утечка; про переменные окружения — в разборе объекта process.
  • connectionLimit: 10 — не «чем больше, тем лучше». У MySQL есть свой лимит max_connections, и десять инстансов приложения по 100 соединений его исчерпают.
  • Деструктуризация [rows] — драйвер возвращает массив: результат и метаданные полей. Забыть скобки — классическая причина «почему у меня в rows какой-то странный объект».

Параметризованные запросы: единственная защита

Самая опасная строка кода в любом приложении с SQL:

// ❌ НИКОГДА так не делайте
const [rows] = await pool.query(
  `SELECT * FROM users WHERE email = '${email}'`
);

Если email пришёл от пользователя и равен ' OR '1'='1, запрос превратится в SELECT * FROM users WHERE email = '' OR '1'='1' — и вернёт всю таблицу. Значение вроде '; DROP TABLE users; -- доведёт мысль до конца.

// ✅ правильно: параметры отдельно
const [rows] = await pool.query(
  'SELECT * FROM users WHERE email = ?',
  [email]
);

Почему это работает. Знак ? — не подстановка строки. Драйвер отправляет текст запроса и данные раздельно, и сервер уже разобрал структуру SQL до того, как увидел значения. Данные физически не могут стать частью команды.

Ровно та же логика, что у execFile против exec: там аргументы уходят массивом мимо оболочки, здесь параметры уходят мимо парсера SQL. Инъекция невозможна не потому, что данные почистили, а потому, что интерпретировать их некому.

⚠️ Оговорка про ?: он подставляет значения, но не имена таблиц и колонок. Для идентификаторов есть ??:

await pool.query('SELECT * FROM ?? WHERE id = ?', ['users', 42]);

Подставлять имя колонки из пользовательского ввода — всё равно плохая идея. Проверяйте по белому списку.

query или execute

await pool.query('SELECT * FROM users WHERE id = ?', [42]);    // обычный запрос
await pool.execute('SELECT * FROM users WHERE id = ?', [42]);  // подготовленное выражение

execute использует prepared statement: сервер разбирает SQL один раз, кэширует план и потом подставляет параметры. Для запроса, который выполняется тысячи раз, это заметная экономия.

Оба одинаково безопасны — защита от инъекций у них общая. query проще, execute быстрее на повторах.

Обрывы — это норма

Главное, чего не ждут новички: MySQL закрывает простаивающие соединения сам. Параметр wait_timeout по умолчанию — 8 часов, но на управляемых базах и за балансировщиком его обычно снижают до минут.

Одиночное соединение после такого обрыва мертво: следующий запрос упадёт с PROTOCOL_CONNECTION_LOST, и переподключение придётся писать самому.

Пул решает это сам: он замечает битое соединение и заменяет его. Именно это и есть главный аргумент за пул — не скорость, а то, что приложение переживает ночь без трафика.

Ошибки различайте по err.code, как и в fs:

Код Значение
ER_ACCESS_DENIED_ERROR неверный логин или пароль
ER_DUP_ENTRY нарушен уникальный индекс
PROTOCOL_CONNECTION_LOST соединение закрыто сервером
ECONNREFUSED база не отвечает или не тот порт

ER_DUP_ENTRY здесь — аналог кода 11000 в MongoDB: та же ситуация, другое имя.

Нужен ли ORM

Не обязательно. mysql2 закрывает большинство задач, и SQL вы пишете сами — что часто плюс, а не минус.

ORM (Prisma, Drizzle) берут ради типизации, миграций и удобства сложных выборок. Но помните урок Waterline из Sails: абстракция над базой протекает ровно там, где вам нужен нетривиальный запрос. Prisma и Drizzle честнее — они не притворяются, что MongoDB это тоже таблица.

Что было на этой странице в 2016 году

Первая версия вышла 28 ноября 2016 года и учила подключаться так:

const mysql = require('mysql');
const connection = mysql.createConnection({ /* ... */ });

connection.connect();
connection.query('SELECT * FROM users', function (err, rows) {
  if (err) throw err;
  console.log(rows);
});
connection.end();

Три приметы времени в одном фрагменте: пакет mysql (сегодня mysql2), колбэки вместо промисов и createConnection вместо пула — то самое одиночное соединение, которое сломается при первом же обрыве.

Что из той версии осталось верным: сама идея «подключиться, выполнить запрос, разобрать результат» и предупреждение про экранирование. Правда, тогда экранирование подавалось как достаточная мера — сегодня понятно, что параметризованный запрос не «удобнее», а безопаснее по устройству: ручное экранирование работает, только пока вы ни разу про него не забыли.


Смежные темы: MongoDB — когда нужна не реляционная база; child_process — та же логика защиты от инъекций; объект process — где хранить пароль от базы. Полный список — в справочнике по Node.js.

Частые вопросы

Какой драйвер выбрать — `mysql` или `mysql2`?
mysql2. Оригинальный mysql практически не развивается, а mysql2 совместим с ним по API, быстрее, поддерживает промисы из коробки и подготовленные выражения.
Зачем пул, если приложение одно?
Потому что запросов много, а соединение одно. Одиночное соединение выполняет запросы строго по очереди и превращается в узкое место; к тому же при обрыве оно не восстанавливается само.
Чем `query` отличается от `execute`?
query отправляет строку запроса целиком. execute использует подготовленное выражение: сервер разбирает SQL один раз и кэширует план. Для повторяющихся запросов execute быстрее и так же безопасен.
Экранирование через `escape()` защищает от инъекций?
Формально да, но это ручная защита: забыли один раз — получили дыру. Параметризованные запросы через ? безопасны по устройству, потому что данные вообще не попадают в текст SQL.
Нужен ли ORM для MySQL в Node?
Не обязательно. Драйвера mysql2 достаточно для большинства задач. ORM (Prisma, Drizzle) берут ради типизации, миграций и удобства — но они добавляют слой, в котором тоже надо разбираться.