Главная / Статьи / 20 шаблонов Node.js, предотвращающих простои производственных серверов

20 шаблонов Node.js, предотвращающих простои производственных серверов

Изучите 20 практических шаблонов Node.js — от обработки ошибок до плавного завершения работы и использования пула соединений — которые предотвращают сбои, делая ненужными перезагрузки.

4280 слов

Периоды простоя сервера почти никогда не начинаются с одного катастрофического события.

Обычно причиной становится что-то незаметное и обыденное.

Соединение с базой данных так и не возвращается в пул.

Использование памяти постепенно растет, пока процесс не достигнет своего предела.

Один маршрут, который раньше отвечал за 200 миллисекунд, теперь начинает обрабатывать запросы в течение пяти секунд.

Процесс развертывания прерывает активные соединения.

Асинхронная задача терпит неудачу, и никто не замечает ошибки.

Внезапный всплеск трафика заставляет каждый входящий запрос выполнять дополнительный дорогостоящий вызов к базе данных.

Сначала всё это не кажется тревожным.

Затем начинают увеличиваться времена отклика.

Потом запросы начинают тайм-аутить.

Затем процесс полностью останавливается.

И именно тогда возникают вопросы:

«Почему сервер не работает?»

Самое раздражающее в этом то, что большинство подобных ситуаций можно было избежать.

Во многих производственных системах постоянно появляются решения для Node.js, которые оказываются действительно эффективными, именно потому что они устраняют коренную причину проблемы, а не просто восстанавливают работу сервера после сбоя.

В этой статье рассматриваются наиболее важные из них.

1. Не позволяйте одному некорректному запросу сбивать весь процесс

Одной из самых опасных привычек в кодовой базе Node.js является позволение выброшенной исключению или нереализованному отклонению обещания дойти до самого процесса.

Возьмем в пример следующий код:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

На первый взгляд всё кажется в порядке.

Но что произойдёт, если соединение с базой данных прервётся в процессе запроса?

Или ORM выдаст ошибку?

Или неожиданное значение входных данных нарушит логику обработчика?

Без надлежащей обработки ошибок процесс может попасть в неисправное состояние, и в зависимости от конкретной ошибки и настроек среды выполнения он может полностью остановиться.

Более надежным подходом является прямое направление ошибок, вместо того чтобы позволять им ускользнуть:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

Оттуда все направляется в один централизованный обработчик:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

Речь не идет о маскировке проблем.

Речь идет о том, чтобы определить точно, куда попадут ошибки.

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

2. Не рассматривайте перезапуск процесса как решение

Когда процесс Node.js останавливается, инстинктивным решением обычно является просто:

npm start

Или, для команд, использующих PM2:

pm2 restart app

Это позволяет быстро восстановить работу сервиса.

Оно не решает проблему, из-за которой произошла ошибка в первую очередь.

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

Инструменты вроде PM2, Docker, Kubernetes или управляемых платформ могут автоматически перезапускать сбойный процесс, что действительно полезно.

Например, с PM2:

pm2 start app.js --name api
pm2 save

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

Вам всё равно нужны ответы на такие вопросы:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

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

Только надлежащая возможность отслеживания позволяет понять, почему сервис вообще прекратил работу.

Оба этих аспекта необходимы.

3. Исправляйте утечки памяти до того, как они приведут к сбоям из-за нехватки памяти

Node.js использует механизм сбора мусора, но это не гарантирует автоматической корректной очистки памяти.

Ваш код может сохранять ссылки на объекты, которые на самом деле больше не нужны.

Вот простой пример:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

Каждый поступающий запрос добавляет новые данные в cache.

Поскольку приложение продолжает хранить ссылки на эти записи, сборщик мусора не может их освободить.

Со временем ситуация выглядит так:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

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

Обращайте внимание на такие факторы:

  • Глобальные массивы, которые постоянно растут
  • Кэши без ограничения по размеру
  • Большие объекты, оставленные в памяти
  • Слушатели событий, которые никогда не отсоединяются
  • Таймеры, которые никогда не аннулируются
  • Открытые потоки
  • Большие объемы данных, хранящиеся внутри замыканий
  • Очереди без верхнего предела
  • Если ваше приложение хранит данные в памяти, этот кэш требует четкой политики управления.

    Например:

    Maximum entries: 10,000
    TTL: 10 minutes
    Eviction: LRU
    

    Кэш без ограничений на самом деле не функционирует как кэш.

    Это проблема памяти, которая рано или поздно произойдет.

    4. Установка таймаутов для внешних запросов

    Запрос к другому сервису без установки таймаута — один из самых быстрых способов заставить ваш API зависнуть.

    Представьте такой запрос:

    const response = await fetch("https://external-service.com/api");
    

    Что, если этот внешний сервис вдруг начнет отвечать очень медленно?

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

    Теперь представьте ситуацию, когда 1 000 одновременных пользователей обращаются к тому же эндпоинту.

    В результате сотни или тысячи запросов застревают в ожидании от ответа от сервиса, который не реагирует.

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

    Всегда предусматривайте явную обработку истечения времени ожидания.

    Например, с использованием AbortController:

    const controller = new AbortController();
    
    const timeout = setTimeout(() => {
      controller.abort();
    }, 5000);
    
    try {
      const response = await fetch(
        "https://external-service.com/api",
        {
          signal: controller.signal
        }
      );
    
      return await response.json();
    } finally {
      clearTimeout(timeout);
    }
    

    Теперь ваше приложение не будет бесконечно ждать.

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

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

    Это так же важно, независимо от того, общаетесь ли вы с платежным шлюзом, сервисом доставки электронной почты, обычным HTTP-эндпоинтом, Redis, собственным слоем базы данных, API стороннего поставщика или другим внутренним микросервисом в вашей инфраструктуре.

    5. Устранение исчерпания соединений с базой данных

    API на Node.js может казаться абсолютно нормальным само по себе, в то время как настоящий узкое место находится на уровне соединений с базой данных.

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

    500 concurrent requests
    

    и каждый из этих запросов пытается открыть собственное новое соединение с базой данных.

    В какой-то момент база данных достигает установленного лимита соединений.

    После этого новые запросы начинают отклоняться полностью.

    API может возвращать ошибки вроде:

    Too many connections
    Connection refused
    Connection timeout
    

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

    Визуально это выглядит примерно так:

    ┌── Connection 1
                  ├── Connection 2
    API ── Pool ──┼── Connection 3 ── Database
                  ├── Connection 4
                  └── Connection 5
    

    Пул действует как контролер, ограничивающий количество открытых соединений с базой данных в любой момент времени.

    Но правильный размер пула имеет такое же значение, как и наличие самого пула.

    Тщательно настраивайте пул.

    Установите слишком маленький размер:

    Requests
       ↓
    Waiting for connections
       ↓
    High latency
    

    Установите слишком большой размер:

    Application
       ↓
    Too many connections
       ↓
    Database overloaded
    

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

    Что бы вы ни делали, не пытайтесь решать проблемы с ошибками подключения, просто увеличивая максимальный размер пула соединений без ограничений.

    6. Устранение запросов типа N+1

    Ещё одним распространенным способом, при котором команды непреднамеренно перегружают свою базу данных, является использование шаблона запросов N+1.

    Представьте эндпоинт, возвращающий список публикаций:

    const posts = await Post.findAll();
    
    
    for (const post of posts) {
      post.author = await User.findByPk(post.authorId);
    }
    

    Если в таблице 1000 публикаций, это приводит к следующему:

    1 query → retrieve posts
    
    1,000 queries → retrieve authors
    
    Total: 1,001 queries
    

    При наличии десяти публикаций эндпоинт работает мгновенно, и никто не замечает ничего необычного.

    Затем объем данных увеличивается.

    И тот же эндпоинт становится крайне медленным.

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

    Несколько распространенных решений:

    JOIN-запросы

    SELECT
      posts.id,
      posts.title,
      users.name
    FROM posts
    JOIN users
      ON users.id = posts.author_id;
    

    Активное загрузчик данных ORM

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

    Пакетные запросы

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

    Get user 1
    Get user 2
    Get user 3
    ...
    

    загрузите все необходимые пользователи с помощью одного объединенного запроса.

    Во время тестирования на нагрузку стоит задать простой диагностический вопрос:

    Сколько отдельных запросов к базе данных выполняется этим эндпоинтом при возврате 10 000 записей?

    Если честный ответ звучит так:

    "10 001."

    это явный признак того, что что-то нуждается в исправлении.

    7. Добавьте пагинацию до того, как база данных станет слишком большой

    Этот фрагмент кода — настоящая бомба замедленного действия:

    const users = await User.findAll();
    
    res.json(users);
    

    Во время разработки всё работает отлично, причем таблица содержит:

    37 users
    

    Шесть месяцев спустя в той же таблице теперь находится:

    2,400,000 users
    

    Этот единственный неизменный конечный пункт теперь может:

    • Загружать миллионы строк из базы данных
    • Использовать большое количество ресурсов базы данных
    • Использовать много памяти процесса Node.js
    • Сериализовывать огромный JSON-пакет
    • Исчерпывать пропускную способность
    • Сильно увеличивать время отклика
    • Провоцировать превышение времени ожидания

    Решение — пагинация.

    Например, принимать запрос в таком формате:

    GET /users?page=1&limit=50
    

    и обрабатывать его следующим образом:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    
    const page = Number(req.query.page) || 1;
    
    const offset = (page - 1) * limit;
    

    Не менее важно установить лимит на объем запросов, которые могут отправлять клиенты.

    Не позволяйте клиенту отправлять:

    ?limit=100000000
    

    и заставляйте ваш API пытаться выполнить запрос такого размера.

    8. Прекратите выполнять независимые операции последовательно

    Предположим, что конечная точка должна собрать три элемента данных:

    User
    Settings
    Notifications
    

    Наивная реализация может выглядеть так:

    const user = await getUser();
    const settings = await getSettings();
    const notifications = await getNotifications();
    

    Если каждый вызов занимает 200 мс, ожидание их поочередно приводит в сумме примерно к:

    200 + 200 + 200 = 600 ms
    

    Когда эти вызовы не зависят друг от друга, выполняйте их одновременно:

    const [
      user,
      settings,
      notifications
    ] = await Promise.all([
      getUser(),
      getSettings(),
      getNotifications()
    ]);
    

    Теперь все три запроса происходят одновременно.

    Общее время сокращается примерно до времени выполнения самого медленного из отдельных вызовов:

    max(200, 200, 200) ≈ 200 ms
    

    Речь идет не только о сокращении количества миллисекунд в ответе.

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

    Запросы, которые завершаются быстрее, также освобождают ресурсы быстрее.

    Тем не менее, не стоит автоматически использовать Promise.all в каждом случае.

    Если один вызов действительно нуждается в результатах другого, как в следующем примере:

    const user = await getUser();
    const orders = await getOrders(user.id);
    

    тогда эти вызовы по своей природе должны выполняться последовательно.

    9. Плавное завершение работы

    Один из самых простых способов вызвать ненужную перерыв в работе во время развертывания — это внезапное прекращение работы процесса.

    Рассмотрим следующую последовательность действий:

    User → POST /checkout
                  ↓
           Processing payment
                  ↓
             Deployment starts
                  ↓
              Process killed
    

    В такой ситуации пользователь может увидеть лишь:

    Connection reset
    

    хотя основная операция уже могла быть частично выполнена.

    Плавное завершение работы решает эту проблему путем прекращения приёма новых соединений сервером при одновременном допуске к завершению уже начатых запросов.

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

    const server = app.listen(PORT);
    
    process.on("SIGTERM", () => {
      console.log("SIGTERM received");
    
    server.close(() => {
        console.log("HTTP server closed");
    
        process.exit(0);
      });
    });
    

    В производственной системе плавное завершение работы часто также подразумевает закрытие:

    • Пулов подключений к базе данных
    • Подключений к Redis
    • Процессов обработки сообщений из очереди
  • Процессы фоновой обработки
  • Открытые соединения WebSocket
  • Любые другие используемые ресурсы
  • Основная цель сводится к следующему:

    Stop receiving new work
            ↓
    Finish current work
            ↓
    Close resources
            ↓
    Exit
    

    Процесс развертывания не должен напоминать ситуацию, когда кто-то выдернул шнур питания из розетки.

    10. Использование проверок работоспособности и готовности

    Совершенно возможно, что процесс Node.js продолжает работать, в то время как приложение, находящееся за ним, фактически перестало функционировать.

    Представьте себе такое состояние:

    Node.js process → Running
    Database → DOWN
    API → Practically unusable
    

    Если единственным сигналом балансировщика нагрузки является наличие ответа с TCP-порта, он может продолжать направлять трафик к инстансу, который на самом деле не может предоставить ничего полезного.

    Эндпоинт готовности дает реальный сигнал для проверки:

    app.get("/ready", async (req, res) => {
      try {
        await db.query("SELECT 1");
    
       res.status(200).json({
          status: "ready"
        });
      } catch (error) {
        res.status(503).json({
          status: "not_ready"
        });
      }
    });
    

    Это позволяет инфраструктуре различать два совершенно разных состояния:

    Process is alive
    

    против:

    Process is ready to serve traffic
    

    Эта разница имеет огромное значение при развертывании нового кода или восстановлении после сбоя.

    11. Защита сервера от всплесков нагрузки

    Предположим, ваш сервис спокойно справляется с:

    100 requests/second
    

    Тогда ваш продукт становится доступен широкой аудитории.

    Внезапно вы сталкиваетесь с:

    2,000 requests/second
    

    При отсутствии защитных мер такой всплеск может полностью перегрузить API.

    Некоторые распространенные способы снижения нагрузки:

    • Ограничение количества запросов, которые клиент может отправить за определенное время
    • Размещение обратного прокси перед серверами приложений
    • Распределение входящего трафика между несколькими инстансами
    • Хранение кэша ответов, чтобы избежать повторной обработки
    • Размещение статического контента через CDN
    • Отложение обработки задач в очередь вместо их немедленной обработки
  • Ограничение количества одновременных подключений к базе данных
  • Автоматическое увеличение количества инстансов по мере роста нагрузки
  • Отклонение запросов, выполнение которых занимает слишком много времени
  • Вам не обязательно нужны все пункты из этого списка.

    Но любая производственная система должна иметь четкий ответ на вопрос:

    Что произойдет, если нагрузка внезапно увеличится в десять раз?

    12. Вынос дорогостоящих операций за пределы HTTP-запроса

    Возьмем конечную точку, которая создает пользователя и отправляет письмо с приветствием в рамках одного и того же запроса:

    await createUser();
    await sendWelcomeEmail();
    res.json({ success: true });
    

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

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

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

    HTTP Request
         ↓
    Create User
         ↓
    Return response
         ↓
    Queue email job
         ↓
    Background Worker
         ↓
    Send Email
    

    API больше не вынужден ожидать завершения медленных фоновых операций.

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

    • Отправка подтверждающих или приветственных электронных писем
    • Создание отчетов по запросу
    • Изменение размера или преобразование загруженных изображений
    • Кодирование или транскодирование видео
    • Отправка уведомлений через push-сервисы
    • Генерация экспортных данных большими объемами
    • Обработка данных, поступающих через webhook-соединения
    • Выполнение сложных числовых или аналитических вычислений

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

    13. Обеспечение безопасной повторной попытки выполнения фоновых задач

    Перенос работы в очередь порождает новый вопрос:

    Что происходит, если сама задача терпит неудачу на каком-то этапе?

    Рассмотрим следующий процесс:

    Worker receives payment notification
            ↓
    Starts processing
            ↓
    Crashes
    

    Система очередей, скорее всего, автоматически попытается выполнить задачу снова.

    Как правило, это хорошо.

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

    Например:

    Send payment
    Send payment again
    

    или:

    Create order
    Create duplicate order
    

    По этой причине фоновые задачи должны быть разработаны с учётом:

    • Повторных попыток
    • Задержек между попытками
    • Обработки сообщений с ошибками
    • Идемпотентности
    • Максимального количества попыток
    • Мониторинга

    Без идемпотентности механизм повторных попыток может превратить одну неудачу в несколько нежелательных побочных эффектов.

    14. Прекратите логировать конфиденциальную информацию

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

    Типичный пример:

    console.log(req.body);
    

    В теле запроса могут легко находиться такие элементы:

    Password
    Access token
    Credit-card information
    Personal information
    

    Как только это происходит, система логирования начинает хранить конфиденциальные данные.

    Более безопасным подходом является логирование только тех полей, которые действительно необходимы:

    logger.info({
      userId,
      requestId,
      endpoint: req.path
    }, "Login attempt");
    

    Не попадайтесь в ловушку мысли:

    «Это же только в логах».

    Логи часто сохраняются неделями, месяцами или даже дольше, и их часто копируют в такие места, как:

    • Системы логирования в облаке
    • Платформы мониторинга
    • Системы SIEM
    • Накопители для резервного копирования
    • Панели управления для разработчиков

    Отладка проблемы в продакшене не должна приводить к возникновению отдельного инцидента безопасности.

    15. Добавляйте идентификаторы запросов заранее

    Когда что-то ломается в продакшене, сбой часто затрагивает несколько систем:

    Client
      ↓
    Nginx
      ↓
    Node.js API
      ↓
    Redis
      ↓
    Database
      ↓
    Payment API
    

    Без способа связать эти события вместе восстановление произошедшего может стать медленным и сложным процессом.

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

    request_id = 7f4e9c21
    

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

    API log
    Database-related log
    Payment log
    Error response
    

    Например:

    {
      "requestId": "7f4e9c21",
      "method": "POST",
      "path": "/orders",
      "status": 500
    }
    

    С этим решением можно отслеживать один запрос на протяжении всей его обработки в системе.

    Таким образом, когда клиент сообщает:

    «Моя заявка не прошла».

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

    16. Предотвращение создания дублирующихся данных из-за дублирующихся запросов

    Сетевые сбои возникают постоянно, и клиентам необходимо уметь с ними справляться.

    Представьте следующую последовательность событий:

    Client
      ↓
    POST /orders
      ↓
    Server creates order
      ↓
    Response lost
    

    У клиента нет способа узнать, действительно ли запрос был успешным.

    Поэтому он естественным образом пытается выполнить запрос снова:

    POST /orders
      ↓
    Server creates another order
    

    Результат:

    Order #1001 → original request
    Order #1002 → retry
    

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

    Клиент добавляет заголовок вроде:

    Idempotency-Key: 5e7b2d91-...
    

    Сервер запоминает результат, связанный с этим ключом. Если тот же ключ появляется снова, он просто возвращает первоначальный результат вместо создания нового ресурса.

    Это особенно важно для операций вроде:

    Payments
    Orders
    Subscriptions
    Account creation
    Resource provisioning
    

    Любой производственный API должен быть спроектирован с учетом того, что клиенты будут повторять запросы, иногда более одного раза.

    17. Используйте транзакции, когда данные должны оставаться консистентными

    Подумайте о такой последовательности действий:

    Create order
    Decrease inventory
    Create payment record
    

    Теперь представьте, что один из этих шагов срывается на полпути:

    Create order → SUCCESS
    Decrease inventory → SUCCESS
    Create payment record → FAIL
    

    Теперь в вашей базе данных осталась незавершённая операция.

    Каждый раз, когда несколько записей в базу данных должны быть сохранены как единый блок, оберните их в транзакцию.

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

    await db.transaction(async (transaction) => {
      const order = await createOrder(transaction);
    
      await decreaseInventory(transaction);
    
      await createPaymentRecord(transaction);
    });
    

    А если какой-либо шаг вызовет ошибку:

    Rollback
    

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

    Транзакции не решат всех проблем, с которыми вы столкнётесь в распределённой системе, но в рамках одной базы данных они являются одним из самых надёжных инструментов для поддержания консистентности данных.

    18. Не позволяйте одному медленному запросу разрушить базу данных

    Один плохо написанный запрос может занять чрезмерно большое количество ресурсов базы данных.

    Возьмём в пример следующий случай:

    SELECT *
    FROM orders
    WHERE customer_id = ?
    ORDER BY created_at DESC;
    

    Если у customer_id нет индекса, а таблица содержит миллионы строк, этот запрос может заставить базу данных выполнять гораздо больше работ, чем это необходимо.

    Используйте

    EXPLAIN
    

    для анализа важных запросов.

    Обращайте внимание на такие предупреждающие сигналы, как:

    Full table scan
    Huge number of rows examined
    Unexpected joins
    Poor index usage
    Expensive sorting
    

    Затем основывайте оптимизацию на реальных данных, полученных в результате анализа.

    Не полагайтесь на догадки.

    Запрос, который выполняется за 50 миллисекунд на локальной среде, может занять несколько секунд при работе с данными в производственных масштабах.

    19. Установка ограничений на ресурсы

    Производственному серверу необходимы ограничения.

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

    Подумайте о установке ограничений на такие аспекты, как:

    Request body size
    File upload size
    Database connections
    HTTP request duration
    Cache size
    Queue size
    Worker concurrency
    Pagination size
    Memory usage
    

    Например:

    app.use(express.json({
      limit: "1mb"
    }));
    

    и:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    

    Не существует универсального числа, которое подошло бы во всех случаях.

    Но основной принцип всегда остается в силе.

    У каждого ресурса должен быть разумный лимит.

    Оставлять что-то без ограничений редко бывает разумным решением в производственной среде.

    20. Контролируйте ситуацию до возникновения инцидента, а не после

    Вы не сможете предотвратить каждую простой, которая может произойти.

    То, что вы можете контролировать, — это скорость обнаружения проблемы и скорость её устранения.

    Как минимум, следите за:

    HTTP 5xx rate
    Request latency
    CPU
    Memory
    Database connections
    Database latency
    Event-loop health
    Queue depth
    External API failures
    Restart count
    

    Например:

    09:00 → 0.2% errors
    09:10 → 0.3%
    09:20 → 0.5%
    09:30 → 4.8% 🚨
    

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

    Эффективная система оповещений меняет ситуацию с:

    "Клиенты сообщают нам, что API не работает."

    на:

    «Наш показатель 5xx превысил установленный порог пять минут назад».

    Такая разница в времени отклика действительно имеет значение.

    Паттерн, лежащий в основе большинства этих решений

    Если взглянуть на все эти решения, становится заметна общая закономерность.

    Надежные системы Node.js не работают на предположении, что всё пройдет гладко.

    Вместо этого они создаются с учетом того, что:

    Requests will fail.
    Databases will become slow.
    Networks will timeout.
    Clients will retry.
    Dependencies will go offline.
    Traffic will spike.
    Deployments will restart processes.
    Developers will make mistakes.
    

    и для каждого из этих предположений принимаются меры защиты.

    Результатом такой архитектуры обычно является следующая структура:

                        ┌───────────────┐
                        │     Client    │
                        └───────┬───────┘
                                │
                                ▼
                        ┌───────────────┐
                        │ Reverse Proxy │
                        │ Rate Limiting │
                        └───────┬───────┘
                                │
                                ▼
                      ┌───────────────────┐
                      │    Node.js API    │
                      │                   │
                      │ Validation        │
                      │ Timeouts          │
                      │ Error Handling    │
                      │ Request IDs       │
                      └───────┬───────────┘
                              │
                 ┌────────────┼────────────┐
                 ▼            ▼            ▼
            ┌────────┐   ┌────────┐   ┌─────────┐
            │Database│   │ Redis  │   │ Queue   │
            └────────┘   └────────┘   └────┬────┘
                                          │
                                          ▼
                                   ┌──────────────┐
                                   │Worker / Jobs │
                                   └──────────────┘
    

    У каждого слоя есть четкое назначение.

    У каждой внешней зависимости определена стратегия реагирования на сбои.

    Каждая дорогостоящая операция ограничена определенными рамками.

    Каждое значимое действие можно отслеживать и измерять.

    Именно такое сочетание придает системе устойчивость.

    Самый важный урок

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

    Иногда решением является изменение всего одной строки кода.

    Иногда это добавление отсутствующего индекса.

    Иногда это ограничение времени выполнения внешнего вызова API.

    Иногда это преобразование 1 001 запроса к базе данных в два.

    Иногда это объединение нескольких операций записи в одну транзакцию.

    Иногда это просто обеспечение корректного завершения процесса.

    То, что отличает нестабильный API от надежного, обычно сводится к серии небольших инженерных решений.

    Ни одно из них само по себе не кажется особенно значимым.

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

    Поэтому в следующий раз, когда ваш API на Node.js будет работать ровно так, как ожидается во время разработки, не ограничивайтесь лишь вопросом:

    «Работает ли оно?»

    Лучше спросите:

    «Что произойдёт, если оно не будет работать?»

    Именно этот вопрос определяет настоящую отправную точку для обеспечения надёжности в производственных условиях.

    А лучшим вариантом сбоя является такой, который пользователи даже не замечают, потому что вы обнаружили и устранили проблему до того, как она привела к перерыву в работе».