Надёжный Node.js Backend: обработка ошибок, параллелизм и фоновые джобы

Курс для глубокого освоения проектирования устойчивых Node.js-сервисов: от локальной и глобальной обработки ошибок до безопасного параллельного выполнения и фоновых задач. Разберём практики для чистой Node.js, Express и NestJS, включая транзакции, rate limiting, очереди, планировщики и запуск в кластере/распределённой среде.

1. Фундамент надёжности: async, event loop, backpressure и наблюдаемость

Фундамент надёжности: async, event loop, backpressure и наблюдаемость

Надёжность Node.js backend начинается не с фреймворка (Express/Nest), а с понимания того, как именно ваш код выполняется, где он может «зависнуть», как нагрузка «давит» на сервис и как вы узнаете, что именно сломалось.

В следующих статьях курса мы будем разбирать обработку ошибок в чистой Node.js, Express и Nest, параллелизм (лимиты, транзакции, гонки, идемпотентность), и фоновые джобы. Но прежде нужно заложить фундамент:

  • модель выполнения async-кода в Node.js;
  • устройство event loop и почему один блокирующий участок ломает всю параллельность;
  • backpressure как механизм самозащиты при потоковой обработке;
  • наблюдаемость (observability): логи, метрики, трассировка.
  • !Общая ментальная модель: где выполняется ваш код и почему “async” не равен “параллельно”

    Ментальная модель Node.js: что значит «асинхронно»

    В Node.js обычно выполняется один поток JavaScript. Это означает:

  • Любой CPU-тяжёлый синхронный код блокирует обработку всех запросов.
  • Асинхронность достигается тем, что ожидание I/O (сеть, файлы, таймеры) вынесено из JS-потока в механизмы ОС и/или в libuv.
  • Когда I/O готов, event loop ставит колбэк в очередь, и JS-поток продолжает выполнение.
  • Важно различать:

  • Concurrency (конкурентность): множество операций «в процессе» (переключаемся между ними на ожиданиях).
  • Parallelism (параллелизм): реальное одновременное выполнение (в Node.js — чаще через несколько процессов/контейнеров, worker_threads, либо через внешние системы).
  • Async в Node.js: колбэки, промисы, async/await

    Колбэки и «error-first»

    Исторический стиль Node.js — колбэки формата (err, result). Надёжность здесь определяется дисциплиной:

  • Любая async-функция должна однократно вызвать колбэк.
  • Ошибку нельзя «проглотить»: if (err) return cb(err).
  • Нельзя бросать throw внутри асинхронного колбэка, ожидая, что внешний try/catch поймает это (не поймает).
  • Promises и async/await

    async/await делает код читабельнее, но меняет характер ошибок:

  • Ошибка внутри async превращается в rejected promise.
  • try/catch ловит только то, что вы await.
  • «Забытый await» — один из самых дорогих багов: ошибка уйдёт в unhandledRejection или потеряется в логике.
  • Практические правила:

  • Всегда await промисы, от которых зависит корректность запроса.
  • Параллельные операции запускайте явно: Promise.all, Promise.allSettled.
  • Для независимых задач часто безопаснее Promise.allSettled, чтобы собрать все результаты и все ошибки.
  • Ссылки:

  • Документация Node.js: асинхронность и event loop
  • Документация Node.js: Promises
  • Event loop: очереди, фазы и «почему всё зависло»

    Event loop в Node.js — это цикл, который выполняет JS-колбэки по очередям и фазам. Упрощённо важно понимать:

  • Таймеры (setTimeout, setInterval) выполняются не «точно через N мс», а когда event loop дойдёт до соответствующей фазы.
  • Сетевые события (HTTP) обрабатываются, когда фаза poll получает готовые события.
  • setImmediate выполняется в фазе check (часто «после poll»).
  • Ключевая мысль для надёжности:

  • Event loop выполняет ваш JS-код в одном потоке.
  • Если вы заняли поток на 200 мс синхронной работой, то все запросы получат +200 мс к latency.
  • Microtasks: Promise и process.nextTick

    Есть отдельная «микроочередь» задач:

  • Promise callbacks (.then/.catch/.finally) и queueMicrotask выполняются как microtasks.
  • process.nextTick имеет особый приоритет в Node.js и может «голодать» event loop, если вы рекурсивно планируете nextTick.
  • Практическое правило:

  • Не используйте process.nextTick для регулярного планирования работы под нагрузкой.
  • Если нужно «сдвинуть» работу после I/O — обычно предпочтительнее setImmediate.
  • Ссылка:

  • Документация Node.js: process.nextTick()
  • Как понять, что event loop блокируется

    Симптомы блокировок:

  • резкий рост latency по всем роутам;
  • таймауты клиентов;
  • «скачущий» throughput;
  • метрики показывают рост event loop lag.
  • Измерять лаг можно встроенным инструментом perf_hooks:

    Ссылка:

  • Документация Node.js: perf_hooks.monitorEventLoopDelay
  • Backpressure: самозащита под нагрузкой

    Backpressure — это механизм, который не позволяет быстрым «производителям» данных (например, входящий запрос или чтение файла) перегрузить «потребителя» (например, запись ответа, запись в диск или отправка в другой сервис).

    Без backpressure типовой сценарий аварии выглядит так:

  • вы читаете данные слишком быстро и буферизуете их в памяти;
  • память растёт, начинается GC-шторм;
  • растёт latency, падает throughput;
  • процесс умирает по OOM или становится неотзывчивым.
  • Backpressure в Node.js Streams

    Потоки (Streams) — базовый инструмент backpressure в Node.js.

    Ключевые факты:

  • У Writable есть внутренний буфер.
  • writable.write(chunk) возвращает false, если буфер переполнен.
  • Когда буфер освобождается, поток эмитит событие drain.
  • Правильный способ «прокачивать» данные между потоками — pipeline, который:

  • корректно прокидывает ошибки;
  • закрывает потоки;
  • уважает backpressure.
  • Ссылки:

  • Документация Node.js: Streams
  • Документация Node.js: stream.pipeline
  • Backpressure в HTTP обработке

    В Node.js req и res — тоже потоки. Надёжный сервер под нагрузкой обычно следует принципам:

  • не читать тело запроса целиком в память без лимитов;
  • ограничивать размер входящих данных;
  • стримить туда, где возможно (например, проксирование, загрузка в S3-подобное хранилище);
  • ставить таймауты и прерывать зависшие соединения.
  • Если вы строите API, где клиент может прислать гигабайт данных, то «наивное» await readAll(req) — почти гарантированный путь к проблемам.

    Наблюдаемость: как перестать дебажить вслепую

    Надёжность — это не только «не падать», но и «быстро понимать, что происходит».

    Наблюдаемость обычно опирается на три столпа:

  • логи (что произошло, контекст, ошибки);
  • метрики (числа и распределения во времени);
  • трассировка (путь запроса через сервисы).
  • Логи: структурно и с корреляцией

    Рекомендации для backend под нагрузкой:

  • Логи должны быть структурированными (JSON), чтобы их можно было агрегировать.
  • У каждого запроса должен быть requestId (корреляционный идентификатор), который попадает во все логи этого запроса.
  • В логах нельзя хранить секреты и персональные данные без маскирования.
  • Популярный логгер:

  • Pino
  • Метрики: измеряем то, что ломается первым

    Минимальный «набор выживания»:

  • RPS/throughput, доли 2xx/4xx/5xx;
  • latency (p50/p95/p99);
  • event loop lag;
  • память (RSS/heap), частота и паузы GC;
  • число активных соединений;
  • очереди/пулы (например, пул соединений к БД).
  • Если вы хотите прометеевский стек, типичный инструмент:

  • prom-client
  • Трассировка: почему один запрос «медленный»

    Трассировка отвечает на вопрос: в каком месте и в каком сервисе запрос потерял время.

    Современный стандарт — OpenTelemetry:

  • OpenTelemetry для Node.js: getting started
  • Практическая цель трассировки в этом курсе:

  • уметь связать ошибку и деградацию latency с конкретным внешним вызовом, SQL-запросом, джобой или блокировкой.
  • Диагностика в Node.js: что включать в продакшене осознанно

    Полезные опции и инструменты:

  • --trace-uncaught для диагностики необработанных исключений;
  • политика для unhandled rejections (подход зависит от вашей стратегии рестартов);
  • профилирование и диагностика на стенде:
  • Ссылки:

  • Документация Node.js: CLI options
  • Clinic.js
  • Мини-набор практик надёжности, которые опираются на фундамент

    Эти практики мы будем разворачивать в следующих статьях, но они уже завязаны на понимание event loop, backpressure и наблюдаемость:

  • Таймауты на внешние запросы (иначе «подвисшие» операции съедают ресурсы).
  • Лимиты конкурентности (bulkhead): нельзя бесконечно параллелить запросы к БД/внешнему API.
  • Очереди и буферизация там, где это оправдано, плюс контроль backpressure.
  • Идемпотентность для повторов (ретраи, джобы) и защита от дублей.
  • Измеримость: если нет метрики — вы не управляете системой.
  • Что будет дальше в курсе

    Следующая логическая ступень после фундамента:

  • локальная и глобальная обработка ошибок в чистой Node.js;
  • затем Express и Nest: middleware/filters, централизованные обработчики, правильные статусы и структуры ошибок;
  • параллельность: лимиты, транзакции, защита от гонок, rate limits;
  • фоновые джобы: одиночные/мульти-инстанс стратегии, блокировки, расписания, очереди.
  • 2. Error handling в чистой Node.js: локально, глобально, доменные ошибки

    Error handling в чистой Node.js: локально, глобально, доменные ошибки

    Надёжность — это способность сервиса предсказуемо вести себя при сбоях: возвращать корректные ответы, не «подвешивать» event loop, не терять контекст ошибки и быстро восстанавливаться. В предыдущей статье мы заложили фундамент: как работает async и event loop, почему блокировки ломают latency, что такое backpressure и почему без наблюдаемости вы дебажите вслепую.

    Теперь соберём следующий слой: обработку ошибок в чистой Node.js без Express и Nest. Эта статья нужна, чтобы вы одинаково уверенно чувствовали себя и в голом http.createServer, и внутри любого фреймворка, понимая, где именно возникают ошибки и почему они иногда «не ловятся».

    !Карта, где именно «живут» ошибки в чистой Node.js

    Что считать ошибкой в Node.js backend

    Для надёжного дизайна полезно разделять ошибки на две категории:

  • Операционные ошибки: ожидаемые сбои среды и зависимостей. Например: таймаут к БД, 503 от внешнего API, ошибка чтения файла, неверный JSON от клиента. Их можно обработать, вернуть контролируемый ответ (или сделать retry), и сервис продолжает работать.
  • Ошибки программиста: баги в коде. Например: TypeError из-за undefined, нарушение инварианта, забытый await, использование неинициализированной переменной. Такие ошибки часто делают процесс небезопасным для продолжения, потому что состояние может быть частично повреждено.
  • Эта граница не всегда идеальна, но она помогает принять ключевое решение: мы отвечаем клиенту и продолжаем или мы считаем ошибку фатальной и перезапускаем процесс.

    Полезная база по ошибкам в Node.js:

  • Node.js: Errors
  • Локальная обработка ошибок: самый дешёвый контроль

    try/catch и await: что именно ловится

    try/catch в async-коде ловит:

  • синхронные throw внутри блока;
  • rejection промиса, который вы await.
  • Но он не ловит ошибки, которые происходят в другом асинхронном тике, если вы не await конкретный промис.

    Пример корректной локальной обработки:

    Здесь мы добавили контекст через cause. Это важно для наблюдаемости: верхний слой видит бизнес-сообщение, а внутри остаётся первопричина.

  • Node.js: Error cause
  • Типичная ловушка: «забытый await»

    Если doImportantThing() вернёт rejected promise, ошибка уйдёт в unhandledRejection (или будет обработана где-то ещё), а ваш код уже «успешно» продолжил выполнение.

    Практика: если операция критична для корректности запроса, она должна быть либо await, либо явно включена в Promise.all(...).

    Обработка ошибок в колбэках и таймерах

    try/catch вокруг места, где вы планируете колбэк, не ловит ошибки внутри колбэка:

    Если вы пишете колбэки, то обработка должна быть внутри колбэка:

    Но на практике в backend лучше минимизировать «чистые» колбэки и держаться async/await, чтобы не терять управляемость ошибок.

    Node-style callbacks: error-first

    Если вы работаете с API вида (err, result), правило простое: ошибка должна быть проверена первой и обработана ровно один раз.

    EventEmitter: особое правило события error

    Во многих частях Node.js (стримы, сокеты, сервер) используются события. У EventEmitter есть специальное соглашение:

  • если эмитится событие error, а слушателя нет, процесс получит исключение и может упасть.
  • Пример:

    Ссылка:

  • Node.js: EventEmitter error events
  • Практика для надёжности:

  • если объект может эмитить error (стрим, соединение, клиент БД), у него всегда должен быть обработчик;
  • лучше централизовать обработку ошибок на границах (HTTP запрос, стрим pipeline, воркер), чем пытаться «поймать всё» в глубине.
  • Streams: ошибки и backpressure через pipeline

    Из фундамента курса: стримы дают backpressure. Но не менее важно, что у стримов сложная жизненная цикл-логика и много событий. Ручное «склеивание» стримов через .pipe() часто приводит к утечкам и пропущенным ошибкам.

    Для надёжного поведения используйте pipeline, который:

  • уважает backpressure;
  • корректно прокидывает ошибки;
  • закрывает цепочку при сбое.
  • Ссылка:

  • Node.js: stream.pipeline
  • Доменные ошибки: как отделить «неправильный запрос» от «сломались мы»

    Доменные ошибки — это ошибки вашей предметной области и правил системы. Они отличаются тем, что:

  • являются ожидаемыми;
  • обычно означают, что запрос клиента некорректен в рамках правил;
  • должны превращаться в стабильный, предсказуемый формат ответа (часто 4xx), без утечек внутренних деталей.
  • Важно: речь не про устаревший модуль domain в Node.js, а про domain errors как часть архитектуры.

    Шаблон: базовый класс доменной ошибки

    Практика:

  • Сообщение ошибки может быть человекочитаемым.
  • code должен быть стабильным контрактом для клиента и фронтенда.
  • details должны быть безопасными (без секретов и внутренней инфраструктуры).
  • cause позволяет сохранить цепочку причин для логов.
  • Где создавать доменные ошибки

    Правило надёжной архитектуры:

  • доменные ошибки создаются там, где принимается бизнес-решение.
  • Например:

  • слой валидации запроса кидает ValidationError;
  • слой бизнес-логики кидает ConflictError, если нарушено правило уникальности;
  • слой доступа к данным может кидать «технические» ошибки (таймаут, соединение), которые выше будут преобразованы в 500.
  • Глобальная обработка: последний рубеж процесса

    Глобальные обработчики нужны не для того, чтобы «всё починить», а чтобы:

  • залогировать фатальную ситуацию с максимумом контекста;
  • корректно завершить процесс, чтобы оркестратор (systemd, Docker, Kubernetes) поднял чистый экземпляр;
  • не оставлять процесс в полу-живом состоянии.
  • unhandledRejection

    Событие процесса, когда промис был отклонён, но обработчика не оказалось.

  • Node.js: process event unhandledRejection
  • Пример:

    uncaughtException

    Событие процесса, когда исключение произошло и «всплыло» до самого верха.

  • Node.js: process event uncaughtException
  • Пример:

    Практическая позиция для backend:

  • uncaughtException почти всегда означает, что процессу нельзя доверять.
  • unhandledRejection в зрелых системах тоже часто ведёт к завершению процесса: лучше быстрый рестарт, чем тихая порча состояния.
  • Но завершать процесс нужно осознанно: дайте логам/трейсам успеть отправиться, закройте сервер для новых соединений.

    Централизованная обработка ошибок в http.createServer

    В чистой Node.js нет «error middleware», но вы можете построить понятный каркас: роутер, обёртка, единый формат ошибок.

    Ниже пример минимального сервера с:

  • единым handleError;
  • поддержкой доменных ошибок;
  • requestId (упрощённо через заголовок, чтобы связать с логами из статьи про наблюдаемость);
  • защитой от повторной отправки ответа.
  • Ключевые моменты:

  • Централизация не отменяет локальную обработку. Локально вы добавляете контекст и превращаете «сырой» сбой в доменную ошибку там, где это уместно.
  • Всё, что не доменное, превращается в 500 без утечки деталей наружу.
  • Контекст (requestId) включается и в ответ, и в логи, чтобы можно было найти конкретный запрос.
  • Стратегия: где обрабатывать, а где падать

    На практике полезно договориться о правилах:

  • На границе запроса (HTTP handler) всегда есть try/catch, который превращает ошибки в ответ.
  • В домене используйте типизированные ошибки с code и statusCode.
  • В инфраструктуре (клиенты БД/HTTP) добавляйте cause и технические поля (timeoutMs, host), но не отдавайте их клиенту.
  • Глобальные хендлеры процесса существуют как аварийный контур: лог + корректное завершение процесса.
  • Связь со следующим модулем курса

    В этой статье вы построили базовую модель ошибок без фреймворков. Дальше мы перенесём те же принципы в:

  • Express: middleware-цепочка, next(err), обработка ошибок в async-роутах;
  • Nest: exception filters, пайпы валидации, единая форма ошибок.
  • А затем эти же идеи напрямую повлияют на параллелизм и фоновые джобы:

  • retry и идемпотентность невозможны без чёткой классификации ошибок;
  • безопасное распараллеливание требует понимания, какие ошибки можно «поглотить», а какие должны валить весь процесс;
  • джобы почти всегда нуждаются в доменных ошибках (ожидаемые) и аварийном контуре (неожиданные).
  • 3. Error handling в Express: middleware, централизованные ошибки, best practices

    Error handling в Express: middleware, централизованные ошибки, best practices

    Express часто воспринимают как простой HTTP-фреймворк, но его реальная сила для надёжности — в предсказуемом конвейере middleware и возможности централизованно преобразовывать ошибки в корректные HTTP-ответы.

    В предыдущих статьях курса мы:

  • разобрали фундамент выполнения async-кода и наблюдаемость (event loop, backpressure, метрики/логи);
  • построили модель локальной и глобальной обработки ошибок в чистой Node.js, включая доменные ошибки.
  • Теперь перенесём те же идеи в Express: где именно ловить ошибки, как сделать единый формат ошибок, что делать с async/await, как не отправить второй ответ и как связать ошибку с конкретным запросом.

    !Схема потока запроса и маршрута ошибки в Express

    Ментальная модель Express: конвейер middleware

    Express обрабатывает запрос как цепочку функций. Каждая функция решает:

  • продолжить цепочку через next();
  • завершить запрос, отправив ответ через res.*;
  • передать ошибку через next(err).
  • Ключевое правило надёжности:

  • Ошибки должны быть превращены в ответ на границе запроса, то есть в error middleware.
  • Полезные первоисточники:

  • Express: Using middleware
  • Express: Error handling
  • Какие ошибки мы обрабатываем: операционные и баги

    Из статьи про чистую Node.js переносим классификацию:

  • Операционные ошибки — ожидаемые сбои окружения и зависимостей (таймауты, 503, ошибки валидации, конфликт уникальности). Их можно безопасно превратить в контролируемый ответ (4xx/5xx).
  • Ошибки программиста — нарушения инвариантов и баги (TypeError, забытый await, неправильные предположения). Часто правильнее быстро рестартовать процесс, чем продолжать в потенциально повреждённом состоянии.
  • В Express централизованный обработчик должен:

  • вернуть клиенту стабильный формат ошибки;
  • не утекать внутренними деталями;
  • логировать максимум контекста для диагностики;
  • корректно отработать ситуацию, когда ответ уже частично отправлен.
  • Локальная обработка ошибок: добавляем контекст, но не прячем проблему

    Express не отменяет локальные try/catch. Они нужны, чтобы:

  • добавить контекст (например, orderId, userId, timeoutMs);
  • превратить «техническую» ошибку в доменную там, где принимается бизнес-решение.
  • Пример: оборачиваем ошибку доступа к репозиторию в доменную NotFound.

    Идея: локально мы формулируем бизнес-смысл. Глобально (в error middleware) — превращаем это в HTTP.

    Централизованный error middleware: базовый каркас

    В Express error middleware отличается сигнатурой: четыре аргумента.

    Важны три правила:

  • error middleware должен быть подключён после всех роутов;
  • если res.headersSent === true, нельзя пытаться отправить новый ответ;
  • в ответе нельзя возвращать сырые err.message и err.stack для неожиданных ошибок.
  • Пример: единый формат ошибки + requestId

    Ниже минимальная, но практичная схема:

  • middleware для requestId (в идеале — корреляция с логами и трассировкой);
  • доменные ошибки с code и statusCode;
  • обработка неизвестных ошибок как 500;
  • безопасный ответ клиенту.
  • Замечания:

  • express.json() сам генерирует ошибку при невалидном JSON. Эта ошибка тоже попадёт в errorHandler.
  • 404-ответ лучше делать после роутов, но до error middleware, потому что это не исключение, а обычный контролируемый ответ.
  • Async/await и Express: почему ошибки «не ловятся»

    Express исторически ориентирован на синхронные handler’ы. Поэтому важная ловушка:

  • если вы бросили исключение внутри async-handler’а, Express в зависимости от версии и способа подключения может не поймать это автоматически.
  • Рекомендованный и предсказуемый подход — обёртка для async-роутов, которая гарантированно прокидывает ошибку в next(err).

    Стандартная обёртка asyncHandler

    Это защищает от:

  • «потерянных» rejected promise;
  • необходимости писать try/catch в каждом handler’е только ради передачи в next.
  • Альтернатива: патчинг Express для async ошибок

    Существует пакет, который патчит Express и автоматически прокидывает ошибки из async handler’ов:

  • express-async-errors
  • Сравнение подходов:

    | Подход | Плюсы | Минусы | | --- | --- | --- | | asyncHandler-обёртка | Явно видно, где async; легко контролировать; не меняет поведение Express глобально | Нужно дисциплинированно оборачивать все async-роуты | | express-async-errors | Меньше шаблонного кода | Глобальный патч; не всем командам подходит по принципам предсказуемости |

    Практика для курса: используйте asyncHandler как базовый механизм и понимайте, что происходит внутри.

    Ошибки от middleware: валидация, парсинг, лимиты

    Express middleware часто является источником ошибок:

  • express.json() может выбросить ошибку парсинга JSON;
  • лимит тела запроса важен для надёжности и защиты от OOM;
  • валидация входных данных должна давать стабильный 4xx.
  • Пример: превращаем ошибку JSON-парсинга в 400

    У ошибок от express.json() есть характерные признаки (например, type и status), но полагаться на частные поля стоит осторожно. Практичнее:

  • считать это ошибкой клиента;
  • вернуть стабильный код INVALID_JSON.
  • Если вы хотите строгое поведение, лучше валидировать req.body на уровне домена и бросать свою ValidationError.

    Никогда не отправляйте второй ответ: headersSent и контроль потока

    Типичная авария в Express под нагрузкой:

  • часть middleware уже начала писать ответ;
  • затем случилась ошибка;
  • error middleware пытается отправить JSON с ошибкой;
  • вы получаете Error: Can't set headers after they are sent и ухудшаете диагностику.
  • Практические правила:

  • если вы начали стримить ответ, продумайте стратегию: либо всегда формируете ответ целиком, либо стримите и понимаете, что ошибка после начала стрима может означать разрыв соединения;
  • в error middleware всегда проверяйте res.headersSent и делайте return next(err);
  • используйте ранние return, чтобы не «проваливаться» дальше по коду после res.json(...).
  • Единый контракт ошибок для клиентов

    Для фронтенда и интеграций важны стабильность и простота.

    Рекомендуемая форма ответа:

    Рекомендации:

  • code — стабильный машинный код, на который можно завязать логику клиента.
  • message — человекочитаемое сообщение, но без внутренних деталей.
  • details — безопасные поля для диагностики клиентом (например, какие поля невалидны).
  • requestId — ключ к поиску логов и трасс.
  • Логи и контекст: что писать при ошибках

    Чтобы ошибки были полезны, логируйте структурно:

  • requestId
  • method, url
  • statusCode
  • durationMs
  • err.name, err.message, err.stack
  • err.cause (если используете new Error(..., { cause }))
  • Если вы хотите промышленный JSON-логгер, часто используют:

  • Pino
  • Важно:

  • не логируйте секреты (токены, пароли);
  • маскируйте персональные данные;
  • не дублируйте гигантские payload’ы.
  • Как связать Express-ошибки с глобальными обработчиками процесса

    Express error middleware — граница запроса. Но остаётся аварийный контур процесса:

  • process.on('uncaughtException', ...)
  • process.on('unhandledRejection', ...)
  • Они нужны не для того, чтобы «починить всё», а чтобы:

  • залогировать фатальную ситуацию;
  • инициировать корректное завершение процесса;
  • дать оркестратору (Docker/Kubernetes/systemd) поднять чистый экземпляр.
  • Ссылки:

  • Node.js: process event uncaughtException
  • Node.js: process event unhandledRejection
  • Практическая позиция для backend:

  • если вы поймали uncaughtException, чаще всего безопаснее завершить процесс после логирования;
  • если у вас случился unhandledRejection, считайте это дефектом в обработке ошибок и тоже рассматривайте завершение процесса.
  • Best practices: чеклист для надёжного Express

  • Подключайте requestId middleware как можно раньше и возвращайте x-request-id в ответ.
  • Всегда ставьте лимиты на express.json() и другие body-parser’ы.
  • Для async handler’ов используйте asyncHandler и прокидывайте ошибки через next.
  • Держите централизованный errorHandler последним middleware.
  • Превращайте доменные ошибки в предсказуемые 4xx/409/404, а всё неизвестное — в 500 без утечки деталей.
  • Проверяйте res.headersSent в error middleware.
  • Логируйте структурно и с контекстом, но не логируйте секреты.
  • Держите глобальные обработчики процесса как аварийный контур и определите политику рестартов.
  • Связь со следующими темами курса

    Express-ошибки напрямую связаны с параллелизмом и джобами:

  • лимиты конкурентности и rate limiting невозможны без ясных 429/503 и предсказуемого формата ошибок;
  • идемпотентность и повторные попытки (ретраи) требуют классификации ошибок на можно повторить и нельзя повторить;
  • фоновые джобы должны использовать те же принципы: доменные ошибки как ожидаемые, неожиданные — как сигнал к алерту и, возможно, рестарту воркера.
  • В следующем модуле мы перенесём этот подход на NestJS, где роль error middleware выполняют exception filters, а также рассмотрим, как стандартизировать ошибки между HTTP-обработчиками и фоновыми воркерами.

    4. Error handling в NestJS: filters, interceptors, pipes и исключения

    Error handling в NestJS: filters, interceptors, pipes и исключения

    В предыдущих статьях курса мы построили «сквозную» модель надёжности:

  • как Node.js исполняет async-код, почему блокировки event loop ломают latency, и зачем нужны наблюдаемость и backpressure;
  • как в чистой Node.js разделять доменные и технические ошибки и почему глобальные хендлеры процесса это аварийный контур;
  • как в Express делать централизованную обработку ошибок через middleware, единый контракт ошибок и requestId.
  • Теперь переносим те же принципы в NestJS. Nest уже даёт архитектурные крючки для надёжного error handling:

  • исключения и HttpException как базовый механизм для управляемых ошибок;
  • pipes для валидации и трансформации входных данных;
  • exception filters для централизованного преобразования ошибок в HTTP-ответ и для безопасного логирования;
  • interceptors для сквозного контекста, измерений и аккуратной обработки ошибок в потоках.
  • !Конвейер NestJS и места, где возникают и обрабатываются ошибки

    Ментальная модель NestJS: где живёт ошибка

    Nest описывает жизненный цикл запроса как набор этапов и расширений. Важное практическое следствие:

  • Pipes работают до вызова handler и часто являются источником 400-ошибок (валидация, трансформация).
  • Exception filters это «последняя линия» внутри Nest, где вы централизованно превращаете ошибку в ответ.
  • Interceptors могут выполнять код до и после handler и удобны для сквозных вещей: requestId, измерение времени, нормализация ответа, а в случае Observable-потоков ещё и обработка ошибок через RxJS.
  • Официальный первоисточник по жизненному циклу:

  • NestJS Request lifecycle
  • Исключения в NestJS: что бросать и как это превращается в HTTP

    HttpException и встроенные HTTP-исключения

    Если вы бросаете HttpException, Nest сформирует HTTP-ответ с нужным статусом.

  • throw new HttpException(body, status)
  • или более конкретно: BadRequestException, NotFoundException, ConflictException, UnauthorizedException и другие.
  • Ссылки:

  • NestJS Exceptions
  • Пример контролируемой ошибки:

    Здесь важно различать две цели:

  • что увидит клиент: стабильный контракт (code, message, details);
  • что увидят логи: исходная причина через cause, стек, контекст запроса.
  • Доменные ошибки и единый контракт: подход из предыдущих модулей

    Даже в Nest полезно иметь доменные ошибки как отдельные классы, чтобы бизнес-логика не зависела от HTTP.

    Минимальная практика:

  • в домене бросать свои DomainError;
  • на границе HTTP преобразовывать их в HttpException или обрабатывать в exception filter.
  • Пример доменной ошибки:

    Смысл такой же, как в чистой Node.js и Express:

  • бизнес-слой формулирует смысл сбоя;
  • HTTP-слой формулирует статус и формат ответа.
  • Pipes: валидация и трансформация как управляемый источник 400

    Pipes в Nest решают две практические задачи:

  • валидация входных данных;
  • трансформация (например, строка в число).
  • Ссылки:

  • NestJS Pipes
  • NestJS Validation
  • ValidationPipe: базовая конфигурация для надёжности

    На практике чаще всего включают глобальный ValidationPipe, чтобы не размазывать валидацию по контроллерам.

    Что означают ключевые опции:

  • transform: true позволяет преобразовывать типы на входе (например, query-параметры в число) с помощью class-transformer.
  • whitelist: true удаляет лишние поля, которых нет в DTO.
  • forbidNonWhitelisted: true делает лишние поля ошибкой (часто это лучше для безопасности и предсказуемости контракта).
  • DTO пример:

    Ссылки на библиотеки, которые реально используются Nest-пайпами:

  • class-validator
  • class-transformer
  • Как сделать ошибки валидации «клиентскими» и стабильными

    По умолчанию ValidationPipe бросает BadRequestException с массивом ошибок. Это уже 400, но формат часто неудобен для фронтенда.

    Надёжный подход:

  • централизованно преобразовать ошибки валидации в ваш контракт (code: VALIDATION_ERROR, нормализованные details).
  • делать это в exception filter, чтобы контроллеры оставались чистыми.
  • Exception filters: централизованный контроль ошибок в Nest

    Exception filters в Nest это аналог error middleware из Express, но более структурированный.

    Ссылка:

  • NestJS Exception filters
  • Базовые правила централизованного фильтра

    Надёжный фильтр должен:

  • обрабатывать HttpException как «управляемые» ошибки;
  • обрабатывать доменные ошибки (DomainError) как управляемые;
  • всё остальное считать 500 и не утекать деталями наружу;
  • логировать структурно с контекстом (requestId, route, status, stack, cause);
  • учитывать, что в распределённой системе клиенту нужен идентификатор для поиска логов.
  • Пример: глобальный фильтр с единым форматом ответа

    Ниже пример для HTTP (Express adapter по умолчанию). Он показывает ключевую идею: в одном месте формируем контракт ответа.

    Подключение фильтра глобально в main.ts:

    Почему глобальный фильтр это «правильная точка»:

  • он является границей HTTP так же, как error middleware в Express;
  • вы обеспечиваете единый контракт ошибки для всех контроллеров;
  • вы не размазываете сериализацию ошибок по бизнес-коду.
  • Локальные filters: когда они уместны

    Глобальный фильтр должен быть базовым. Локальные filters полезны, когда:

  • у конкретного контроллера есть особый контракт ошибок;
  • вы хотите «перевести» специфические ошибки инфраструктуры в доменные только в одном модуле.
  • Interceptors: контекст, метрики и обработка ошибок в потоках

    Interceptors в Nest это механизм вокруг handler, который работает и с Promise, и с Observable.

    Ссылка:

  • NestJS Interceptors
  • Типовые задачи интерсепторов для надёжности

    Интерсепторы особенно полезны для:

  • измерения длительности запроса и логирования;
  • прокидывания requestId в контекст;
  • унификации формата успешного ответа;
  • обработки ошибок, если handler возвращает Observable.
  • Пример интерсептора, который логирует время и добавляет requestId:

    Замечание про RxJS: если вы обрабатываете ошибки в интерсепторе, используйте catchError.

    Ссылка:

  • RxJS catchError
  • Практическое правило разделения ответственности:

  • interceptor добавляет контекст и измерения;
  • exception filter решает, какой HTTP-ответ вернуть.
  • Это делает поведение системы предсказуемым.

    Практика: где именно превращать технические ошибки в доменные

    Типичная ситуация: слой БД или внешний HTTP-клиент возвращает «сырой» сбой. Надёжный backend не должен отдавать такие детали наружу.

    Рекомендованный поток:

  • в репозитории или адаптере добавить контекст через cause и поля вроде timeoutMs, provider;
  • на уровне use-case превратить часть ошибок в доменные, если это бизнес-смысл;
  • в глобальном filter превратить остальное в 500.
  • Пример: оборачиваем сбой уникальности в доменную 409.

    Дальше глобальный filter решит, что ConflictError это 409 с безопасными details.

    Глобальные ошибки процесса: Nest не отменяет аварийный контур

    Даже если в Nest идеально настроены filters, остаются ошибки, которые могут:

  • случиться вне контекста запроса;
  • быть результатом бага, который приводит к uncaughtException или unhandledRejection.
  • Поэтому стратегия из статьи про чистую Node.js остаётся актуальной:

  • process.on('uncaughtException', ...) логирует и инициирует завершение процесса;
  • process.on('unhandledRejection', ...) сигнализирует о дыре в обработке async-ошибок.
  • Ссылки:

  • Node.js process event uncaughtException
  • Node.js process event unhandledRejection
  • Nest помогает на уровне HTTP-конвейера, но не является заменой политики рестартов и наблюдаемости.

    Чеклист надёжного error handling в NestJS

  • Выберите единый контракт ошибок: requestId + error.code + error.message + error.details.
  • Подключите глобальный ExceptionFilter, который:
  • - обрабатывает HttpException. - обрабатывает доменные ошибки. - всё остальное превращает в 500 без утечки деталей.
  • Включите глобальный ValidationPipe и централизованно нормализуйте его ошибки.
  • Используйте interceptors для requestId, логирования длительности и метрик.
  • Локально оборачивайте ошибки зависимостей через cause, но не отдавайте их клиенту.
  • Держите глобальные обработчики процесса как аварийный контур и определите стратегию рестартов.
  • Связь со следующими темами курса

    Эта статья напрямую готовит вас к параллелизму и джобам:

  • rate limit и bulkhead-подход требуют стабильных 429 и 503 и понятных кодов ошибок;
  • ретраи невозможны без классификации ошибок на повторяемые и неповторяемые;
  • фоновые воркеры должны использовать те же доменные ошибки и тот же формат логирования, что и HTTP.
  • Дальше в курсе мы начнём разбирать параллельное выполнение: лимиты конкурентности, транзакции, идемпотентность и защиту от гонок.

    5. Параллельная обработка запросов: гонки, идемпотентность, rate limiting и транзакции

    Параллельная обработка запросов: гонки, идемпотентность, rate limiting и транзакции

    Надёжный backend почти всегда обрабатывает несколько запросов одновременно (конкурентно): пока один запрос ждёт БД или внешнее API, другой уже выполняется. В предыдущих модулях курса мы построили основу для предсказуемого поведения при сбоях (локальная и глобальная обработка ошибок в Node.js, Express, Nest). Теперь добавим второй слой надёжности: как сделать так, чтобы параллельность не приводила к гонкам, дублированию операций, перегрузке зависимостей и повреждению данных.

    !Где появляется риск при параллельных запросах и какими механизмами он контролируется

    Что значит «параллельно» в Node.js backend

    Node.js исполняет JavaScript в одном потоке, но это не делает систему «последовательной». Под нагрузкой у вас конкурентно выполняются десятки и сотни запросов, потому что каждый запрос большую часть времени ждёт I/O.

    Ключевая мысль:

  • конкурентность запросов создаёт конкуренцию за общие ресурсы: записи в БД, уникальные ключи, лимиты внешних API, соединения, кэш, файлы
  • большинство «поломок» под нагрузкой — это не падения процесса, а нарушение инвариантов данных из‑за гонок и повторов
  • Какие проблемы приносит конкурентность

    Гонки и потерянные обновления

    Гонка — когда результат зависит от порядка выполнения конкурентных операций.

    Типичные симптомы:

  • двойное списание денег
  • два одинаковых заказа
  • счётчик обновился неправильно
  • «последняя запись победила», а предыдущая silently потерялась
  • Перегрузка зависимостей

    Если каждый входящий запрос запускает 20 параллельных вызовов к БД или внешнему API, под ростом RPS вы быстро получаете:

  • исчерпание пула соединений к БД
  • рост таймаутов
  • каскадный отказ (одна медленная зависимость «тянет вниз» весь сервис)
  • Повторы (retries) и дублирование операций

    Повторы возникают даже если вы их не писали:

  • клиент может повторить запрос по таймауту
  • балансировщик или gateway может ретраить
  • пользователь нажмёт кнопку дважды
  • Без идемпотентности повторы превращаются в дубли.

    Гонки в Node.js: почему «один поток» не спасает

    Ошибочная интуиция: «у нас один поток, значит гонок нет». На практике гонки возникают между асинхронными участками.

    Пример: «проверить и создать» как гонка

    Два запроса одновременно делают findByEmail, оба видят «нет пользователя», оба делают insert.

    Правильное мышление:

  • любые два await — это потенциальная точка переключения
  • «проверка» и «действие» должны быть атомарны на уровне хранилища или через блокировку
  • Инструменты контроля конкурентности

    Надёжная система обычно сочетает несколько механизмов:

  • ограничение скорости (rate limiting): сколько запросов вообще допускаем
  • ограничение конкурентности (bulkhead): сколько операций одновременно выполняем к конкретной зависимости
  • идемпотентность: безопасные повторы без дублей
  • транзакции и блокировки БД: атомарность, консистентность
  • уникальные ограничения и версионирование: защита на уровне данных
  • Важно различать:

  • rate limiting отвечает на вопрос: сколько запросов в единицу времени
  • concurrency limiting отвечает на вопрос: сколько запросов/операций одновременно
  • Ограничение конкурентности: bulkhead для БД и внешних API

    Зачем нужен concurrency limit

    Если внешнее API начинает отвечать медленно, каждый запрос удерживает ресурсы дольше. Без лимита вы накапливаете всё больше одновременных ожиданий и превращаете деградацию в лавину.

    Практика:

  • ставьте лимиты отдельно для каждой зависимости
  • не делайте Promise.all на большие списки без лимита
  • Простой семафор в чистой Node.js

    Ниже минимальная реализация семафора, чтобы ограничить количество одновременных операций.

    Что это даёт:

  • верхняя граница одновременных вызовов
  • предсказуемое потребление ресурсов
  • меньше таймаутов из-за «саморазгона» нагрузки
  • Риски:

  • очередь ожидания может расти бесконечно, если вы не ставите таймауты
  • Полезная практика из предыдущего модуля про observability:

  • метрика «длина очереди семафора» и «время ожидания слота»
  • Rate limiting: защищаем вход, а не только зависимости

    Rate limiting нужен, чтобы:

  • защититься от всплесков и злоупотреблений
  • держать нагрузку в пределах, при которых система остаётся управляемой
  • гарантировать справедливость между клиентами
  • Базовый контракт для API

    Обычно:

  • статус 429 Too Many Requests
  • заголовок Retry-After (в секундах или HTTP-date)
  • Спецификация:

  • HTTP 429 (RFC 6585)
  • Retry-After (MDN)
  • Алгоритмы rate limiting

    Три самых используемых подхода:

  • Fixed window: проще всего, но даёт всплеск на границе окна
  • Sliding window: ровнее, но сложнее
  • Token bucket: хороший баланс, позволяет кратковременные всплески
  • На уровне курса важнее не математические детали, а инженерные выводы:

  • если у вас много инстансов, лимитер должен хранить состояние в общем хранилище (например, Redis)
  • если лимитер локальный, каждый инстанс будет пропускать свою долю, и глобальный лимит «поедет» вверх
  • Где ставить rate limiting

    Практически всегда лучше располагать лимит как можно ближе к краю:

  • API gateway / ingress
  • reverse proxy
  • приложение (как второй контур)
  • С точки зрения приложения:

  • в Express часто используют express-rate-limit
  • Важно:

  • rate limiting должен возвращать предсказуемый формат ошибки (как мы делали в Express/Nest статьях)
  • решение о лимите должно логироваться с requestId и ключом лимитирования (ip/user/token)
  • Идемпотентность: как сделать повтор безопасным

    Что такое идемпотентность

    Запрос идемпотентен, если повтор с теми же параметрами не меняет итоговое состояние больше одного раза.

    Важно не путать:

  • идемпотентность не означает «запрос всегда успешен»
  • идемпотентность означает «повтор не создаёт дубль»
  • HTTP-методы:

  • GET должен быть идемпотентным по смыслу
  • POST обычно не идемпотентен, поэтому для критичных операций его делают идемпотентным через ключ
  • Хороший практический ориентир (индустриальный стандарт поведения):

  • Stripe: Idempotency
  • Идемпотентный ключ

    Типовой контракт:

  • клиент присылает Idempotency-Key: <uuid>
  • сервер хранит запись о результате выполнения по этому ключу
  • при повторе сервер возвращает тот же результат (или то же доменное состояние)
  • Ключевые детали, без которых механизм ломается:

  • ключ должен быть уникален в рамках бизнес-операции (например, «создание платежа»)
  • у ключа должен быть TTL, чтобы хранилище не росло бесконечно
  • желательно связывать ключ с клиентом (userId/merchantId), чтобы избежать атак угадыванием
  • Минимальная схема хранения (паттерн «идемпотентный реестр»)

    Одна из рабочих схем:

  • таблица idempotency_keys
  • поля: key, scope (например, userId), requestHash, status, responseBody, createdAt
  • уникальный индекс на (scope, key)
  • Поток:

  • начать транзакцию
  • попытаться вставить ключ
  • если вставка не удалась из-за уникальности, прочитать сохранённый результат и вернуть его
  • если вставка удалась, выполнить бизнес-операцию
  • сохранить результат и закоммитить
  • Это связывает идемпотентность и транзакции: без транзакции легко получить «ключ записали, операцию не сделали» или наоборот.

    Транзакции: атомарность и защита инвариантов данных

    Транзакция нужна, когда несколько операций в БД должны быть выполнены как единое целое.

    Свойства ACID на практике:

  • атомарность: либо всё, либо ничего
  • консистентность: инварианты данных сохраняются
  • изоляция: параллельные транзакции не видят «грязных» промежуточных состояний
  • долговечность: после коммита данные не пропадут
  • Ориентир по транзакциям PostgreSQL:

  • PostgreSQL: Transactions
  • Lost update и блокировки

    Классическая проблема:

  • два запроса читают одну строку
  • оба считают новое значение
  • оба пишут, и один перетирает результат другого
  • Решения:

  • блокировка строки через SELECT ... FOR UPDATE
  • оптимистичная блокировка через version (CAS-логика)
  • перенос инварианта в уникальные ограничения и атомарные UPDATE (где возможно)
  • Пример транзакции в Node.js с PostgreSQL

    Ниже схема «перевести деньги» (упрощённо). Важно: пример показывает форму, а не полную платёжную систему.

    Что здесь защищает инварианты:

  • BEGIN/COMMIT/ROLLBACK обеспечивает атомарность
  • FOR UPDATE не даёт двум транзакциям «одновременно» изменить один и тот же баланс
  • Практические замечания:

  • транзакции должны быть короткими: чем дольше держите блокировки, тем ниже пропускная способность
  • на транзакции и запросы нужны таймауты
  • некоторые ошибки изоляции (например, при SERIALIZABLE) требуют retry на уровне приложения
  • Уникальные ограничения и оптимистичная конкуренция

    Не всё нужно решать блокировками. Очень часто надёжнее и проще:

  • добавить уникальный индекс на уровне БД
  • делать INSERT и обрабатывать конфликт
  • Пример: «не создать два одинаковых заказа»:

  • уникальный индекс на (user_id, external_order_id)
  • повторный INSERT приводит к конфликту уникальности
  • вы превращаете этот конфликт в доменную ошибку CONFLICT или в идемпотентный ответ
  • Оптимистичная конкуренция через version полезна, когда:

  • блокировки дорогие
  • конфликты редки
  • вы готовы повторить операцию
  • Схема:

  • строка содержит version
  • обновление делает UPDATE ... WHERE id = 2
  • если обновлено 0 строк, значит кто-то уже изменил запись, и надо повторить/вернуть 409
  • Как всё это собрать в одну надёжную стратегию

    Типовой рецепт для критичной операции (создать платёж/заказ)

  • rate limiting на входе, чтобы сдержать злоупотребления
  • concurrency limiting на внешний платёжный провайдер и БД
  • Idempotency-Key для защиты от повторов
  • транзакция в БД + уникальные ограничения
  • единый контракт ошибок (как в Express/Nest статьях): requestId + error.code
  • !Как идемпотентность и транзакции вместе предотвращают дубли

    Ошибки и повторы: что можно ретраить, а что нельзя

    Связь с предыдущими модулями про error handling:

  • ретраить можно только операционные ошибки (таймаут, 503, временная недоступность)
  • нельзя ретраить ошибки валидации и большинство доменных 4xx
  • если вы ретраите, идемпотентность становится обязательной
  • Полезные ориентиры по AbortController (чтобы не держать ресурсы бесконечно):

  • Node.js: AbortController
  • Наблюдаемость для параллельности

    Чтобы параллельность была управляемой, её нужно измерять.

    Минимально полезные метрики:

  • количество 429 и причина лимита
  • текущая конкурентность по ключевым зависимостям (сколько занято слотов семафора)
  • время ожидания слота семафора
  • доля таймаутов внешних вызовов
  • число транзакций, завершившихся rollback
  • число конфликтов уникальности и/или optimistic lock конфликтов
  • Логи при ошибках и конфликтах должны включать:

  • requestId
  • ключ rate limiting (например, userId)
  • Idempotency-Key (если есть)
  • тип конфликта (unique violation / version mismatch)
  • Чеклист для внедрения в реальный сервис

  • Определите, какие endpoints должны быть идемпотентными.
  • Добавьте Idempotency-Key для критичных POST.
  • Закрепите инварианты в БД: уникальные индексы, внешние ключи, ограничения.
  • Используйте транзакции там, где нужен атомарный набор изменений.
  • Ограничьте конкурентность на БД и внешние API (bulkhead).
  • Введите rate limiting на входе и возвращайте 429 с Retry-After.
  • Разделяйте доменные и технические ошибки и не позволяйте ретраям создавать дубли.
  • Добавьте метрики и структурные логи для лимитов, очередей и конфликтов.
  • Связь со следующей темой курса

    Параллельность всегда упирается в фоновые задачи:

  • многие операции лучше переводить в фон (очередь), чтобы разгрузить HTTP
  • идемпотентность и транзакции становятся ещё важнее, потому что фоновые джобы почти всегда выполняются в режиме at-least-once
  • появляется отдельная проблема: как запускать джобы в одном инстансе из многих, как делать distributed locks и как гарантировать, что задача не выполнится дважды
  • Дальше в курсе мы перейдём к фоновым джобам и разберём стратегии одиночного запуска, очереди, блокировки, повторные попытки и наблюдаемость воркеров.

    6. Фоновые задания и очереди: retries, DLQ, cron, outbox и согласованность

    Фоновые задания и очереди: retries, DLQ, cron, outbox и согласованность

    Фоновые задания появляются там, где надёжность важнее немедленного ответа HTTP. Как только вы переносите работу в фон, меняются правила игры:

  • выполнение становится конкурентным и распределённым;
  • большинство систем доставки работают в режиме at-least-once (возможны повторы);
  • ошибки перестают быть “ответом клиенту”, а становятся состоянием джобы;
  • согласованность данных становится отдельной задачей.
  • В предыдущих статьях курса мы построили основу:

  • как классифицировать и централизованно обрабатывать ошибки (Node.js, Express, Nest);
  • как конкурентность приводит к гонкам и почему идемпотентность и транзакции обязательны.
  • Теперь переносим эти принципы в фон: retries, DLQ, cron, паттерн transactional outbox и практики согласованности.

    !Общая архитектура: API, outbox, очередь, воркеры и DLQ

    Что такое фоновые задания

    Фоновая джоба это единица работы, которая выполняется вне HTTP-обработчика. Типовые примеры:

  • отправка email или push;
  • генерация отчёта;
  • обработка медиа;
  • синхронизация с внешними системами;
  • периодические задачи (очистка, пересчёты).
  • Почти всегда цель фона одна: сделать систему устойчивой к задержкам и сбоям зависимостей, не удерживая HTTP-соединение и ресурсы.

    Очередь и семантика доставки

    Когда вы используете очередь (Redis-based, брокер сообщений, managed queue), важно понимать базовую терминологию.

    Producer, consumer, worker

  • Producer публикует джобу.
  • Consumer получает джобу из очереди.
  • Worker выполняет джобу и фиксирует результат.
  • Один процесс часто совмещает роли, но надёжность проще строить, когда роли разделены.

    Ack, retry и почему повторы нормальны

    Большинство очередей и job-раннеров обеспечивают не “ровно один раз”, а at-least-once:

  • джоба может быть доставлена повторно из-за падения воркера;
  • джоба может быть обработана повторно из-за таймаута подтверждения;
  • сеть может дать дубликаты;
  • вы сами включите retries.
  • Практический вывод:

  • идемпотентность воркера обязательна, если задача меняет состояние.
  • Для managed очередей полезно знать термин visibility timeout: сообщение “спрятано” на время обработки, и если воркер не подтвердил выполнение, сообщение возвращается в очередь.

  • Документация Amazon SQS: Visibility Timeout
  • Retries: как повторять правильно

    Retries нужны, потому что часть ошибок операционные и временные: таймауты, сетевые сбои, 503, временно недоступная БД или провайдер.

    Связь с предыдущими модулями про error handling:

  • повторяем только повторяемые (retryable) ошибки;
  • ошибки валидации и доменные конфликты обычно не повторяются.
  • Классификация ошибок для джоб

    Практичная схема:

  • retryable: таймаут, 429, 503, reset соединения, временная блокировка ресурса;
  • non-retryable: невалидный payload, отсутствует сущность, нарушено доменное правило;
  • unknown: считаем багом или неожиданным сбоем, обычно ограниченное число ретраев и затем DLQ.
  • Backoff и jitter

    Если ретраить “сразу”, вы усиливаете аварию: зависимость перегружена, а вы шлёте ещё больше запросов.

    Поэтому почти всегда используют:

  • exponential backoff: задержка растёт с каждой попыткой;
  • jitter: добавление случайности, чтобы воркеры не ретраили синхронно.
  • AWS Architecture Blog: Exponential Backoff And Jitter
  • Ограничение времени жизни джобы

    Ретраи без ограничений превращаются в бесконечный “шум”. На практике вводят:

  • лимит попыток;
  • общий TTL задачи;
  • отдельные лимиты на шаги (например, внешний API не дольше N минут).
  • DLQ: Dead Letter Queue и “ядовитые” сообщения

    DLQ это место, куда попадают задачи, которые не удалось обработать автоматически.

    Причины, почему задача уходит в DLQ:

  • исчерпан лимит retries;
  • payload некорректен;
  • задача систематически ломает воркеры (например, баг);
  • бизнес-правило требует ручного разбора.
  • DLQ нужна не для “склада ошибок”, а для управляемости:

  • отдельная алертизация;
  • возможность ручного requeue после фикса;
  • возможность анализировать причины по статистике.
  • !Жизненный цикл задачи с retries и DLQ

    Идемпотентность воркеров: фундамент надёжности “at-least-once”

    Если джоба может выполниться дважды, воркер должен быть безопасен к повторам.

    Типовые стратегии:

  • идемпотентный ключ: jobId или бизнес-ключ (например, paymentId);
  • таблица обработанных событий с уникальным индексом по ключу;
  • upsert вместо “создать если нет”;
  • уникальные ограничения в БД как последняя линия защиты.
  • Пример: фиксируем обработку jobKey в таблице, чтобы повтор не менял состояние:

    Псевдологика воркера:

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

    Cron против очередей: разные классы задач

    Cron это планирование по времени: “каждые 5 минут”, “в полночь”. Очередь это реакция на событие: “после создания заказа отправь письмо”.

    Проблема cron в распределённой среде:

  • если у вас 10 инстансов, cron запустится 10 раз.
  • Есть два базовых подхода:

  • single leader: только один инстанс выполняет cron-задачу;
  • sharding: все инстансы выполняют часть работы, но без пересечений.
  • Node.js инструменты для cron-подобных задач:

  • Bree
  • node-cron
  • А на уровне оркестратора:

  • Kubernetes CronJob
  • Практическая рекомендация:

  • cron хорошо подходит для периодических задач без строгих требований к точности;
  • если задача критична и должна быть выполнена гарантированно, чаще удобнее моделировать её как очередь + планировщик, который кладёт джобы.
  • Как запускать “один инстанс из многих”: distributed locks

    Чтобы cron-задача выполнялась в одном экземпляре, нужен механизм взаимного исключения.

    Вариант: advisory locks в PostgreSQL

    Если у вас уже есть PostgreSQL, часто удобно использовать advisory locks.

  • PostgreSQL: Advisory Locks
  • Идея:

  • при запуске задачи пробуем взять lock;
  • кто взял lock, тот выполняет;
  • остальные пропускают.
  • Вариант: Redis distributed locks

    Для Redis есть известный подход Redlock. Важно относиться к нему осознанно и понимать ограничения.

  • Redis documentation: Distributed Locks
  • Практическое правило:

  • любой lock должен иметь TTL, чтобы “не зависнуть навсегда” при падении владельца;
  • лидерство должно обновляться, если задача длительная.
  • Transactional outbox: решаем проблему dual write

    Одна из самых дорогих проблем в фоновых системах: dual write.

    Сценарий:

  • вы в HTTP-транзакции записали изменения в БД;
  • затем попытались отправить сообщение в очередь;
  • между ними сервис упал или сеть отвалилась;
  • в итоге БД обновилась, а событие не ушло, либо наоборот.
  • Transactional outbox решает это так:

  • вы пишете бизнес-данные и запись “что надо отправить” в одну транзакцию;
  • отдельный dispatcher читает outbox и публикует в очередь;
  • после успешной публикации помечает outbox-сообщение отправленным.
  • Классическое описание паттерна:

  • microservices.io: Transactional Outbox
  • Минимальная схема таблицы outbox

    Запись в outbox в той же транзакции, что и бизнес-изменения

    Dispatcher: конкурентная отправка без дублей

    Если dispatcher-ов несколько, они не должны “забирать” одни и те же строки. Для PostgreSQL часто используют FOR UPDATE SKIP LOCKED.

    Поток dispatcher-а:

  • взять пачку outbox-сообщений с блокировкой строк;
  • опубликовать в очередь;
  • пометить published_at.
  • Даже при повторной публикации устойчивость достигается идемпотентностью потребителя.

    Очередь в Node.js: практический пример с BullMQ

    Для Redis-based очередей в Node.js часто используют BullMQ.

  • BullMQ
  • Producer: кладём задачу

    Здесь важно:

  • attempts и backoff задают базовую стратегию retries;
  • jobKey пригодится для идемпотентности;
  • requestId связывает логи HTTP и воркера.
  • Worker: обрабатываем и классифицируем ошибки

    Связь с предыдущими статьями про error handling:

  • внутри воркера вы всё так же разделяете доменные и технические ошибки;
  • но “ответом” становится не HTTP, а переход задачи в состояние completed или failed, возможно с DLQ.
  • Согласованность: что именно гарантируем

    В фоновых системах чаще всего реалистичная цель не “мгновенная консистентность”, а eventual consistency:

  • HTTP-операция фиксирует факт (например, “заказ создан”);
  • фон доводит систему до полного состояния (письма, синхронизации, вторичные индексы).
  • Чтобы это было надёжно, вы формулируете инварианты:

  • что должно быть строго атомарно в транзакции (например, “заказ создан”);
  • что допустимо “догнать” позже (например, “email отправлен”);
  • какие повторения допустимы (обычно любые), если воркер идемпотентен.
  • Наблюдаемость для джоб

    Без наблюдаемости фон быстро превращается в “чёрный ящик”. Минимальный набор:

  • метрики количества completed и failed по типам джоб;
  • метрики retries: среднее число попыток, доля задач, дошедших до DLQ;
  • время в очереди (queue latency) и время выполнения (processing time);
  • алерт по росту backlog (очередь растёт быстрее, чем воркеры успевают);
  • логи с jobId, jobName, requestId, attempt, и доменным error.code.
  • Связь с фундаментом курса:

  • если воркер делает CPU-тяжёлую работу, он так же блокирует event loop, как и HTTP-сервис;
  • backpressure теперь выражается не в streams, а в контроле конкурентности воркеров и скорости публикации.
  • Практический чеклист

  • Делайте воркеры идемпотентными, исходя из at-least-once.
  • Вводите retries только для retryable ошибок и используйте backoff.
  • Используйте DLQ как управляемый контур: алерты, анализ, requeue.
  • Для cron в мульти-инстанс среде используйте leader/lock или переносите периодику в очередь.
  • Для надёжной публикации событий используйте transactional outbox.
  • Прокидывайте requestId или аналог корреляции в джобы.
  • Мерите backlog, длительность, retries и долю DLQ.
  • Что дальше в курсе

    Следующий слой после очередей и outbox:

  • стратегии масштабирования воркеров и лимитов конкурентности;
  • graceful shutdown воркеров и безопасная остановка без потери задач;
  • гарантии “ровно один инстанс” для периодических задач в Kubernetes и в bare-metal среде;
  • практики миграции от “cron внутри приложения” к очередям.
  • 7. Многопроцессность и распределённый запуск: один инстанс, все инстансы, leader election

    Многопроцессность и распределённый запуск: один инстанс, все инстансы, leader election

    Node.js даёт конкурентность на I/O, но надёжность в продакшене почти всегда достигается масштабированием вширь: несколько процессов на хосте и несколько инстансов сервиса в кластере. Как только инстансов становится больше одного, появляется важный вопрос: какие фоновые задачи и периодические процессы должны выполняться в одном экземпляре, а какие можно (или нужно) запускать в каждом инстансе?

    В предыдущих модулях курса мы построили базу:

  • обработка ошибок и единый контракт ошибок (Node.js, Express, Nest)
  • конкурентность запросов, идемпотентность, транзакции и rate limiting
  • фоновые задачи, retries, DLQ, cron и transactional outbox
  • Эта статья соединяет эти темы с реальностью деплоя: многопроцессность, многоинстансность и распределённое лидерство.

    !Три режима выполнения фоновых задач в многоинстансной среде

    Зачем вообще нужна многопроцессность

    Два уровня масштабирования

  • Внутри одного хоста: несколько процессов Node.js, чтобы использовать все CPU-ядра и снизить влияние пауз GC одного процесса на весь сервис.
  • Внутри кластера: несколько инстансов (контейнеров/подов/VM), чтобы переживать падения, обновляться без простоя и выдерживать рост нагрузки.
  • Что меняется для надёжности

  • ошибки и рестарты становятся нормой: оркестратор поднимает новый инстанс
  • фоновые задачи и cron-логика могут случайно размножиться (N инстансов запустят одно и то же)
  • возрастает вероятность гонок между инстансами: то, что было безопасно в одном процессе, может стать опасным при N
  • Многопроцессность на одном хосте: что реально нужно знать

    cluster: несколько воркеров под одним master

    cluster запускает несколько процессов Node.js, которые слушают один порт.

  • плюс: простая утилизация CPU
  • минус: каждый воркер имеет свою память, кэш, локальные очереди и локальные rate limits
  • Документация:

  • Node.js cluster
  • Минимальный каркас:

    Практические выводы для курса:

  • локальные in-memory rate limits и локальные idempotency caches в cluster расползаются, потому что каждый воркер независим
  • фоновые джобы внутри каждого воркера почти всегда приводят к дублям
  • если вам нужна «общая истина» для ограничения, дедупликации и лидерства, она должна быть во внешнем хранилище (Redis/БД/Lease)
  • worker_threads: параллелизм для CPU-тяжёлых задач

    Если задача CPU-тяжёлая (парсинг больших файлов, криптография, изображение), переносите её из event loop в worker_threads.

    Документация:

  • Node.js worker_threads
  • Ключевое отличие от cluster:

  • cluster это несколько процессов (изоляция памяти)
  • worker_threads это несколько потоков внутри одного процесса (можно делить память через SharedArrayBuffer, но это усложняет дизайн)
  • Для фоновых задач на уровне кластера worker_threads не решает проблему «сколько инстансов это запустят», он решает проблему «не блокировать event loop».

    Многоинстансный деплой: три режима выполнения задач

    Когда у вас несколько инстансов сервиса (например, несколько pod’ов в Kubernetes), любую фоновую активность нужно отнести к одному из режимов.

    Запуск на каждом инстансе

    Это безопасно, если операция:

  • идемпотентна по смыслу
  • не вызывает конфликтов при параллельном выполнении
  • или сознательно нужна на каждом инстансе
  • Примеры:

  • экспорт метрик GET /metrics
  • health checks
  • локальные периодические обновления кэша, которые не пишут в общий state
  • Риск:

  • если задача делает записи в общую БД или вызывает внешнего провайдера, «на каждом инстансе» быстро превращается в DDoS самого себя
  • Запуск ровно на одном инстансе

    Это нужно, если задача:

  • должна выполняться один раз по расписанию (cron)
  • управляет глобальным состоянием (миграции, пересчёты, дедупликация)
  • должна выдавать единственный побочный эффект (например, «отправить один daily report»)
  • Эта категория требует либо координатора лидерства, либо переноса логики из приложения на уровень платформы.

    Практические варианты:

  • Kubernetes CronJob вместо cron внутри приложения
  • leader election и выполнение cron только на лидере
  • Шардинг: все инстансы работают, но без пересечений

    Это режим высокой надёжности и производительности: вы используете все инстансы, но делите работу так, чтобы инстансы не дублировали друг друга.

    Примеры:

  • обработка outbox батчами через FOR UPDATE SKIP LOCKED
  • очередь задач, где брокер сам распределяет сообщения по воркерам
  • консистентный шардинг по ключу (например, userId)
  • Почему «cron внутри приложения» ломается при масштабировании

    Если у вас 10 инстансов и в каждом стоит node-cron, то:

  • задача выполнится 10 раз
  • при рестартах и деплоях будут случайные «дополнительные» запуски
  • в момент деградации системы cron может ухудшить ситуацию (усилить нагрузку)
  • Если периодическая задача критична, надёжнее:

  • сделать её отдельным воркером/сервисом
  • или управлять запуском на уровне оркестратора
  • Ссылки:

  • Kubernetes CronJob
  • Leader election: что это и что он должен гарантировать

    Leader election это механизм, который в каждый момент времени определяет лидера среди нескольких кандидатов.

    Цель для backend обычно практичная:

  • выполнять определённый класс задач только на одном инстансе
  • Важное ограничение:

  • лидерство в реальных распределённых системах не «идеально», поэтому дизайн должен переживать короткие окна неопределённости
  • Практический инвариант, который вы хотите получить:

  • в нормальном режиме лидер один
  • при сбоях лидер либо быстро переизбирается, либо временно отсутствует
  • при «разделении сети» возможны риски «двух лидеров», поэтому задачи должны быть идемпотентны или защищены транзакционно
  • Три практичных способа сделать “один инстанс из многих”

    Способ: Kubernetes Lease и встроенный механизм лидерства

    В Kubernetes есть ресурс Lease, который часто используют контроллеры и операторы.

    Документация:

  • Kubernetes Lease
  • Идея:

  • инстансы соревнуются за обновление lease
  • тот, кто поддерживает lease, считается лидером
  • при остановке обновлений лидерство «протухает», другой инстанс может стать лидером
  • Когда выбирать:

  • вы уже в Kubernetes
  • вы хотите платформенный стандарт вместо самодельных redis/db lock
  • Способ: advisory locks в PostgreSQL

    Если у вас уже есть PostgreSQL, advisory locks часто дают простой и надёжный механизм «один исполнитель».

    Документация:

  • PostgreSQL Advisory Locks
  • Типовая схема:

  • лидер пытается взять lock (например, pg_try_advisory_lock)
  • если lock взят, выполняет задачу
  • если не взят, пропускает
  • Сильные стороны:

  • не нужен дополнительный сервис
  • lock живёт в рамках соединения: если процесс упал, соединение закрылось, lock освободился
  • Слабые стороны:

  • нужно аккуратно управлять соединением и временем выполнения
  • если задача очень долгая, важно контролировать таймауты и состояние соединения
  • Способ: распределённые locks в Redis

    Redis часто используют для lock’ов из-за скорости и простоты.

    Официальный паттерн и обсуждение ограничений:

  • Redis distributed locks
  • Ключевые требования к lock’у:

  • TTL обязателен, иначе при падении владельца lock зависнет
  • если задача длительная, TTL нужно продлевать heartbeat’ом
  • потеря lock’а должна останавливать выполнение задачи, иначе возможны две параллельные обработки
  • Практическая рекомендация в контексте курса:

  • для критичных задач, меняющих данные, полагайтесь на транзакции и идемпотентность так, как будто повторы возможны всегда
  • Две архитектуры periodic jobs: лидер или отдельный scheduler

    Лидер выполняет cron-задачи

    Плюсы:

  • проще: тот же сервис, тот же деплой
  • Минусы:

  • логика лидерства смешивается с бизнесом
  • при перегрузке сервиса cron может страдать или ухудшать перегрузку
  • Рекомендации:

  • держите cron-задачи максимально короткими
  • долгую работу выносите в очередь (лидер только ставит джобы)
  • Отдельный scheduler кладёт задачи в очередь

    Плюсы:

  • разделение ответственности
  • масштабирование воркеров независимо от планировщика
  • легче наблюдать backlog и SLA выполнения
  • Минусы:

  • больше компонентов
  • Этот подход хорошо сочетается с outbox и очередями из предыдущего модуля.

    Как совместить leader election с очередями и outbox

    Хороший практический паттерн:

  • лидер периодически читает outbox и публикует события
  • несколько dispatcher’ов читают outbox через FOR UPDATE SKIP LOCKED и публикуют параллельно
  • воркеры очереди обрабатывают события идемпотентно
  • Выбор зависит от нагрузки:

  • если outbox маленький и редкий, лидер-диспетчер может быть достаточен
  • если outbox большой, используйте шардинг через SKIP LOCKED и несколько dispatcher’ов
  • Связь с надёжностью из предыдущих статей:

  • outbox решает dual write
  • очередь даёт backpressure и retries
  • идемпотентность воркеров делает повторы безопасными
  • Шардинг работы между инстансами: базовые варианты

    Шардинг нужен, когда «один лидер» не справляется, но «все инстансы делают всё» недопустимо.

    Шардинг очередью

    Если вы используете очередь (например, BullMQ, SQS, Kafka), распределение задач обычно делает брокер.

    Важное условие надёжности:

  • обработка должна быть идемпотентной, потому что at-least-once
  • Шардинг через БД и SKIP LOCKED

    Для батчевой обработки:

  • таблица задач или outbox
  • несколько воркеров делают SELECT ... FOR UPDATE SKIP LOCKED LIMIT N
  • каждый воркер получает свою порцию без пересечений
  • Это простой способ масштабирования без отдельного брокера, если нагрузка умеренная.

    Шардинг по ключу (например, userId)

    Инстансы делят пространство ключей:

  • каждый инстанс отвечает за свой диапазон/множество
  • обычно требует стабильной схемы назначения и механизма ребалансинга при изменении числа инстансов
  • Практическое предупреждение:

  • как только вы шарите по ключу без брокера, вам нужно думать о перераспределении при масштабировании и о том, что будет при потере инстанса
  • Failure modes: что может пойти не так и как это пережить

    Split brain: два лидера одновременно

    Причины:

  • сетевые проблемы
  • задержки обновления lease/TTL
  • паузы процесса (например, GC) и неверно настроенные таймауты
  • Защита:

  • лидерские задачи должны быть идемпотентны или защищены транзакционно
  • при потере лидерства задача должна прекращаться
  • "Зависший лидер" и вечный lock

    Причины:

  • lock без TTL
  • зависшее соединение без разрыва
  • Защита:

  • TTL обязателен (Redis)
  • connection-scoped locks полезны (Postgres advisory)
  • метрики и алерты по времени удержания lock
  • Дубли запуска при деплое

    Причины:

  • два инстанса одновременно считают себя лидером на короткое время
  • быстрые рестарты
  • Защита:

  • любая задача должна быть безопасна к повтору, как и фоновые джобы
  • Наблюдаемость для лидерства и распределённого запуска

    Минимальные метрики:

  • leader_status (0 или 1)
  • число успешных и неуспешных попыток взять лидерство
  • время удержания лидерства
  • время выполнения периодических задач
  • backlog очереди или outbox
  • Логи:

  • события became_leader, lost_leader
  • requestId и jobId там, где это применимо
  • причина потери лидерства (таймаут, ошибка обновления lease, закрытие соединения)
  • Практический чеклист выбора стратегии

    Таблица для быстрого решения:

    | Класс работы | Пример | Режим | Ключевая защита | | --- | --- | --- | --- | | Локальная без общего state | прогрев локального кэша | каждый инстанс | таймауты, лимиты | | Глобальная периодика | daily отчёт, очистка | один инстанс | leader election + идемпотентность | | Массовая обработка записей | рассылка, outbox dispatch | шардинг | SKIP LOCKED или очередь | | Внешний провайдер с лимитами | отправка писем | шардинг/очередь | retries+backoff, rate limits |

    И финальное правило курса, которое связывает все модули:

  • как только появляется больше одного исполнителя, ваш дизайн должен предполагать повторы
  • идемпотентность и транзакционные гарантии важнее, чем надежда на «идеальное лидерство»
  • Что дальше

    Следующий практический слой надёжности для multi-instance систем:

  • graceful shutdown для HTTP и воркеров, чтобы не терять задачи и не рвать активные операции
  • управление конкурентностью воркеров и backpressure на уровне очередей
  • стратегия деплоя: как не запускать опасные миграции параллельно и как правильно делать "run once" операции