Большинство туториалов по Node.js относятся к обработке ошибок как к чему-то второстепенному. Вы оборачиваете обработчик маршрута в блок try/catch, логируете стек вызовов и возвращаете 500. Такое мышление сохраняется потому, что на другом конце HTTP-запроса ждет живой человек. Фоновые задачи работают иначе. В системе очередей нет нетерпеливого клиента, которому нужно ответить, или автоматического обновления браузера. Есть только воркер, полезная нагрузка и счетчик попыток повтора, молча идущий вверх. Когда что-то идет не так, это начинается медленно, а затем происходит всё и сразу. Одна неверно классифицированная ошибка может остановить весь конвейер или разбудить инженера в три часа ночи.
Суть проблемы проста. Циклы «запрос-ответ» завершаются быстро и заметно. Сбой в очереди происходит тихо. Воркер может обработать сотни задач, прежде чем разорвется соединение с базой данных. Без четких правил обработки воркер начинает мгновенно повторять попытки, «долбит» и без того перегруженную базу данных и падает. Поскольку за воркером никто не наблюдает напрямую, первым признаком беды часто становится каскадный затор или переполненный логами диск. Вам нужно нечто большее, чем просто блоки catch. Вам нужна стратегия, которая по-разному обрабатывает различные типы сбоев и защищает остальную систему от одной «плохой» задачи.
Два вида сбоев
Начните с того, что разделите каждую ошибку на одну из двух категорий.
Повторяемые (retryable) ошибки — это временные сбои. Тайм-аут сети при обращении к стороннему API, ответ 429 (rate limit) или временное отставание реплики базы данных от основного узла. Это симптомы нагрузки, а не баги. Система может восстановиться сама за тридцать секунд. Задачи, которые можно повторить, заслуживают еще одного шанса, но только при контролируемых условиях.
Постоянные (permanent) ошибки — это ошибки в данных или логике. Невалидный JSON в полезной нагрузке, отсутствующий ID пользователя или необходимый файл, которого нет в хранилище. Они провалятся на сотой попытке точно так же, как и на первой. Их повторение сжигает циклы CPU, тратит слоты в очереди и создает токсичное обратное давление (back-pressure), которое задерживает нормальные задачи. Единственное полезное место для постоянной ошибки — это лог, алерт или dead-letter queue. Ей не место в цикле повторов.
Создайте механизм принятия решений
Классифицируйте ошибки немедленно. Не перекладывайте это решение на фреймворк очередей. Как только вы поймали ошибку, сразу решите её судьбу.
На практике это означает создание пользовательских классов ошибок или функций-оберток, которые анализируют сбой перед тем, как пробросить его выше. Если драйвер базы данных выбрасывает ошибку сброса соединения (connection reset), ваш обработчик должен пометить её как повторяемую. Если валидатор полезной нагрузки выбрасывает ошибку несоответствия схемы, пометьте её как постоянную. Многие обработчики задач по умолчанию пытаются повторить всё подряд, и это самое дорогое решение, которое вы можете принять. Отклоняйте постоянные задачи мгновенно. Либо удаляйте их, либо перенаправляйте в dead-letter queue, где они не смогут «отравить» основной конвейер. Эта простая привычка предотвращает эффект снежного кома надежнее, чем любые изменения в инфраструктуре.
Делайте паузы, но умнее
Если вы всё же решили повторить попытку, никогда не делайте это мгновенно. Если база данных недоступна, поток воркеров, «долбящих» её каждую секунду, выглядит как DoS-атака изнутри. Используйте экспоненциальную задержку (exponential backoff). Подождите одну минуту, затем пять, затем пятнадцать. Дайте вышестоящей системе пространство для восстановления.
Но одной экспоненциальной задержки недостаточно. Если тысяча задач упадет одновременно из-за перезапуска сервиса, их графики повторов совпадут. Они одновременно обрушатся на сервис, когда тот вернется в онлайн, что может снова его уронить. Добавьте джиттер (jitter): небольшое случайное смещение к каждой задержке. Рассредоточение повторов во времени на несколько секунд предотвращает синхронные «набеги». Математика проста, но стабильность, которую она обеспечивает, огромна.
Сохраняйте улики
Dead-letter queue — это ваш аудиторский след, а не мусорная корзина. Когда задача исчерпывает все попытки повтора, не удаляйте её просто так. Переместите всю полезную нагрузку вместе с контекстом ошибки и историей попыток в DLQ.
Это сохраняет доказательства. Человек сможет изучить задачу, исправить баг и при необходимости запустить её повторно вручную. Что еще важнее, следите за глубиной вашей DLQ. Внезапный всплеск задач в DLQ — это часто самый ранний сигнал о неудачном деплое, ошибке при изменении схемы или нарушении условий работы внешнего вендора. Относитесь к росту DLQ как к опережающему индикатору, а не запаздывающему. Если ваша DLQ заполняется, значит, что-то изменилось выше по цепочке, и вашей команде нужно об этом узнать до того, как очередь разрастется.
Проектируйте с учетом повторов
Проектируйте каждую задачу так, будто она запустится дважды, потому что так оно и может быть. Воркер может упасть на середине обработки, быть переназначенным и запуститься снова. Если ваша задача списывает деньги с клиента, отправляет письмо или увеличивает остаток на складе, наивный повтор приведет к дублированию.
Решение — идемпотентность. Прежде чем выполнять побочный эффект, проверьте, не произошел ли он уже. Используйте уникальный идентификатор из полезной нагрузки задачи в качестве ключа идемпотентности. Сохраняйте этот ключ в кратковременном кэше или в таблице базы данных с ограничением уникальности. Если ключ уже существует, пропустите выполнение и верните статус успеха. Это превращает повторные попытки из риска в безвредную операцию-пустышку (no-op). Это требует всего несколько дополнительных строк кода, но избавляет вас от необходимости объяснять финансовому отделу, почему выручка удвоилась за одну ночь.
Защитите процесс
Необработанные отклонения промисов и случайные исключения могут без предупреждения убить процесс Node.js. Для воркера это означает потерянные задачи и оркестратор, спешно пытающийся перезапустить контейнер.
Зарегистрируйте глобальные обработчики для unhandledRejection и uncaughtException. Их задача — не спасти приложение, а выполнить необходимую минимальную очистку и завершиться. Позвольте Docker, Kubernetes или systemd перезапустить воркер с чистым состоянием памяти. Попытки продолжать работу после срабатывания глобального обработчика чреваты утечками памяти и повреждением состояния. Быстрая и чистая смерть безопаснее, чем медленный процесс-зомби, который некорректно обрабатывает задачи. Доверьтесь своему оркестратору в вопросе перезапуска; не пытайтесь перехитрить поврежденную среду выполнения.
Уважайте сигналы
Воркеры завершают работу во время развертывания, масштабирования или ротации узлов. Если ваш процесс умирает в тот же миг, когда получает SIGTERM, вы прерываете любую текущую задачу. Эта задача может никогда не завершиться, а счетчик её повторов может даже не успеть увеличиться.
Слушайте сигналы SIGTERM и SIGINT. При получении сигнала прекратите извлекать новые задачи из очереди. По возможности завершите текущую задачу. Установите жесткий таймаут, например, тридцать секунд, после которого вы завершитесь в любом случае. Такое «мягкое» завершение (graceful shutdown) проявляет уважение к очереди и позволяет избежать ложных сбоев. Ваш конвейер развертывания должен считать воркер, который завершился корректно, исправным, в то время как аварийное завершение воркера должно вызывать оповещение.
Главный вывод
Надежная обработка очередей — это не умение ловить каждую ошибку. Это умение принимать осознанные решения для каждого сценария отказа. Терпеливо повторяйте попытки при временных сбоях. Быстро отсекайте постоянные ошибки. Защищайте свои воркеры от «эффекта стада», оберегайте данные с помощью ключей идемпотентности и позволяйте завершающимся процессам уходить чисто. Когда у каждого сбоя есть определенный путь решения, три часа ночи становятся просто очередным часом. Ваш конвейер продолжает работать, а ваша команда — спать.
