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) берут ради типизации, миграций и удобства — но они добавляют слой, в котором тоже надо разбираться.