Node.js & NestJS
Устройство рантайма и архитектура NestJS. Event loop, асинхронность, потоки, DI и жизненный цикл запроса, транзакции и очереди, паттерны надёжной обработки платежей. Для разработчика, который приходит из PHP/Laravel — с параллелями между фреймворками.
Node.js и NestJS разбираются вместе: сначала рантайм, потом фреймворк поверх него. Большая часть ошибок в Node растёт из непонимания event loop, поэтому он идёт первым.
Если вы пришли из PHP, самое важное отличие Node не в синтаксисе и не в пакетах. Оно в том, кто живёт между запросами. В привычной связке Nginx и PHP-FPM на каждый запрос берётся отдельный процесс-воркер: он поднимает фреймворк, собирает ответ, отдаёт его и умирает. Всё, что он успел положить в переменные, исчезает вместе с ним — именно поэтому в Laravel вы не задумываетесь, не утечёт ли память и не увидит ли следующий посетитель чужие данные. Каждый запрос начинается с чистого листа.
Node устроен наоборот. Процесс запускается один раз и дальше работает неделями, обслуживая все запросы подряд. Ваш файл выполняется не на каждый запрос, а однократно при старте — он лишь расставляет обработчики и говорит: «вот что делать, когда придёт событие». После этого управление уходит в бесконечный цикл, который принимает события и вызывает ваши функции. Код внутри этих функций выполняется в одном-единственном потоке.
Из этих двух абзацев вырастает всё остальное в разделе, поэтому к ним стоит возвращаться. Разберём, что именно следует.
| Следствие | Почему так | Где об этом подробно |
|---|---|---|
| Нельзя делать долгие вычисления прямо в обработчике | Поток один. Пока он занят вашим циклом, он не обслуживает никого — ни один из тысяч открытых запросов | Event loop, Thread pool |
| Ожидание сети и диска не тормозит сервер | Ждёт не ваш поток, а операционная система. Поток в это время обслуживает других | Ввод-вывод и libuv |
| Данные в переменных переживают запрос | Процесс не умирает. Это даёт бесплатный кэш в памяти — и утечки, если складывать туда лишнее | Утечки памяти |
| Процесс нужно гасить аккуратно | В момент остановки внутри него могут быть недообработанные запросы, которых в PHP просто не бывает | Graceful shutdown |
| Ошибка способна уронить весь сервер | Необработанное исключение валит процесс, а с ним — все текущие соединения, а не один запрос | Ошибки в async-коде |
Команда node app.js запускает процесс операционной системы, внутри которого поднимаются две части рантайма: V8 — тот самый движок из Chrome, он исполняет ваш JavaScript, и libuv — библиотека на C, которая умеет работать с сетью, файлами и таймерами, не блокируя поток. Дальше Node выполняет служебную подготовку, загружает ваш файл и проходит его сверху вниз ровно один раз.
Вот место, где у людей из мира PHP чаще всего ломается модель. Строка app.listen(3000) не начинает обрабатывать запросы и не блокирует программу — она лишь регистрирует намерение и немедленно возвращает управление. Когда ваш файл дочитан до конца, Node переходит в цикл ожидания событий, и только там начинается настоящая работа. Поэтому код, написанный «после сервера», выполнится сразу же, а не после первого запроса.
Отсюда же ответ на вопрос «почему мой скрипт не завершается». Пока у libuv есть хотя бы одно незакрытое дело — слушающий сокет, живой интервал, незавершённый запрос — цикл продолжает работать. Как только их не остаётся, процесс выходит самостоятельно, без всякого exit.
В Laravel путь запроса понятен и неизменен: public/index.php поднимает приложение через bootstrap/app.php, создаётся ядро, и Kernel::handle($request) прогоняет запрос через конвейер middleware к контроллеру. Этот путь повторяется для каждого запроса с самого начала.
В Node ядра в этом смысле нет. Роль диспетчера играет цикл ожидания событий: он получает событие «пришёл HTTP-запрос» и вызывает ту функцию, которую вы зарегистрировали при старте. Разница в том, что конвейер Laravel собирается заново на каждый запрос, а цикл поднимается однажды и живёт, пока жив процесс.
| Laravel на PHP-FPM | Node.js | |
|---|---|---|
| Точка входа | public/index.php — заново на каждый запрос | app.js — один раз при старте |
| Сборка приложения | bootstrap/app.php: контейнер, провайдеры | Ваш код верхнего уровня, один проход |
| Кто вызывает ваш код | Ядро: Kernel::handle() | Цикл ожидания событий |
| Состояние между запросами | Не сохраняется | Сохраняется |
| Цена утечки памяти | Почти нулевая: процесс всё равно умрёт | Накапливается неделями, пока не придёт OOM |
| Вопрос | Короткий ответ |
|---|---|
| Чем модель Node отличается от PHP-FPM? | Один долгоживущий процесс с одним потоком JS против процесса на каждый запрос. Отсюда общее состояние, кэш в памяти, утечки и необходимость аккуратного завершения |
| Почему Node называют однопоточным, если он держит тысячи соединений? | В одном потоке выполняется только ваш JavaScript. Само ожидание сети и диска происходит вне его, поэтому поток не простаивает |
| Что будет, если в обработчике посчитать что-то тяжёлое? | Встанут все запросы процесса, а не только текущий |
| Почему процесс не завершается после выполнения скрипта? | У libuv остались незакрытые дела: сервер, таймер или незавершённая операция |
Node.js — это среда выполнения (runtime) для JavaScript вне браузера. Язык остался тот же самый — JavaScript. Изменилось окружение: вместо DOM, window и запросов к серверу появились файловая система, сеть, процессы и всё, что нужно серверной программе.
| Что это | Пример | Node.js |
|---|---|---|
| Язык | JavaScript, PHP, Python | Нет — язык здесь JavaScript |
| Среда выполнения | PHP-FPM, JVM, CPython | Да, это она |
| Фреймворк | Laravel, NestJS, Express | Нет — они пишутся поверх Node |
| Сильная сторона | Слабая сторона |
|---|---|
| Много одновременных соединений при лёгкой обработке: API, чаты, стриминг, прокси | Тяжёлые вычисления: обработка изображений, криптография в цикле, разбор гигабайтных файлов |
| Реалтайм: WebSocket, long polling, SSE | Задачи, где нужен настоящий параллелизм на одном процессе |
| Один язык на фронте и бэке, общие типы через TypeScript | Долгие синхронные операции — они блокируют всех |
| Аспект | JavaScript — язык | Node.js — среда выполнения |
|---|---|---|
| Что это | Язык программирования, стандарт ECMAScript | Платформа, исполняющая JS вне браузера |
| Где работает | В браузере | На сервере или локально, отдельным процессом |
| Движок | Движок конкретного браузера: V8, SpiderMonkey, JavaScriptCore | V8 из Chrome плюс libuv и системные библиотеки |
| Доступные API | Браузерные: DOM, window, localStorage, fetch | Серверные: fs, http, net, stream, child_process |
| Глобальный объект | window | global (и globalThis в обоих) |
| Применение | Интерактивные страницы, фронтенд | Бэкенд, API, CLI-инструменты, десктоп через Electron |
Array.map работает и там, и там — различается только окружение вокруг.# macOS — через Homebrew, стандартный путь
brew install node # последняя версия
brew install node@22 # конкретная major-версия
# Windows
winget install OpenJS.NodeJS.LTS
# либо .msi-установщик с nodejs.org
# Ubuntu/Debian — в репозитории обычно устаревшая версия,
# поэтому ставят из NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
# Проверка — npm ставится вместе с Node
node --version
npm --version
На разных проектах нужны разные версии Node. Менеджер версий держит их рядом и переключает одной командой — примерно как brew link для PHP.
# nvm — самый распространённый (macOS, Linux)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install --lts # поставить последнюю LTS
nvm install 22 # конкретную major
nvm use 22 # переключиться
nvm ls # что установлено
nvm alias default 22 # версия по умолчанию в новых сессиях
| Менеджер | Особенность |
|---|---|
| nvm | Стандарт де-факто. Shell-функция, поэтому which nvm ничего не покажет |
| fnm | То же самое, но на Rust — заметно быстрее при старте оболочки |
| volta | Прибивает версию к проекту: заходишь в папку — версия переключается сама |
| Homebrew | Не менеджер версий. Держит одну версию, переключение через brew unlink / brew link |
.nvmrc в корне проекта с номером версии плюс nvm use — способ зафиксировать версию для всей команды. Аналог "engines" в package.json, только исполняемый.| Линия | Номер версии | Поддержка | Для чего |
|---|---|---|---|
| LTS | Чётный: 20, 22, 24 | ~30 месяцев | Прод и всё, что должно работать долго |
| Current | Нечётный: 21, 23, 25 | ~6 месяцев | Посмотреть новые возможности |
node -v. Зафиксировать требование в проекте: "engines": { "node": ">=22" } в package.json — npm предупредит при несовпадении.| Компонент | За что отвечает |
|---|---|
| V8 | Движок JavaScript от Google, тот же, что в Chrome. Компилирует JS в машинный код и исполняет его |
| libuv | Сердце асинхронности: event loop, неблокирующая работа с сетью и файлами, пул потоков. Написан на C |
| Bindings | Прослойка, через которую JS вызывает C++-функции — например, fs.readFile |
| OpenSSL | Криптография и TLS: модуль crypto, HTTPS |
| llhttp | Парсер HTTP-протокола |
| undici | HTTP-клиент, на котором работает встроенный fetch |
| zlib, c-ares, zstd | Сжатие и DNS-резолвинг |
# Посмотреть, что внутри именно твоей сборки
node -p "process.versions"
# Пример вывода (Node 25):
# node: '25.2.1', v8: '14.1.146', uv: '1.51.0',
# openssl: '3.6.2', undici: '7.16.0', zlib: '1.2.12', ...
| PHP-FPM | Node.js | |
|---|---|---|
| Жизнь процесса | Один запрос | Недели, пока не перезапустят |
| Глобальные переменные | Умирают с запросом | Живут и видны всем запросам |
| Параллельность | Несколько процессов-воркеров | Один поток + неблокирующий I/O |
| Соединение с БД | Обычно одно на запрос | Пул, общий на весь процесс |
| Утечка памяти | Почти незаметна — процесс умрёт | Копится и роняет сервис |
| Тяжёлый цикл | Тормозит только свой запрос | Тормозит все соединения |
| Остановка | Можно убивать в любой момент | Нужен graceful shutdown |
// В PHP безобидно, в Node — утечка и протекание данных
let currentUser; // модульная переменная, одна на весь процесс
const cache = new Map(); // растёт бесконечно
export function handler(req, res) {
currentUser = req.user; // параллельный запрос перетрёт
cache.set(req.id, req.body); // никто никогда не чистит
}
// Состояние запроса живёт в самом запросе
export function handler(req, res) {
const user = req.user; // локально, умрёт с вызовом
}
AsyncLocalStorage или scope REQUEST.// server.js — всё из коробки, npm install не нужен
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, url: req.url }));
});
server.listen(3000, () => console.log('http://localhost:3000'));
node server.js
В Node 58 встроенных модулей. Подключаются без установки, с префиксом node:.
| Модуль | Для чего |
|---|---|
node:http, node:https | HTTP-сервер и клиент |
node:fs, node:fs/promises | Файловая система |
node:path | Работа с путями, кроссплатформенно |
node:crypto | Хеши, HMAC, шифрование, UUID |
node:events | EventEmitter — на нём построена половина Node |
node:stream | Потоки и pipeline |
node:os, node:process | Система, переменные окружения, сигналы |
node:child_process | Запуск внешних команд |
node:worker_threads | Настоящие потоки для CPU-задач |
node:test | Встроенный тест-раннер |
// Посмотреть полный список
node -p "require('module').builtinModules.join('\n')"
node: не обязателен (import 'fs' тоже работает), но желателен: он явно говорит, что это встроенный модуль, а не пакет из node_modules с таким же именем.node:path — пути к файлам, а не маршрутыpath знает только о первом: о файлах и папках на диске. К маршрутизации HTTP он отношения не имеет.| Что это | Пример | Чем работать |
|---|---|---|
| Файловый путь | ./data/users.json, C:\app\logs | node:path, node:fs |
| URL-маршрут | /users/123, /api/orders?status=new | Express, Fastify, NestJS, req.url |
| URL как объект | https://site.com/a?b=1 | Глобальный URL, node:url |
// Маршрут — это URL-шаблон, path тут ни при чём
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id }); // '123' из /users/123
});
// Разбор URL — третья вещь, тоже не path
const u = new URL('https://site.com/users/123?tab=posts');
u.pathname; // '/users/123' — URL-путь, не файловый
u.searchParams.get('tab'); // 'posts'
/, независимо от операционной системы. Поэтому собирать их через path.join — ошибка: на Windows получишь \ внутри адреса.pathГлавная задача — кроссплатформенность. В Windows разделитель \, в Linux и macOS — /. Склеивание строк через '/' вручную ломается на Windows.
import path from 'node:path';
path.join('users', 'data', 'file.txt');
// POSIX: 'users/data/file.txt'
// Windows: 'users\data\file.txt'
path.resolve('file.txt'); // абсолютный путь от текущей папки
path.extname('index.html'); // '.html'
path.basename('/foo/baz.txt'); // 'baz.txt'
path.dirname('/foo/baz.txt'); // '/foo'
path.sep; // '/' или '\' в зависимости от ОС
path.parse('/home/user/file.txt');
// { root: '/', dir: '/home/user', base: 'file.txt',
// ext: '.txt', name: 'file' }
join против resolve. join просто склеивает куски и нормализует разделители. resolve строит абсолютный путь, идя справа налево до первого абсолютного сегмента, и достраивает остальное от текущей рабочей директории. Для «файл рядом со скриптом» нужен resolve или import.meta.dirname, потому что рабочая директория зависит от того, откуда запустили процесс.path.join(uploadDir, req.query.name) при name=../../etc/passwd выведет за пределы папки. Проверять надо после нормализации — и сравнивать не с самой папкой, а с папкой плюс разделитель, иначе проверку пройдёт соседний каталог с похожим именем.import path from 'node:path';
const uploadDir = path.resolve('/var/www/uploads');
const target = path.resolve(uploadDir, req.query.name);
// так нельзя: '/var/www/uploads-secret/key' тоже начинается с uploadDir
if (target.startsWith(uploadDir)) { /* ... */ }
// правильно: добавляем разделитель, тогда совпадёт только вложенное
if (target.startsWith(uploadDir + path.sep)) {
// путь точно внутри uploadDir
}
| Модуль | Что даёт |
|---|---|
node:http / node:https | HTTP-сервер через createServer и клиент для запросов. На нём стоят Express, Fastify и почти весь веб в Node |
node:fs / node:fs/promises | Чтение и запись файлов и папок. Версия /promises — тот же API, но под async/await |
node:path | Сборка и разбор файловых путей, кроссплатформенно |
node:url + глобальный URL | Разбор и сборка URL, работа с query-параметрами |
node:crypto | Хеши (createHash), HMAC, шифрование, randomUUID, случайные байты |
node:events | EventEmitter. На событиях построены серверы, потоки и сокеты: on / emit |
node:stream | Обработка данных по частям без загрузки в память. pipeline() связывает потоки и ловит ошибки |
node:os | Информация о системе: cpus(), platform(), объём памяти |
node:process | Текущий процесс: env, argv, exit(), обработка сигналов |
node:child_process | Запуск внешних программ: exec, spawn — например git или ffmpeg |
node:worker_threads | Настоящие потоки ОС для CPU-задач, чтобы не блокировать event loop |
node:test | Встроенный тест-раннер (node --test) — лёгкая замена Jest без зависимостей |
node: — зачем он на самом делеnode_modules. Это не так: Node резолвит встроенные модули раньше node_modules. Пакет с именем fs встроенный fs не перекроет — проверяется за минуту:
mkdir -p node_modules/fs && echo 'module.exports={HACKED:1}' > node_modules/fs/index.jsnode -e "console.log(require('fs').HACKED ?? 'встроенный')" → встроенный| Настоящая причина | Пояснение |
|---|---|
| Новые модули иначе не подключить | require('test') даёт MODULE_NOT_FOUND, работает только require('node:test'). Так же с node:sqlite |
| Явность | Сразу видно, что это часть Node, а не зависимость из package.json |
| Скорость резолва | Node не ищет по файловой системе — сразу берёт встроенный |
import 'fs' и import 'node:fs' равнозначны. Привычка писать префикс везде полезна тем, что код единообразен и не ломается на новых модулях.{
"name": "my-api",
"type": "module", // ESM: import/export. Без этого — CommonJS
"engines": { "node": ">=22" },
"scripts": {
"dev": "node --watch src/main.js",
"start": "node src/main.js",
"test": "node --test"
},
"dependencies": { "@nestjs/core": "^11.0.0" },
"devDependencies": { "typescript": "^5.7.0" }
}
| Понятие | Аналог в PHP | Смысл |
|---|---|---|
package.json | composer.json | Манифест: зависимости, скрипты, метаданные |
package-lock.json | composer.lock | Точные версии всего дерева. В Git обязательно |
node_modules/ | vendor/ | Сами пакеты. В Git не кладут |
dependencies | require | Нужны в проде |
devDependencies | require-dev | Только для разработки: TypeScript, тесты, линтер |
npm ci | composer install | Строго по lock-файлу. Для CI и прода |
npm install | composer update (частично) | Может обновить lock в рамках диапазонов |
npm install на проде — та же ошибка, что composer update. Он может подтянуть версии, которых никто не тестировал, и переписать lock прямо на сервере. В CI и на проде только npm ci: он удаляет node_modules и ставит ровно то, что в lock-файле.npm init -y # создать package.json
npm install express # поставить в dependencies
npm install -D typescript # -D → devDependencies
npm uninstall express # удалить
npm ci # установка по lock — прод и CI
npm run dev # запустить скрипт из "scripts"
npm outdated # что устарело
npm audit # известные уязвимости
npx tsc --init # выполнить пакет без установки
npx — запуск бинарника пакета без глобальной установки. Именно так создают проекты: npx @nestjs/cli new my-app.I/O (input/output) — любой обмен данными с внешним миром относительно процесса: чтение и запись файлов, сетевые запросы, сокеты, работа с базой, stdin/stdout, DNS-запросы. Общее у них одно: нужно ждать ответа от диска, сети или другого процесса, и это ожидание программа не контролирует.
Процессор работает на порядки быстрее внешних устройств. Отсюда и вся асинхронная модель:
| Операция | Примерное время | Во сколько раз медленнее инструкции CPU |
|---|---|---|
| Выполнить инструкцию CPU | ~0.3 нс | 1× |
| Прочитать из RAM | ~100 нс | ~300× |
| Прочитать с SSD | ~100 000 нс (0.1 мс) | ~300 000× |
| Сетевой запрос внутри дата-центра | ~0.5–1 мс | ~2 000 000× |
| Запрос к БД | ~1–50 мс | до ~150 000 000× |
| Сеть через интернет | ~50–500 мс | до ~1 500 000 000× |
Если программа «встаёт и ждёт» диск или сеть — процессор простаивает. Классический сервер на потоках решает это так: на каждый запрос отдельный поток, и потоки просто спят в ожидании I/O. При тысячах соединений это дорого и по памяти, и по переключениям контекста.
Одно и то же чтение файла двумя способами:
// Блокирующий: поток Node замирает, пока файл читается
const fs = require('node:fs');
const data = fs.readFileSync('big.txt', 'utf8');
console.log('прочитано', data.length);
// следующие строки не выполнятся, пока чтение не закончится
// Неблокирующий: Node работает дальше, результат придёт позже
const fs = require('node:fs');
fs.readFile('big.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('прочитано', data.length);
});
console.log('эта строка выполнится сразу, не дожидаясь файла');
В асинхронном варианте Node отдаёт чтение системе через libuv, а сам возвращается в event loop и обслуживает другие запросы. Когда данные готовы — вызывается колбэк или резолвится промис.
async/await — тот же механизм, просто записанный линейно:
const fs = require('node:fs/promises');
async function main() {
const data = await fs.readFile('big.txt', 'utf8'); // event loop не блокируется
console.log('прочитано', data.length);
}
main();
await не делает код «параллельным» — он лишь не блокирует event loop на время ожидания. Два последовательных await выполнятся один за другим; чтобы они шли одновременно, нужен Promise.all.libuv — C-библиотека, дающая Node кроссплатформенный неблокирующий I/O. В ней две ключевые части.
1. Event loop — диспетчер. Бесконечный цикл, который забирает у ОС готовые события («пришли данные по сокету», «сработал таймер», «файл прочитан»), вызывает соответствующие JS-колбэки, проверяет отложенные задачи, а если делать нечего — засыпает и просыпается на новом событии. Именно поэтому один поток JS обслуживает тысячи соединений: он не ждёт каждое, а реагирует на события. Подробный разбор фаз — в разделе Event loop и фазы.
2. Пул потоков. Некоторые операции ОС не умеет делать асинхронно — например, чтение файлов на Linux. Для них libuv держит пул фоновых потоков (по умолчанию 4, меняется через UV_THREADPOOL_SIZE): они выполняют блокирующую работу, а event loop получает только результат. Что именно уходит в пул, а что операционная система умеет делать сама — разобрано в разделе Thread pool и блокировка.
fs.readFile одновременно при пуле из четырёх потоков — пятый будет ждать освобождения. Это частая причина «загадочных» задержек, которые не объясняются ни сетью, ни базой.Как части схемы из раздела «Из чего состоит» работают вместе:
Нижние слои схемы — конкретные C-библиотеки, на которых стоят встроенные модули: OpenSSL (node:crypto, HTTPS/TLS), zlib (сжатие, gzip в HTTP), c-ares (асинхронный DNS в dns.resolve*), llhttp (парсер HTTP), undici (HTTP-клиент, на котором работает глобальный fetch).
Node оптимизирован под I/O-bound задачи — где время уходит на ожидание сети и диска, а не на вычисления: API-серверы, прокси, real-time, чаты, стриминг. Здесь он силён.
Но JS исполняется в одном потоке V8. Тяжёлые вычисления — сортировка гигантского массива, обработка изображений, криптография на чистом JS — останавливают event loop, и все соединения ждут, хотя никакого I/O нет:
// Блокирует event loop на секунды — все запросы повиснут
function heavyCpu() {
let sum = 0;
for (let i = 0; i < 1e10; i++) sum += i;
return sum;
}
// Уводим в worker thread — event loop свободен
const { Worker } = require('node:worker_threads');
new Worker('./heavy-worker.js');
| Куда выносить CPU-работу | Когда это правильный выбор |
|---|---|
node:worker_threads | Настоящие потоки внутри процесса, есть общая память через SharedArrayBuffer |
node:child_process | Отдельный процесс — изоляция памяти, можно запускать не-JS |
| Нативные асинхронные реализации | crypto и zlib уже уходят в пул потоков — не пишите своё на чистом JS |
Поэтому Node и называют однопоточным, но конкурентным: один поток JS — и множество одновременных I/O-операций.
Короткая иерархия, к которой можно возвращаться при чтении дальнейших разделов:
| Понятие | Частью чего является | Кто это запускает |
|---|---|---|
| Фаза | Event loop | Сам цикл, по кругу |
| Event loop | libuv | Node — после того, как выполнил ваш файл |
| Пул потоков | libuv | libuv, когда операцию нельзя сделать асинхронно |
| Микрозадачи | V8 (промисы) и Node (nextTick) | Сливаются после каждого колбэка |
| Ваш колбэк | Очередь внутри фазы | Event loop, когда дошёл до этой фазы |
Цикл работает, пока у libuv есть незакрытые дела: открытый сокет сервера, живой таймер, незавершённая операция с файлом. Как только счётчик таких дел обнуляется, цикл выходит и процесс завершается сам.
// Скрипт завершится сразу: дел не осталось
console.log('привет');
// А этот будет жить: открытый сервер — незакрытое дело
http.createServer(handler).listen(3000);
// И этот: интервал держит цикл вечно
setInterval(() => {}, 1000);
// unref() снимает удержание: таймер сработает, но держать цикл не будет
const t = setInterval(tick, 1000);
t.unref();
process.getActiveResourcesInfo() — он покажет, что именно удерживает цикл.JS-код выполняется в одном потоке. Ввод-вывод — сеть, сокеты — отдаётся операционной системе через libuv неблокирующе. Когда операция завершается, её колбэк ставится в очередь, и event loop его подбирает.
timers. Начиная с libuv 1.45.0, который вошёл в Node 20, таймеры выполняются после фазы poll на каждом обороте. Схемы в старых статьях показывают прежний порядок — на Node 20 и новее они устарели.console.log('1');
setTimeout(() => console.log('2'), 0);
setImmediate(() => console.log('3'));
Promise.resolve().then(() => console.log('4'));
process.nextTick(() => console.log('5'));
console.log('6');
| Порядок | Что выводится | Почему |
|---|---|---|
| 1–2 | 1, 6 | Синхронный код идёт первым, целиком |
| 3 | 5 | process.nextTick — отдельная очередь, приоритетнее промисов |
| 4 | 4 | Очередь промисов |
| 5–6 | 2 и 3 | Порядок не гарантирован |
2 и 3 плавают. В главном модуле результат зависит от того, успел ли истечь таймер к моменту входа в фазу timers — а это зависит от скорости запуска процесса. Но внутри I/O-колбэка setImmediate всегда раньше setTimeout: цикл уже находится в фазе poll, и следующая фаза — check.Event loop — не одна общая очередь колбэков, а цикл из нескольких фаз. Каждая фаза держит собственную очередь задач своего типа. Node проходит фазы по кругу и на каждой обрабатывает только свою очередь. Смысл разделения — предсказуемость порядка и возможность дать разным видам работы разный приоритет.
Фаза idle/prepare — внутренняя, она нужна самому Node, и в прикладном коде вы с ней не сталкиваетесь. Остальные разбираем по порядку.
| Фаза | Что выполняет | Чем важна |
|---|---|---|
| pending callbacks | Системные колбэки, отложенные ОС на следующий проход — в документации приведены «некоторые типы ошибок TCP» | В прикладном коде встречается редко |
| idle, prepare | Служебная фаза | В документации помечена «used only internally» — вашего кода здесь не бывает |
| poll | Забирает готовые события ввода-вывода и выполняет их колбэки — все, кроме close-колбэков, таймеров и setImmediate | Сердце цикла. Именно здесь процесс «спит», не тратя процессор, пока ждёт сеть или диск |
| check | Колбэки setImmediate | Следующая фаза после poll — отсюда вся разница с setTimeout(fn, 0) |
| timers | Колбэки setTimeout и setInterval, у которых истёк срок | Таймер задаёт порог, после которого колбэк может выполниться, а не точное время |
| close callbacks | События закрытия: socket.on('close') | Замыкает круг, дальше цикл начинается заново |
Фаз на самом деле семь или восемь; в документации разбирают те, которые реально использует Node — они перечислены выше.
Как фаза poll решает, ждать ли. У неё две работы: посчитать, сколько времени блокироваться на ожидании ввода-вывода, и обработать события из своей очереди. Дальше поведение зависит от того, что её ждёт:
setTimeout(fn, 0) — это не «немедленно». Минимальная задержка в Node фактически равна 1 мс, и колбэк всё равно ждёт захода в фазу timers. Если нужно «как можно скорее, но после текущего ввода-вывода» — это setImmediate.Самая известная задача на понимание фаз. Внутри колбэка ввода-вывода порядок строго определён, а на верхнем уровне — нет.
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Всегда: immediate → timeout
// Мы внутри poll, а check идёт сразу за ней;
// timers наступит только на следующем проходе цикла.
// А на верхнем уровне гарантии нет — зависит от того, успел ли
// истечь таймер к моменту первого захода в фазу timers
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Порядок может отличаться от запуска к запуску
Это самое важное для практики. Микрозадачи — очереди, которые опустошаются полностью, прежде чем цикл пойдёт дальше. Очередей две, и порядок между ними строгий:
process.nextTick() формально не является частью event loop. Его очередь обрабатывается после завершения текущей операции, независимо от того, в какой фазе цикл сейчас находится. Операцией здесь называют переход от обработчика на C/C++ к исполнению связанного с ним JavaScript. Поэтому на схеме цикла обе очереди микрозадач стоят отдельно, а не внутри фаз.| Очередь | Что попадает | Приоритет |
|---|---|---|
process.nextTick | Только process.nextTick | Первая — выше промисов |
| Микрозадачи промисов | .then, .catch, .finally, queueMicrotask, продолжение после await | Вторая |
console.log('1 — синхронно');
setTimeout(() => console.log('5 — timers'), 0);
Promise.resolve().then(() => console.log('3 — promise'));
process.nextTick(() => console.log('2 — nextTick'));
queueMicrotask(() => console.log('4 — queueMicrotask'));
console.log('1.5 — синхронно');
// Вывод:
// 1 — синхронно
// 1.5 — синхронно
// 2 — nextTick
// 3 — promise
// 4 — queueMicrotask
// 5 — timers
Сначала до конца отрабатывает синхронный код, затем вся очередь nextTick, затем все промисы — и только потом цикл уходит в следующую фазу.
Почему nextTick раньше промисов. Исторически: он появился в Node задолго до промисов, и внутренний код платформы опирался на него для гарантий согласованности — например, чтобы событие 'close' точно возникало после завершения текущей операции. Когда добавили промисы, их поставили после, чтобы не сломать существующее поведение.
Прямое следствие предыдущего правила: цепочка микрозадач, которая пополняет сама себя, никогда не закончится — и цикл просто не дойдёт до ввода-вывода.
function starve() {
process.nextTick(starve); // добавляет новый nextTick прямо во время слива
}
starve();
// Очередь nextTick никогда не опустеет →
// ни ввод-вывод, ни таймеры, ни setImmediate больше не выполнятся.
function loop() {
Promise.resolve().then(loop); // то же самое с промисами
}
loop(); // сервер встанет: до фазы poll дело не дойдёт
Поэтому nextTick и промисы хороши для «сразу после текущей операции», но не годятся для длинных циклов обработки. Чтобы отдавать управление циклу между итерациями, берут setImmediate:
// Правильно: между порциями работы цикл успевает обслужить ввод-вывод
function processChunk() {
// ...немного работы...
setImmediate(processChunk); // уходим в фазу check, даём шанс poll
}
| Что | Когда выполняется | Очередь |
|---|---|---|
| Синхронный код | Сразу | — |
process.nextTick | После текущего колбэка, до промисов | Микрозадачи, приоритет 1 |
.then, await, queueMicrotask | После очереди nextTick | Микрозадачи, приоритет 2 |
setTimeout, setInterval | Фаза timers | Макрозадачи |
Колбэки ввода-вывода (fs, сеть) | Фаза poll | Макрозадачи |
setImmediate | Фаза check — следующая после poll | Макрозадачи |
| События закрытия | Фаза close callbacks | Макрозадачи |
nextTick → полный слив очереди промисов → следующая макрозадача. Понимание этого порядка снимает большую часть вопросов вида «почему лог вывелся не там» и «почему сервер завис».| Идёт в пул потоков | Что именно, по документации |
|---|---|
fs | Все файловые операции, кроме fs.FSWatcher() и явно синхронных (*Sync) |
dns | dns.lookup() и dns.lookupService() — они идут через системный резолвер. Остальные dns.resolve* работают через c-ares и пул не занимают |
crypto | pbkdf2, scrypt, randomBytes, randomFill, generateKeyPair |
zlib | Все операции сжатия, кроме явно синхронных |
| Нативные дополнения | Модули на C++ могут отправлять в пул собственные задачи |
Сеть в пул не идёт. TCP, HTTP и сокеты операционная система умеет обслуживать сама: она сообщает процессу, когда на сокете появились данные, и отдельный поток для ожидания не нужен. Поэтому тысячи открытых соединений не стоят Node почти ничего, а вот сотня одновременных чтений файлов упирается в четыре потока пула.
Механизмы, которыми это сделано, называются epoll в Linux, kqueue в macOS и IOCP в Windows — знать их названия для работы не нужно.
По умолчанию в пуле 4 потока, меняется переменной окружения UV_THREADPOOL_SIZE.
# так — работает
UV_THREADPOOL_SIZE=8 node app.js
process.env.UV_THREADPOOL_SIZE в коде не гарантирует эффекта, потому что пул создаётся при инициализации рантайма — задолго до того, как начнёт выполняться ваш файл. Задавать нужно при запуске процесса.pbkdf2 — и пятый ждёт.JSON.parseогромных тел запроса- Тяжёлые регулярные выражения (катастрофический бэктрекинг)
- Синхронные вызовы
fs:readFileSyncв обработчике - Циклы на миллионы итераций
- Синхронный
bcrypt
| Решение | Когда применять |
|---|---|
worker_threads | CPU-задача внутри того же процесса |
| Отдельный сервис или очередь | Долгая обработка, которую не ждёт пользователь |
Разбить на части через setImmediate | Длинный цикл, который можно нарезать |
cluster или несколько реплик | Использовать все ядра. В Docker/K8s обычно реплики, а не cluster |
// Основной способ — try/catch вокруг await
try {
const user = await fetchUser(id);
} catch (e) {
// обработка
}
| Ситуация | Что происходит |
|---|---|
Промис без catch | unhandledRejection. В современных версиях Node процесс по умолчанию падает |
process.on('uncaughtException') | Последний рубеж только для логирования и аккуратного завершения |
| В NestJS | Exception filters превращают исключения в HTTP-ответы |
uncaughtException продолжать работу нельзя. Состояние процесса неопределённо: могли остаться незакрытые транзакции, повреждённые структуры в памяти, полузаписанные файлы. Правильное поведение — залогировать и завершиться, дав супервизору поднять новый процесс.| Метод | Поведение | Когда применять |
|---|---|---|
Promise.all | Падает на первой ошибке | Нужны все результаты, и любая ошибка критична |
Promise.allSettled | Ждёт всех, возвращает статус каждого | Собрать результаты, часть из которых может упасть |
Promise.race | Первый завершившийся — успех или ошибка | Таймауты |
Promise.any | Первый успешный | Несколько поставщиков, подходит любой ответ |
race и any — в отношении к ошибкам. race завершится ошибкой, если первым пришёл отказ; any будет ждать успеха и упадёт, только если провалились все.Поток отдаёт данные кусками и не держит всё в памяти. Отдать CSV-отчёт на гигабайт без потоков — значит сначала собрать этот гигабайт в память процесса.
write() возвращает false. Это сигнал прекратить писать и дождаться события drain. Игнорировать его — значит копить данные в буфере и прийти к той же утечке памяти, от которой уходили.import { pipeline } from 'node:stream/promises';
// pipeline сам обрабатывает backpressure, ошибки и закрытие потоков
await pipeline(
source,
transform,
destination,
);
.pipe() и .on('error') — источник незакрытых дескрипторов. stream.pipeline() закрывает всю цепочку при ошибке в любом звене.| Причина | Как выглядит |
|---|---|
| Глобальный кеш без лимита | Map, который только растёт и никогда не чистится |
| Неотписанные слушатели | emitter.on(...) без парного off |
| Замыкания на большие объекты | Колбэк держит ссылку на весь ответ, а нужен один id |
| Таймеры без очистки | setInterval без clearInterval |
# Запуск с инспектором
node --inspect app.js
# Дальше: Chrome DevTools → Memory → два heap snapshot с интервалом,
# сравнить и посмотреть, что выросло
// Быстрая проверка в рантайме
setInterval(() => console.log(process.memoryUsage()), 10_000);
| CommonJS | ES Modules | |
|---|---|---|
| Синтаксис | require / module.exports | import / export |
| Загрузка | Синхронная | Асинхронная |
| Анализ | В рантайме | Статический — отсюда tree shaking |
| Top-level await | Нет | Да |
emitDecoratorMetadata, на которых держится DI.- Поймать
SIGTERM - Перестать принимать новые запросы —
server.close() - Дать текущим запросам завершиться
- Закрыть пул БД и подключения к очередям
- Выйти
// NestJS
app.enableShutdownHooks();
// И хуки в провайдерах:
async onModuleDestroy() { /* закрыть ресурсы */ }
async beforeApplicationShutdown(signal?: string) { /* ... */ }
SIGTERM и через 10 секунд SIGKILL — за эти 10 секунд нужно успеть доработать текущую задачу и не взять новую.| Характеристика | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Направление | Двунаправленное: клиент ⇄ сервер | Только сервер → клиент | Сервер → клиент, но запрос инициирует клиент |
| Протокол | Свой, поверх TCP: ws:// / wss:// | Обычный HTTP, text/event-stream | Обычный HTTP |
| Применение | Чаты, игры, совместное редактирование | Ленты, уведомления, дашборды, котировки | Фоллбэк, легаси, редкие обновления |
| Сложность | Средняя — нужно вести жизненный цикл | Низкая — переподключение встроено | Низкая, но накладно |
| Накладные расходы | Низкие | Низкие | Высокие — каждый запрос полный HTTP-цикл |
| Переподключение | Пишешь сам | Браузер сам | Сам по природе — цикл запросов |
| Бинарные данные | Да | Нет, только текст | Да |
Соединение начинается как обычный HTTP-запрос с просьбой сменить протокол, после чего тот же TCP-сокет работает уже по WebSocket.
// Запрос клиента
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
// Ответ сервера
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
import { WebSocketServer, WebSocket } from 'ws'; // WebSocket нужен ради константы OPEN
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
ws.send('Добро пожаловать!');
ws.on('message', (raw) => {
const text = raw.toString();
// Разослать всем живым клиентам
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(text);
}
}
});
});
// Клиент
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => ws.send('привет');
ws.onmessage = (e) => console.log(e.data);
ws.onclose = () => /* переподключение пишешь сам */;
last_message_id. Именно за это берут socket.io: там это уже реализовано.ping, клиент отвечает pong, не ответил за два интервала — закрываем.Сервер открывает один долгоживущий HTTP-ответ и пишет в него текстовые события. Клиент только слушает.
// Примеры ниже используют import, поэтому в package.json нужно
// "type": "module". Без этой строки Node ждёт CommonJS и упадёт
// с «Cannot use import statement outside a module».
import express from 'express';
const app = express();
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
// Connection здесь только для HTTP/1.1. В HTTP/2 этот заголовок запрещён
if (req.httpVersionMajor === 1) res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // чтобы Nginx не копил ответ в буфере
res.flushHeaders();
const timer = setInterval(() => {
const payload = { ts: Date.now(), value: Math.random() };
// Формат строгий: поле, двоеточие, значение. Событие закрывают ДВА \n
res.write(`id: ${payload.ts}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}, 2000);
req.on('close', () => { clearInterval(timer); res.end(); });
});
// Клиент — всё встроено в браузер
const es = new EventSource('/events');
es.onmessage = (e) => console.log(JSON.parse(e.data));
es.onerror = () => /* браузер переподключится сам */;
| Поле | Что делает |
|---|---|
data: | Полезная нагрузка. Несколько строк подряд склеиваются через \n |
id: | Идентификатор события. Браузер запоминает последний |
event: | Имя события — слушается через addEventListener('имя', ...) |
retry: | Задержка переподключения в миллисекундах |
: в начале строки | Комментарий. Используют как heartbeat, чтобы прокси не закрыл соединение |
Last-Event-ID с последним полученным id. Сервер по нему досылает пропущенное. С WebSocket эту логику пишут руками.Клиент шлёт обычный запрос, сервер не отвечает сразу, а держит его открытым, пока не появятся данные или не истечёт таймаут. Получив ответ, клиент немедленно шлёт следующий.
const waiting = new Map(); // res -> timer
app.get('/poll', (req, res) => {
const since = Number(req.query.since ?? 0);
const missed = getEventsAfter(since);
// Если что-то уже накопилось — отдаём сразу, без ожидания
if (missed.length) return res.json(missed);
// Таймаут обязателен: иначе прокси оборвёт соединение сам
const timer = setTimeout(() => {
waiting.delete(res);
res.json([]); // пустой ответ — клиент сразу спросит снова
}, 25_000);
// Храним ответ ВМЕСТЕ с его таймером, иначе погасить его потом нечем
waiting.set(res, timer);
req.on('close', () => { clearTimeout(timer); waiting.delete(res); });
});
function publish(event) {
store(event);
for (const [res, timer] of waiting) {
clearTimeout(timer); // иначе через 25 с сработает res.json([]) на уже отвеченном ответе
res.json([event]);
}
waiting.clear();
}
publish() просто ответить и очистить список, запланированные setTimeout останутся жить. Через 25 секунд каждый из них попытается ответить второй раз — и Express упадёт с Cannot set headers after they are sent. Поэтому waiting здесь Map, а не Set: ответ хранится вместе со своим таймером.since / last_event_id), а сервер отдаёт всё, что после него — а не только свежее.proxy_read_timeout закроет и WebSocket, и SSE, если в них нет трафика. Нужны proxy_http_version 1.1, проброс Upgrade / Connection для WebSocket и увеличенный таймаут. Разобрано в разделе Nginx → Reverse proxy.proxy_buffering off; или заголовком X-Accel-Buffering: no из приложения.socket.io есть готовый адаптер) плюс sticky sessions на балансировщике, чтобы переподключение попадало в тот же инстанс.ws.bufferedAmount и отключать безнадёжно отстающих.| Задача | Выбор |
|---|---|
| Чат, игра, совместное редактирование | WebSocket |
| Лента уведомлений, прогресс задачи, котировки | SSE — проще и переподключается сам |
| Обновление раз в минуту | Обычный опрос по таймеру, без всего этого |
| Окружение режет WebSocket (корпоративный прокси) | Long Polling как фоллбэк |
Nest CLI создаёт каркас и умеет генерировать части модуля. Для примера понадобятся сам Nest, валидация и TypeORM с драйвером PostgreSQL.
npm i -g @nestjs/cli
nest new shop # каркас приложения
cd shop
npm i @nestjs/typeorm typeorm pg
npm i class-validator class-transformer
nest g module orders # создаст orders.module.ts и подключит его к AppModule
nest g controller orders
nest g service orders
nest g module orders — это примерно php artisan make:provider плюс создание папки под фичу. Разница в том, что в Laravel сервис-провайдер лишь регистрирует связки в общем контейнере, а модуль Nest ещё и задаёт границу видимости: что он берёт извне и что отдаёт наружу.Две таблицы: счёт пользователя и заказ. На них мы потом покажем транзакцию — списание денег и создание заказа должны происходить вместе или не происходить вовсе.
// src/orders/account.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Account {
@PrimaryGeneratedColumn()
id: number;
// numeric вместо float: деньги нельзя хранить в плавающей точке
@Column({ type: 'numeric', precision: 12, scale: 2 })
balance: string;
}
// src/orders/order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
accountId: number;
@Column({ type: 'numeric', precision: 12, scale: 2 })
total: string;
// ключ идемпотентности: повторный запрос не создаст второй заказ
@Column({ unique: true })
requestId: string;
@CreateDateColumn()
createdAt: Date;
}
DTO здесь ровно то же, что FormRequest в Laravel: описание того, что мы согласны принять. Разница в том, что правила пишутся декораторами прямо над полями, а не массивом в методе rules().
// src/orders/dto/create-order.dto.ts
import { IsInt, IsPositive, IsNumberString, IsUUID } from 'class-validator';
export class CreateOrderDto {
@IsInt()
@IsPositive()
accountId: number;
// строка, а не number: иначе float испортит копейки
@IsNumberString()
total: string;
@IsUUID()
requestId: string;
}
Чтобы декораторы начали работать, валидацию включают глобально. Заодно просим отсекать лишние поля — иначе клиент пришлёт isAdmin: true, и оно доедет до сервиса.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // выкинуть поля, которых нет в DTO
forbidNonWhitelisted: true, // и вернуть 400, если такие были
transform: true, // привести типы: '5' из params станет number
}));
app.enableShutdownHooks(); // без этого SIGTERM не доедет до onModuleDestroy
await app.listen(3000);
}
bootstrap();
Guard отвечает на один вопрос: продолжать обработку или отказать. Он срабатывает до валидации — нет смысла разбирать тело запроса от того, кого мы всё равно не пустим.
// src/orders/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const key = request.headers['x-api-key'];
if (key !== process.env.API_KEY) {
throw new UnauthorizedException('Неверный ключ');
}
return true;
}
}
Gate или middleware авторизации. Ключевое отличие от middleware Express: guard получает ExecutionContext и потому знает, какой контроллер и какой метод сейчас вызывается. Обычный middleware этого не знает, поэтому проверки прав на нём не делают.В контроллере не должно быть бизнес-логики. Его работа — принять запрос, передать данные в сервис и вернуть результат. Ровно как тонкий контроллер в Laravel.
// src/orders/orders.controller.ts
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { ApiKeyGuard } from './api-key.guard';
@Controller('orders')
export class OrdersController {
// зависимость объявляется в конструкторе — Nest подставит её сам
constructor(private readonly orders: OrdersService) {}
@Post()
@UseGuards(ApiKeyGuard)
create(@Body() dto: CreateOrderDto) {
return this.orders.create(dto);
}
@Get(':id')
findOne(@Param('id') id: number) {
return this.orders.findOne(id);
}
}
Здесь живёт логика. Списание денег и создание заказа обязаны быть атомарными: если заказ не создался, деньги должны вернуться. В TypeORM это делают через DataSource.transaction — аналог DB::transaction() в Laravel.
// src/orders/orders.service.ts
import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Account } from './account.entity';
import { Order } from './order.entity';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrdersService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async create(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
// Блокируем строку счёта до конца транзакции.
// Без этого два одновременных запроса прочитают один и тот же
// баланс и спишут дважды — классическая гонка.
const account = await manager.findOne(Account, {
where: { id: dto.accountId },
lock: { mode: 'pessimistic_write' },
});
if (!account) throw new NotFoundException('Счёт не найден');
const balance = Number(account.balance);
const total = Number(dto.total);
if (balance < total) throw new BadRequestException('Недостаточно средств');
account.balance = (balance - total).toFixed(2);
await manager.save(account);
try {
return await manager.save(Order, {
accountId: dto.accountId,
total: dto.total,
requestId: dto.requestId,
});
} catch (e: any) {
// 23505 — нарушение уникальности: такой requestId уже был.
// Значит это повтор, а не новая покупка.
if (e.code === '23505') throw new ConflictException('Заказ уже создан');
throw e;
}
});
}
async findOne(id: number) {
const order = await this.dataSource.getRepository(Order).findOneBy({ id });
if (!order) throw new NotFoundException();
return order;
}
}
float — копейки начнут теряться, поэтому колонка numeric, а в коде строка. Вторая: чтение баланса без блокировки — два параллельных запроса спишут с одного остатка дважды, спасает pessimistic_write. Третья: отсутствие ключа идемпотентности — клиент повторит запрос по таймауту и купит дважды, поэтому requestId с уникальным индексом.Модуль перечисляет, из чего состоит фича и что она отдаёт наружу. Всё, чего нет в exports, остаётся внутренним — это и есть граница, которой нет у сервис-провайдера Laravel.
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { Order } from './order.entity';
import { Account } from './account.entity';
@Module({
imports: [TypeOrmModule.forFeature([Order, Account])],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService], // другие модули смогут его внедрить
})
export class OrdersModule {}
// src/app.module.ts — подключение базы и нашего модуля
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'shop',
autoLoadEntities: true,
synchronize: true, // только для разработки: в проде — миграции
}),
OrdersModule,
],
})
export class AppModule {}
npm run start:dev
# без ключа — 401 от guard
curl -X POST localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"accountId":1,"total":"100.00","requestId":"3f1a..."}'
# с ключом, но с лишним полем — 400 от ValidationPipe (forbidNonWhitelisted)
curl -X POST localhost:3000/orders \
-H 'x-api-key: secret' -H 'Content-Type: application/json' \
-d '{"accountId":1,"total":"100.00","requestId":"3f1a...","isAdmin":true}'
# корректный запрос — 201 и созданный заказ
curl -X POST localhost:3000/orders \
-H 'x-api-key: secret' -H 'Content-Type: application/json' \
-d '{"accountId":1,"total":"100.00","requestId":"3f1a..."}'
# тот же requestId ещё раз — 409, второй заказ не создан
ValidationPipe разобрал тело по DTO и отсёк лишние поля. Контроллер передал готовый объект в сервис, не зная ничего о базе. Сервис открыл транзакцию, заблокировал строку счёта, списал деньги и создал заказ — либо откатил всё целиком. Это ровно тот порядок, который описан в разделе Жизненный цикл запроса.| Вопрос | Короткий ответ |
|---|---|
| Чем модуль Nest отличается от сервис-провайдера Laravel? | Провайдер только регистрирует связки в общем контейнере, модуль дополнительно задаёт границу: что импортирует и что экспортирует |
| Почему guard срабатывает раньше pipe? | Чтобы не тратить разбор и валидацию тела на запрос, который всё равно будет отклонён |
| Как не списать деньги дважды? | Блокировка строки счёта в транзакции плюс ключ идемпотентности с уникальным индексом |
| Зачем whitelist в ValidationPipe? | Отсекает поля, которых нет в DTO, иначе лишние данные из запроса доедут до сервиса |
| NestJS | Аналог в Laravel | Что делает |
|---|---|---|
@Module | Service Provider | Четыре поля: providers — что создаёт инжектор, controllers — какие контроллеры поднять, imports — чужие модули, чьи провайдеры нужны здесь, exports — то из своих провайдеров, что отдаётся наружу |
@Injectable провайдер | Класс в контейнере | Сервис, который DI создаёт и внедряет через конструктор |
| Controller | Controller | Маршруты: @Get, @Post, @Param, @Body |
| DTO + ValidationPipe | FormRequest | Валидация входа через class-validator |
| Guard | Middleware auth / Gate / Policy | Решает, пускать ли запрос: JWT, роли, подпись вебхука |
| Interceptor | Middleware до и после | Обёртка вокруг хендлера: логи, кеш, таймаут, преобразование ответа |
| Exception filter | Exception Handler | Превращает исключения в HTTP-ответы |
| Middleware | Global middleware | Низкоуровнево, до guards: request-id, raw body, CORS |
Кроме цикла запроса у Nest есть цикл самого приложения: поднятие при старте и остановка при сигнале. Это то место, где подключают пулы соединений и где их корректно закрывают.
| Хук | Когда срабатывает |
|---|---|
onModuleInit | Зависимости конкретного модуля разрешены. Nest идёт по модулям снизу вверх — от самых глубоких в графе импортов к корневому |
onApplicationBootstrap | Все модули проинициализированы, но сервер ещё не начал принимать соединения |
onModuleDestroy | Получен сигнал завершения, например SIGTERM |
beforeApplicationShutdown | Все onModuleDestroy отработали, соединения ещё не закрыты. Получает имя сигнала |
onApplicationShutdown | Соединения закрыты — последний шаг app.close(). Получает имя сигнала |
Если хук вернул промис, Nest дождётся его перед переходом к следующему шагу — поэтому в них можно спокойно делать асинхронную работу.
const app = await NestFactory.create(AppModule);
// без этой строки хуки по сигналам не сработают
app.enableShutdownHooks();
await app.listen(3000);
enableShutdownHooks() хуки отработают только при явном app.close(), а на SIGTERM от Docker или Kubernetes — нет. Контейнер будет убит по таймауту вместе с незавершёнными запросами. На Windows SIGTERM не работает в принципе, SIGINT — работает.Запрос проходит фиксированную последовательность. У каждого типа обработчиков есть три уровня — глобальный, контроллера и маршрута, — и они тоже идут в строгом порядке.
| Что | Правило из документации |
|---|---|
| Middleware между модулями | Сначала middleware глобального модуля, затем корневого, затем остальных — в порядке удалённости от корня |
| Пайпы параметров | Если пайпы стоят у нескольких параметров, последний параметр обрабатывается первым |
| Фильтры исключений | Разрешаются снизу вверх: от маршрута к глобальному. Исключение, пойманное на одном уровне, дальше не передаётся |
| Фильтры и успешный запрос | Запускаются только при необработанном исключении — в обычном сценарии их в цепочке нет |
ExecutionContext, класс контроллера и метод-обработчик. Всё, что опирается на эти данные — проверка ролей по декоратору, кеш по маршруту — должно быть guard или interceptor, а не middleware.| Пара | Почему в этом порядке |
|---|---|
| Guard раньше Pipe | Нет смысла разбирать и валидировать тело запроса от того, кого мы не пустим. Сначала отсекаем, потом тратим время на данные |
| Interceptor раньше Pipe | Перехватчик должен охватывать весь вызов, включая валидацию: иначе таймаут или замер времени не учтут разбор входных данных |
| Middleware раньше Guard | Middleware работает на уровне HTTP-сервера, до того как Nest вообще определил, какой обработчик вызывать |
| Фильтры в самом конце | Они ловят исключения всего, что было выше, — включая сами guard-ы и пайпы |
Gate или Policy, pipe — FormRequest, interceptor — middleware с кодом до и после $next($request), exception filter — Handler::render(). Главное отличие: в Laravel всё это конвейер middleware, а в Nest — четыре разных типа с собственным порядком.Провайдер регистрируется в модуле, Nest строит граф зависимостей по типам параметров конструктора — ровно как контейнер Laravel по рефлексии.
| Scope | Время жизни | Нюанс |
|---|---|---|
DEFAULT | Один экземпляр на всё приложение | Действует по умолчанию, объявлять не нужно |
REQUEST | Свой экземпляр на каждый входящий запрос; после обработки собирается сборщиком мусора | Документация прямо предупреждает, что такие провайдеры влияют на производительность, и советует без необходимости их не брать |
TRANSIENT | Свой экземпляр у каждого, кто его внедряет — потребители не делят один объект | В отличие от REQUEST, вверх не поднимается: синглтон получит собственный экземпляр transient-провайдера, но сам останется синглтоном |
// Четыре формы — аналог bind/singleton/instance в Laravel
{ provide: PaymentGateway, useClass: StripeGateway }
{ provide: 'CONFIG', useValue: { timeout: 5000 } }
{ provide: Client, useFactory: (cfg) => new Client(cfg), inject: [Config] }
{ provide: Alias, useExisting: Original }
Через них подменяют реализации — например, мок платёжного шлюза в тестах.
forwardRef(() => OtherService) в обоих местах. Но это признак неверной границы модулей: правильнее вынести общую логику в третий сервис или общаться через события (EventEmitter2).app.useGlobalPipes(new ValidationPipe({
whitelist: true, // выкинуть поля, которых нет в DTO
forbidNonWhitelisted: true, // или вообще отклонить запрос
transform: true, // привести типы: '5' → 5
}));
whitelist: true — прямой аналог $fillable в Laravel: защита от mass assignment. Лишние поля не доедут до сервиса.export class CreateOrderDto {
@IsUUID() productId: string;
@IsInt() @Min(1) quantity: number;
@IsString() @IsOptional() comment?: string;
}
@nestjs/passport + @nestjs/jwt: JwtStrategy валидирует токен, JwtAuthGuard вешается на контроллер или глобально.
// Роли: свой декоратор через SetMetadata + guard, читающий их через Reflector
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete(':id')
remove(@Param('id') id: string) { /* ... */ }
// TypeORM: все запросы внутри идут через manager
await dataSource.transaction(async (manager) => {
const order = await manager.findOneOrFail(Order, {
where: { id },
lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
});
// ...
});
// Prisma
await prisma.$transaction(async (tx) => { /* ... */ });
manager / tx, запрос уйдёт по другому соединению и окажется вне транзакции — молча, без ошибки. В PHP такой проблемы почти нет, потому что там одно соединение на запрос.| Решение | Хранилище | Чем хорошо |
|---|---|---|
BullMQ (@nestjs/bullmq) | Redis | Стандарт экосистемы: ретраи, backoff, отложенные задачи, приоритеты |
| pg-boss, graphile-worker | PostgreSQL | Очередь в той же БД — задачу можно поставить в одной транзакции с бизнес-операцией |
const moduleRef = await Test.createTestingModule({
providers: [PaymentsService, LedgerService],
})
.overrideProvider(PaymentGateway)
.useValue(mockGateway)
.compile();
| Уровень | Инструмент |
|---|---|
| Unit — сервисы | Jest с подменой провайдеров |
| E2E — HTTP | supertest на поднятое приложение |
| Интеграционные | Настоящий Postgres в Docker, testcontainers |
Promise.all на 50 одинаковых вебхуков, проверка что обработка произошла ровно один раз.| Уровень | Что видит транзакция | Цена |
|---|---|---|
READ COMMITTED — по умолчанию | Каждый запрос видит закоммиченное на момент своего старта | Дёшево |
REPEATABLE READ | Снимок на всю транзакцию | При конфликте записи — ошибка сериализации, нужен ретрай |
SERIALIZABLE | Как будто транзакции шли по очереди | Тоже с ретраями, дороже всего |
READ COMMITTED плюс явных блокировок строк или атомарных UPDATE с условием. Поднимать изоляцию до SERIALIZABLE ради одного места — дорого и приносит ретраи во всё приложение.-- Вариант 1: атомарный UPDATE с условием, затем проверка rowCount
UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1;
-- Вариант 2: блокировка строки в транзакции
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;
Плюс идемпотентность операции по ключу: уникальный индекс на idempotency_key.
UPDATE в Postgres сам берёт блокировку строки, а условие balance >= $1 проверяется уже на заблокированной строке. Если денег не хватило, обновится ноль строк — и это надо проверить по rowCount, иначе списание «пройдёт» молча.-- Нарушение уникальности в Postgres = код 23505
-- Либо ловим его, либо:
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
-- Вернулась строка → обрабатываем впервые. Пусто → уже обработано.
-- SKIP LOCKED: берём строку и блокируем, занятые другими — пропускаем
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
SKIP LOCKED построены pg-boss и graphile-worker. Несколько воркеров разбирают одну таблицу задач без конфликтов и без двойной обработки — без внешнего брокера.Платёжные провайдеры доставляют вебхуки как минимум один раз: повторы при таймауте — норма, а не сбой. Обработчик обязан быть идемпотентным.
@Injectable()
export class PaymentsService {
constructor(
private readonly ds: DataSource,
private readonly ledger: LedgerService,
) {}
async applyEvent(evt: PspEvent): Promise<void> {
await this.ds.transaction(async (m) => {
// 1. Дедупликация: второй такой же вебхук ничего не вставит
const fresh = await m.query(
`INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
[evt.id],
);
if (fresh.length === 0) return; // уже обработан
// 2. Блокируем заказ — параллельный вебхук подождёт
const order = await m.findOneOrFail(Order, {
where: { id: evt.orderId },
lock: { mode: 'pessimistic_write' },
});
if (order.status !== 'pending') return;
order.status = 'paid';
await m.save(order);
// 3. Проводки и задача коммитятся ВМЕСТЕ с оплатой
await this.ledger.post(m, [
{ account: 'psp_clearing', debit: evt.amountMinor },
{ account: 'revenue', credit: evt.amountMinor },
]);
await m.insert(Job, { type: 'deliver_key', orderId: order.id });
});
}
}
async requestKey(orderId: string, requestId: string) {
try {
const res = await fetch(`${this.baseUrl}/keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': requestId,
},
body: JSON.stringify({ orderId }),
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return { status: 'error', code: res.status };
return { status: 'ok', key: (await res.json()).key };
} catch (e) {
// Таймаут НЕ значит отказ: операция могла пройти.
if (e instanceof Error && e.name === 'TimeoutError') {
return { status: 'unknown' };
}
throw e;
}
}
unknown, ретрай с тем же Idempotency-Key и сверка.Баланс не хранится изменяемым числом, а считается из проводок. Каждая операция — набор проводок, где сумма дебетов равна сумме кредитов; иначе транзакция не коммитится.
| Правило | Зачем |
|---|---|
Суммы в минорных единицах (bigint) | Никаких float для денег |
| Проводки не изменяются и не удаляются | История неизменна и проверяема |
| Возврат — новая обратная проводка | Не правка старой записи |
| Сверка | Ищет заказы «оплачен, но не выдан» и расхождения с выпиской провайдера |
catch. unhandledRejection в современных версиях Node роняет процесс.uncaughtException. Состояние процесса неопределённо — только логирование и выход.Map — утечка.write() вернул false, а мы пишем дальше — данные копятся в памяти.manager. Уйдёт по другому соединению и окажется вне транзакции — без единой ошибки.forwardRef как норма. Лечит симптом циклической зависимости, но означает неверную границу модулей.ValidationPipe без whitelist. Лишние поля из тела запроса доедут до сервиса и до ORM.enableShutdownHooks. Контейнер убьёт воркер посреди транзакции.UPDATE ... WHERE balance >= $1 без проверки rowCount. Списание не произошло, а код считает, что прошло.Promise.all на параллельные одинаковые запросы.