Backend-инженер Node.js: асинхронность, высокие нагрузки, сети, базы данных и производительность

Курс для глубокой подготовки к требованиям backend-вакансий на Node.js. Разбираем устройство event loop, проектирование высоконагруженных сервисов, базы данных и сетевые протоколы, а также диагностику, тестирование, Linux и оптимизацию производительности.

1. Асинхронность в Node.js и устройство Event Loop

Асинхронность в Node.js и устройство Event Loop

Зачем Node.js «асинхронный»

Node.js часто выбирают для backend-сервисов из-за способности эффективно обслуживать множество одновременных соединений. Ключевая идея: не блокировать поток выполнения ожиданием медленных операций (сеть, диск, DNS, криптография), а продолжать выполнять полезную работу, пока результаты этих операций «доставляются» позже.

В Node.js это достигается комбинацией:

  • Один основной поток JavaScript, который выполняет ваш код.
  • Event Loop, который решает, когда запускать отложенные колбэки.
  • Неблокирующий ввод-вывод (non-blocking I/O) и пул потоков для части операций, которыми управляет библиотека libuv.
  • Важно различать:

  • Конкурентность — много задач «в процессе» (в Node это даёт асинхронность).
  • Параллелизм — одновременное выполнение на нескольких ядрах (в Node достигается не Event Loop’ом, а, например, worker_threads, кластеризацией или внешними системами).
  • Что именно делает асинхронность «быстрой»

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

    Типичный запрос в API часто большую часть времени ждёт:

  • сеть (запрос к БД/кэшу/другому сервису),
  • диск (логирование, чтение файлов),
  • внешние ресурсы.
  • Event Loop помогает использовать это время ожидания, выполняя другие задачи.

    Основные строительные блоки: стек, куча и очереди

    Чтобы понимать Event Loop, нужно знать три сущности:

  • Call Stack (стек вызовов) — где выполняются функции прямо сейчас.
  • Heap (куча) — где хранятся объекты и данные.
  • Очереди задач — списки колбэков, которые будут выполнены позже.
  • JavaScript-код выполняется, пока стек не станет пустым. Когда стек пуст, Event Loop выбирает, какую задачу поставить в стек следующей.

    Роль libuv: почему Node «умеет» ждать

    Node.js использует libuv — кроссплатформенный слой, который:

  • управляет Event Loop,
  • работает с неблокирующим I/O (сокеты, таймеры),
  • содержит thread pool для операций, которые нельзя эффективно сделать неблокирующими на всех платформах.
  • Примеры операций, которые часто используют пул потоков libuv:

  • файловые операции fs (многие из них),
  • crypto (часть функций),
  • dns.lookup().
  • Сетевые операции (HTTP/TCP) обычно опираются на неблокирующие механизмы ОС и не «занимают» пул потоков на ожидании.

    Ссылки:

  • Node.js: The Node.js Event Loop, Timers, and process.nextTick()
  • libuv: Design overview
  • Фазы Event Loop: что происходит по шагам

    Event Loop работает циклами (итерациями). В каждой итерации он проходит несколько фаз и выполняет колбэки, которые готовы к запуску.

    Классическая модель фаз в Node.js:

  • timers — колбэки setTimeout/setInterval, срок которых истёк.
  • pending callbacks — некоторые системные колбэки от предыдущих операций.
  • poll — ожидание I/O и выполнение I/O-колбэков (например, получение данных из сокета).
  • check — выполнение setImmediate.
  • close callbacks — обработчики закрытия (например, 'close' у сокетов).
  • Внутренние фазы idle/prepare существуют, но в прикладном коде обычно не используются напрямую.

    !Диаграмма показывает порядок фаз Event Loop и приоритет microtasks и process.nextTick

    Микрозадачи: Promise и process.nextTick

    Помимо фаз Event Loop есть механизм микрозадач (microtasks). В Node.js важно различать:

  • Очередь process.nextTick() — имеет очень высокий приоритет.
  • Очередь микрозадач Promise — реакции then/catch/finally, продолжения await.
  • Правило, практично полезное для понимания порядка выполнения:

  • Выполняется очередной колбэк из текущей фазы.
  • Затем Node.js вычищает очередь process.nextTick().
  • Затем выполняются микрозадачи Promise.
  • После этого Event Loop продолжает работу по фазам.
  • Следствие: если бесконечно планировать process.nextTick(), можно «задушить» цикл событий, и I/O перестанет обрабатываться.

    Ссылка (про общую идею микрозадач в JS):

  • MDN: Microtask guide
  • Практика: почему порядок setTimeout и setImmediate бывает разным

    setTimeout(fn, 0) и setImmediate(fn) часто путают как «запусти как можно скорее», но они привязаны к разным фазам:

  • setTimeout(..., 0) выполняется в фазе timers (но не «мгновенно», а когда истечёт минимальная задержка и наступит timers).
  • setImmediate(...) выполняется в фазе check.
  • Порядок их срабатывания зависит от контекста:

  • Из верхнего уровня (главный модуль) порядок может быть недетерминированным.
  • Изнутри I/O-колбэка (например, после чтения файла) setImmediate обычно срабатывает раньше setTimeout(0).
  • Пример:

    Частый результат: сначала immediate, потом timeout, потому что после I/O колбэков цикл переходит в check прежде чем снова придёт в timers.

    Колбэки, Promises и async/await: один механизм под разными формами

    Колбэк-стиль

    Исторически Node.js строился вокруг колбэков:

    Особенности:

  • колбэки вызываются позже (обычно в фазе poll, когда I/O готово);
  • ошибки передаются первым аргументом (err).
  • Promise-стиль

    Promise — это объект, представляющий результат, который появится в будущем:

    Здесь продолжение (then/catch) ставится в очередь микрозадач Promise.

    async/await

    async/await — это синтаксический слой над Promise:

    Ключевое: await не блокирует поток. Он приостанавливает текущую async-функцию, а продолжение выполняется позже как микрозадача Promise, когда Promise будет выполнен.

    Что значит «блокировать Event Loop» и почему это опасно

    Node.js отлично обрабатывает множество запросов, пока обработчики быстрые и не блокируют основной поток. Блокировка Event Loop происходит, когда вы выполняете длительную синхронную работу в JavaScript:

  • тяжёлые вычисления,
  • большие циклы без разбиения,
  • синхронные API (fs.readFileSync, crypto.pbkdf2Sync и т.п.).
  • Симптомы:

  • растёт latency на всех запросах,
  • таймауты у клиентов,
  • «подвисание» сервиса без роста CPU до 100% на каждом ядре (часто одно ядро загружено сильнее остальных).
  • Практический приём: если вам нужно сделать тяжёлую задачу, рассматривайте:

  • перенос в worker_threads,
  • вынесение в отдельный сервис,
  • разбиение вычисления на куски и планирование продолжения через setImmediate (чтобы отдавать управление циклу событий).
  • Минимальная модель производительности: что вы должны уметь объяснить

    Когда вы проектируете обработчик HTTP-запроса, важно понимать, какие части:

  • выполняются синхронно в JS и блокируют Event Loop,
  • выполняются асинхронно (I/O) и возвращают управление циклу событий,
  • попадают в microtasks (Promise) и могут «вклиниться» между фазами.
  • Если вы можете для любого участка кода ответить «в какой очереди это окажется и почему», вы уже существенно ближе к уверенной работе с высокими нагрузками.

    Типичные ошибки при работе с асинхронностью

  • Забыть обработать ошибки Promise: необработанные отклонения могут завершить процесс в зависимости от настроек Node и политики проекта.
  • Смешивать колбэки и Promise без понимания очередей: неожиданный порядок выполнения.
  • Считать, что await делает код параллельным: await последовательно ждёт, если вы не запускаете операции заранее.
  • Злоупотреблять process.nextTick: можно создать starvation, когда I/O не получает шанса выполниться.
  • Куда дальше по курсу

    Эта тема — фундамент. Дальше, когда мы будем говорить про высокие нагрузки, сети (HTTP/TCP), базы данных и оптимизацию, мы постоянно будем возвращаться к тому, как:

  • Event Loop распределяет время,
  • микрозадачи влияют на порядок выполнения,
  • блокировки JS-кода превращаются в общую деградацию latency.
  • 2. Высоконагруженные системы: архитектура, масштабирование и надежность

    Высоконагруженные системы: архитектура, масштабирование и надежность

    Что такое высокая нагрузка и почему она ломает «обычный» backend

    Высоконагруженная система — это сервис (или набор сервисов), который должен стабильно работать при:

  • большом числе одновременных пользователей и соединений
  • высоком потоке запросов (RPS)
  • жёстких требованиях к задержкам (latency)
  • неизбежных сбоях зависимостей (БД, сеть, внешние API)
  • Важно: высокая нагрузка почти всегда превращается в задачу управления ожиданием (I/O, блокировки, очереди) и управления отказами. Это напрямую связано с предыдущей темой курса: если ваш Node.js процесс блокирует Event Loop, то под нагрузкой деградирует всё, даже «быстрые» запросы.

    Цели: производительность и надежность как инженерные требования

    Систему под нагрузкой удобно описывать через измеримые цели:

  • Latency: сколько времени занимает запрос (обычно смотрят p50/p95/p99)
  • Throughput: сколько запросов в секунду система выдерживает
  • Availability: доля времени, когда система отвечает корректно
  • Durability: не теряем ли данные при сбоях
  • В современной инженерной практике это связывают с SLO (Service Level Objective) и error budget (допустимый бюджет ошибок). Хорошая отправная точка — материалы Google SRE: Site Reliability Engineering (книга).

    Базовая модель нагрузки: очереди и закон Литтла

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

    Полезная формула (она не про Node.js, а про системы в целом) — закон Литтла:

    Где:

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

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

    Под нагрузкой обычно выигрывают архитектуры, где:

  • запросы на чтение обслуживаются быстро (кэш, реплики)
  • медленные операции выносятся в фон (очереди, воркеры)
  • сервисы остаются статлес (state хранится в БД/кэше), чтобы их можно было масштабировать горизонтально
  • !Схема типовой архитектуры: балансировка, кэш, БД, очередь и воркеры

    Масштабирование: вертикальное и горизонтальное

    Вертикальное масштабирование

    Вертикальное масштабирование — это «дать серверу больше ресурсов»:

  • больше CPU
  • больше RAM
  • быстрее диск
  • Плюсы:

  • проще внедрить
  • меньше распределённых проблем
  • Минусы:

  • есть предел (и часто нелинейный рост стоимости)
  • не решает отказоустойчивость (одна машина — одна точка отказа)
  • Горизонтальное масштабирование

    Горизонтальное масштабирование — это «добавить больше экземпляров сервиса»:

  • несколько Node.js процессов
  • несколько машин/контейнеров
  • балансировщик трафика
  • Ключевое требование: статлесность. Всё, что нужно для обработки запроса, должно быть доступно независимо от того, на какой экземпляр пришёл запрос.

    Полезная практика для проектирования — The Twelve-Factor App.

    Специфика Node.js под нагрузкой: один процесс, один Event Loop, один горячий CPU

    Node.js отлично масштабируется на I/O-нагрузках (много сетевого ожидания), но у него есть важные ограничения:

  • один процесс Node.js имеет один основной поток выполнения JavaScript
  • CPU-тяжёлый синхронный код блокирует Event Loop и повышает latency сразу для всех запросов
  • Отсюда типовые решения:

  • запускать несколько процессов Node.js на хосте
  • выносить CPU-тяжёлые задачи в worker_threads
  • делать деградацию управляемой: таймауты, очереди, лимиты, backpressure
  • Официальные API для масштабирования внутри хоста:

  • Node.js: cluster
  • Node.js: worker_threads
  • Когда использовать cluster, а когда worker_threads

  • cluster — масштабирование обработки запросов между процессами (каждый процесс со своим Event Loop). Хорошо для типичного HTTP API.
  • worker_threads — вынос CPU-bound работы из Event Loop. Хорошо для тяжёлой валидации, криптографии, преобразования больших данных.
  • Важное ограничение: ни cluster, ни worker_threads не заменяют правильную архитектуру. Если вы перегружаете БД или делаете N+1 запросов, больше процессов лишь быстрее «добьют» зависимость.

    Балансировка нагрузки и управление соединениями

    Чтобы горизонтально масштабировать API, нужен балансировщик:

  • L4 (TCP) или L7 (HTTP)
  • health checks (проверка живости)
  • понятная стратегия распределения (round-robin, least connections)
  • Практический момент для Node.js:

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

    Кэш — это способ уменьшить нагрузку на медленные зависимости (обычно на БД) и снизить latency.

    Типичные уровни:

  • CDN/edge-кэш для статического контента и публичных GET
  • reverse proxy кэш перед приложением (иногда)
  • in-memory key-value (например, Redis) для горячих данных
  • локальные in-process кэши (осторожно при нескольких экземплярах)
  • Cache-aside как базовый паттерн

    Один из самых распространённых подходов:

  • сначала читаем из кэша
  • если промах (miss), читаем из БД
  • кладём результат в кэш с TTL
  • Риски, которые важно знать заранее:

  • cache stampede: множество запросов одновременно промахиваются и бьют в БД
  • устаревание: данные могут быть не самыми свежими
  • инвалидация: самая сложная часть кэширования
  • Митигировать stampede помогают:

  • блокировка на ключ (single-flight)
  • случайный джиттер к TTL
  • предварительный прогрев
  • Работа с данными под нагрузкой: SQL, NoSQL и разделение ролей

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

  • медленные запросы
  • блокировки транзакций
  • отсутствие индексов
  • слишком много соединений
  • неудачная модель данных
  • Типовые приёмы масштабирования данных:

  • индексация и оптимизация запросов (первое, с чего нужно начинать)
  • read replicas (масштабирование чтений)
  • шардинг (разделение данных по ключу)
  • CQRS в умеренной форме (развести пути чтения и записи)
  • Важно понимать: как только вы внедряете реплики/шардинг, растёт сложность согласованности данных и обработки ошибок.

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

    Если операция не обязана завершиться в рамках HTTP-запроса (например, отправка письма, генерация отчёта, обработка изображений), её лучше вынести в асинхронный pipeline:

  • API принимает запрос
  • кладёт задачу в очередь
  • воркер обрабатывает задачу в фоне
  • Что это даёт:

  • стабильнее latency API
  • лучше контроль ретраев и ошибок
  • естественная форма backpressure (очередь растёт, но API не «умирает» мгновенно)
  • Но появляется новая ответственность:

  • идемпотентность задач
  • семантика доставки (at-least-once, at-most-once)
  • дедупликация
  • мониторинг длины очереди и времени ожидания
  • Надежность: проектируем систему так, будто сбои неизбежны

    Таймауты как обязательный элемент

    Любой сетевой вызов должен иметь таймаут:

  • запрос к БД
  • вызов внешнего сервиса
  • чтение из кэша
  • Без таймаута запросы «висят», занимают ресурсы и раздувают очереди.

    Ретраи только с ограничениями

    Повторные попытки полезны, но опасны:

  • без ограничений они создают retry storm и добивают зависимость
  • Практика для ретраев:

  • ретраить только ошибки, которые действительно временные
  • делать экспоненциальную задержку
  • добавлять jitter (случайное отклонение), чтобы клиенты не ретраили синхронно
  • Circuit breaker и bulkhead

    Два ключевых паттерна отказоустойчивости:

  • Circuit breaker: если зависимость часто падает или тормозит, временно перестать её дергать и быстро отвечать деградацией
  • Bulkhead: изолировать ресурсы, чтобы одна проблемная часть не утянула весь сервис (например, отдельные лимиты параллельности на разные внешние API)
  • Идемпотентность и дедупликация

    Под нагрузкой вы обязательно увидите:

  • повторные запросы из-за ретраев клиента
  • повторную доставку сообщений из очереди
  • Поэтому операции записи и фоновые задачи нужно проектировать идемпотентными:

  • вводить idempotency key
  • делать уникальные ограничения в БД
  • учитывать повторное выполнение как нормальный сценарий
  • Управление перегрузкой: backpressure, rate limiting, очереди

    Когда входной поток запросов превышает возможности системы, есть только три здоровых стратегии:

  • ограничить вход (rate limiting)
  • поставить в очередь (и контролировать её рост)
  • деградировать функциональность (например, отключить тяжёлые эндпоинты)
  • Для Node.js это особенно важно: если вы безлимитно создаёте Promise и параллельные запросы к БД, вы не «ускоряетесь», вы накапливаете очередь и увеличиваете p99.

    Наблюдаемость: иначе вы не знаете, что происходит

    Три столпа observability:

  • метрики: RPS, p95/p99, ошибки, нагрузка на БД, очередь, CPU, event loop lag
  • логи: структурированные, с корреляционными идентификаторами
  • трейсы: распределённые трассировки для поиска узких мест
  • Под Node.js нагрузкой особенно полезны:

  • метрика event loop lag (показывает блокировки основного потока)
  • метрики пула соединений к БД и времени ожидания в пуле
  • доли ошибок по типам (таймауты, 5xx, отмены)
  • Практический чеклист проектирования highload-сервиса на Node.js

  • делайте обработчик запроса коротким и неблокирующим Event Loop
  • ставьте таймауты на все I/O-зависимости
  • ограничивайте параллельность (пулы, семафоры, очереди)
  • кэшируйте горячие чтения, но проектируйте инвалидацию
  • отделяйте фоновые задачи через очередь и воркеры
  • внедряйте graceful shutdown (перестать принимать трафик, дождаться активных запросов)
  • измеряйте p95/p99 и event loop lag, а не только среднюю задержку
  • Связь с дальнейшими темами курса

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

  • сети: HTTP и TCP как источники задержек, таймаутов и перегрузки
  • базы данных: индексы, транзакции, репликация, шардинг, key-value кэши в памяти
  • производительность: профилирование Node.js, поиск блокировок Event Loop, оптимизация горячих путей
  • тестирование: как модульные тесты помогают безопасно менять код под нагрузкой
  • 3. Хранение данных: SQL, NoSQL и in-memory key-value (Redis)

    Хранение данных: SQL, NoSQL и in-memory key-value (Redis)

    Как эта тема связана с асинхронностью и highload в Node.js

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

  • почему Node.js эффективен на I/O-нагрузках (Event Loop и неблокирующий ввод-вывод)
  • почему под высокой нагрузкой система превращается в очереди, таймауты и деградацию (архитектура и надежность)
  • Хранилище данных почти всегда становится главным источником:

  • задержек (latency)
  • очередей (ожидание соединений в пуле)
  • ошибок (таймауты, блокировки, репликация)
  • Поэтому выбор между SQL, NoSQL и in-memory key-value — это не «религия», а инженерное решение, которое должно укладываться в требования по надежности и производительности.

    !Типовая архитектура, где SQL/NoSQL — источник истины, а Redis ускоряет чтения и снижает нагрузку

    Базовые роли хранилищ в backend-системе

    Практично думать о хранилищах по ролям:

  • Источник истины: данные должны быть сохранены надежно и корректно (чаще SQL, иногда NoSQL)
  • Ускоритель: кэш, сессии, счётчики, лимиты, временные данные (чаще Redis)
  • Поисковый/аналитический слой: отдельные хранилища под специфичные запросы (не основная тема этой статьи)
  • В highload-системах часто используется комбинация, а не один тип БД.

    SQL: реляционная модель, схемы, транзакции

    Когда SQL — лучший выбор

    SQL-базы (PostgreSQL, MySQL) особенно сильны, когда вам нужно:

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

  • Документация PostgreSQL
  • Документация MySQL
  • Схема, ограничения и нормализация

    Реляционная модель держится на идее, что данные:

  • разделены на таблицы
  • связаны ключами
  • защищены ограничениями
  • Типовые ограничения (они уменьшают число ошибок в коде):

  • PRIMARY KEY: уникальный идентификатор строки
  • FOREIGN KEY: ссылка на другую таблицу
  • UNIQUE: уникальность значения (например, email)
  • CHECK: проверка условия (например, age > 0)
  • Нормализация (в разумной степени) помогает:

  • не дублировать данные
  • уменьшать аномалии обновления
  • Но под highload иногда сознательно делают денормализацию ради скорости чтения — и тогда появляется ответственность за согласованность.

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

    Если запросы в SQL «вдруг стали медленными», очень часто причина — отсутствие или неправильный индекс.

    Индекс ускоряет поиск, но имеет цену:

  • занимает место
  • замедляет записи (индекс тоже нужно обновлять)
  • Практический принцип:

  • индексируйте то, по чему реально фильтруете (WHERE), сортируете (ORDER BY) и соединяете (JOIN)
  • не создавайте индексы «на всякий случай»
  • Транзакции и конкурентный доступ

    Транзакция — это способ сделать несколько изменений как одно целое.

    Что важно понимать backend-инженеру:

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

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

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

    Ключевые правила:

  • используйте пул соединений, а не создавайте новое соединение на каждый запрос
  • ставьте таймауты на запросы и на получение соединения из пула
  • следите за очередью ожидания пула (это ранний индикатор перегрузки)
  • Инструменты:

  • node-postgres (pg)
  • Документация Prisma
  • NoSQL: когда реляционная модель мешает

    Что означает NoSQL на практике

    NoSQL — это не один тип БД, а семейство подходов. В контексте типичного backend чаще всего встречается документная модель.

    Полезный источник:

  • MongoDB Manual
  • Документные базы (например, MongoDB)

    Модель: данные хранятся как документы (обычно JSON-подобные структуры), которые могут быть вложенными.

    Сильные стороны:

  • гибкая схема (быстро менять форму данных)
  • удобно хранить «агрегаты» целиком (например, заказ со списком позиций)
  • часто проще стартовать, когда предметная область ещё меняется
  • Типовые риски:

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

  • если данные обычно читаются вместе — храните их вместе
  • если данные обновляются независимо и часто — подумайте о разделении
  • Другие семейства NoSQL (коротко, для ориентации)

  • key-value хранилища (часто для простых сценариев; Redis тоже key-value, но in-memory и со структурами)
  • wide-column (часто для больших объёмов и специфичных паттернов чтения)
  • графовые базы (когда критичны запросы по связям)
  • В этой статье фокус на том, что чаще всего встречается в вакансиях Node.js backend: SQL + документные NoSQL + Redis.

    Как выбрать между SQL и NoSQL: инженерные критерии

    Ниже упрощённая таблица, чтобы быстро «приземлять» выбор к требованиям.

    | Критерий | SQL (PostgreSQL/MySQL) | Документная NoSQL (например, MongoDB) | |---|---|---| | Схема | Жёсткая, через миграции | Гибкая, часто на уровне приложения | | Связи | Естественно через JOIN и FOREIGN KEY | Обычно через вложенность или ручные ссылки | | Транзакции | Базовая сильная сторона | Есть, но часто используется иначе | | Типичные ошибки | плохие индексы, блокировки, N+1 | «разъехавшиеся» форматы документов, дублирование | | Эволюция модели | медленнее, но дисциплинированно | быстрее, но требуется контроль |

    !Простое дерево выбора между SQL, NoSQL и Redis

    Redis как in-memory key-value: ускорение, а не замена базы

    Почему Redis так часто нужен в highload

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

  • разгружать основную БД
  • стабилизировать latency на чтениях
  • реализовывать прикладные примитивы (лимиты, счётчики, блокировки)
  • Официальная документация:

  • Документация Redis
  • Базовые понятия Redis, которые нужны backend-инженеру

  • ключ и значение (но значение бывает разных типов)
  • TTL (время жизни ключа)
  • политики вытеснения (eviction), когда памяти не хватает
  • атомарность одиночных команд (важно для счётчиков и лимитов)
  • Структуры данных Redis и типовые применения

  • String: кэш ответа, токен, флаг, JSON-строка
  • Hash: профиль пользователя по полям
  • List: простая очередь
  • Set: уникальные значения (например, уникальные посетители)
  • Sorted Set: топы, рейтинги, окна по времени
  • Типовые сценарии в backend:

  • кэширование (cache-aside)
  • хранение сессий
  • rate limiting (ограничение запросов)
  • распределённые блокировки (осторожно, с пониманием ограничений)
  • Cache-aside на Redis: самый частый паттерн

    Суть:

  • читать из Redis
  • при промахе читать из БД
  • положить в Redis с TTL
  • Ключевые проблемы под нагрузкой:

  • cache stampede: много запросов одновременно промахиваются и «штурмуют» БД
  • hot key: один ключ читают настолько часто, что он становится узким местом
  • несвежие данные: кэш не успел обновиться или инвалидироваться
  • Практические меры:

  • single-flight на ключ (один запрос строит значение, остальные ждут)
  • джиттер к TTL (чтобы ключи не протухали одновременно)
  • ограничение параллельности запросов к БД
  • Persistency в Redis: RDB и AOF (и почему это важно)

    Redis — in-memory, но он может сохранять данные на диск.

    Два основных механизма:

  • RDB (снимки состояния): быстрее для восстановления, но возможна потеря последних изменений между снимками
  • AOF (журнал команд): меньше потерь, но может быть тяжелее и требует обслуживания
  • Инженерный вывод:

  • Redis часто используют как кэш, и тогда потеря данных допустима
  • если вы используете Redis как часть бизнес-критичного состояния, вы обязаны понимать, что будет при рестарте и сбое
  • Redis Cluster и отказоустойчивость

    В продакшене Redis часто разворачивают с репликацией и/или кластером.

    Что важно на уровне приложения:

  • сетевые сбои и реконнекты — нормальный сценарий
  • команды могут падать по таймауту
  • при failover часть запросов может временно ошибаться
  • Следствие: клиентский код должен быть устойчивым и иметь понятную деградацию.

    Redis в Node.js: клиенты и практические настройки

    Распространённые клиенты:

  • node-redis (репозиторий)
  • ioredis (репозиторий)
  • Практические рекомендации:

  • используйте один клиент (или небольшой пул) на процесс, а не создавайте подключение на каждый запрос
  • задавайте таймауты и обрабатывайте ошибки сети
  • внимательно относитесь к сериализации (JSON) и размеру значений
  • измеряйте hit rate кэша и среднее время команд
  • Антипаттерны, которые ломают систему под нагрузкой

  • хранить в Redis данные без TTL, если это кэш
  • делать Redis единственным источником истины для критичных данных без продуманной надежности
  • бесконтрольно увеличивать параллельность запросов к БД из Node.js (очередь в пуле растёт, p99 растёт)
  • отсутствие таймаутов на запросы к SQL/NoSQL/Redis
  • N+1 запросы (особенно в SQL), которые под нагрузкой превращаются в лавину
  • Наблюдаемость слоя данных: что измерять

    Минимальный набор метрик, который помогает «увидеть» проблемы раньше пользователей:

  • p95/p99 latency запросов к БД и Redis
  • число активных соединений и очередь ожидания в пуле
  • количество таймаутов и ошибок по типам
  • cache hit rate
  • event loop lag (показывает, что ваш процесс Node.js не успевает обслуживать I/O)
  • Практический чеклист выбора и эксплуатации

  • определите, где находится источник истины (обычно SQL или NoSQL)
  • используйте Redis как ускоритель и механизм управления нагрузкой
  • всегда ставьте таймауты и лимитируйте параллельность
  • проектируйте кэш с учётом stampede и инвалидации
  • закладывайте отказ зависимостей как нормальный сценарий (ретраи с ограничениями, деградация)
  • Что дальше по курсу

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

  • сети (HTTP/TCP): таймауты, keep-alive, поведение клиентов и балансировщиков
  • производительность: профилирование Node.js, поиск блокировок Event Loop, оптимизация горячих путей
  • 4. Сетевые протоколы и взаимодействие сервисов: HTTP, TCP, FTP

    Сетевые протоколы и взаимодействие сервисов: HTTP, TCP, FTP

    Как сеть связана с асинхронностью и highload в Node.js

    В предыдущих темах курса мы уже упирались в две причины деградации под нагрузкой:

  • вы блокируете Event Loop синхронным кодом
  • вы неправильно управляете ожиданием I/O: таймаутами, очередями, параллельностью
  • Сеть — главный источник I/O-ожидания в backend. Именно сетевые детали часто объясняют, почему:

  • p95/p99 растут, хотя среднее время «нормальное»
  • сервис «зависает» из-за утечек соединений или отсутствия таймаутов
  • ретраи «добивают» зависимость (retry storm)
  • Дальше мы разберём три уровня практики:

  • что даёт TCP как транспорт
  • как HTTP строится поверх TCP (и почему настройки keep-alive, таймауты и лимиты критичны)
  • где в реальности встречается FTP и почему его важно понимать хотя бы базово
  • !Как прикладные протоколы опираются на TCP и почему это I/O для Node.js

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

  • Протокол — набор правил обмена сообщениями.
  • Соединение — установленный канал связи между двумя сторонами (например, TCP-соединение).
  • Сокет — программный интерфейс (endpoint) для работы с соединением.
  • Latency — задержка (время до ответа), в highload важно смотреть p95/p99.
  • Timeout — ограничение времени ожидания (которое вы обязаны задавать сами).
  • Retry — повторная попытка (полезно только при контроле и идемпотентности).
  • TCP: транспорт, на котором держится большинство backend-взаимодействий

    Что даёт TCP

    TCP — это транспортный протокол, который предоставляет приложению:

  • надёжную доставку (потерянные пакеты будут переотправлены)
  • упорядоченность (байты приходят в том же порядке, в котором отправлены)
  • управление потоком и перегрузкой (чтобы не «залить» сеть или получателя)
  • Официальная спецификация: RFC 9293: Transmission Control Protocol (TCP).

    TCP — это поток байтов, а не «сообщения»

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

  • TCP не знает границ ваших сообщений
  • если вы пишете протокол поверх TCP сами, вам нужна фрейминг-стратегия: длина+данные, разделители, fixed-size заголовок и т.д.
  • Именно поэтому HTTP имеет строгие правила парсинга, а «сырой TCP» требует дисциплины.

    Установка и завершение соединения

    Чтобы начать обмен по TCP, стороны делают 3-way handshake:

  • клиент отправляет SYN
  • сервер отвечает SYN-ACK
  • клиент подтверждает ACK
  • Закрытие соединения — отдельная последовательность (обычно FIN/ACK), и она тоже может занимать время.

    !Почему создание TCP-соединения стоит времени и влияет на latency

    Почему keep-alive так важен

    Создание нового TCP-соединения на каждый запрос дорого по нескольким причинам:

  • дополнительные RTT на handshake
  • нагрузка на CPU (особенно с TLS)
  • риск упереться в лимиты ОС (портов, файловых дескрипторов)
  • Поэтому в backend почти всегда стремятся к переиспользованию соединений:

  • HTTP keep-alive
  • пулы соединений к БД
  • долгоживущие соединения между сервисами
  • Взаимодействие TCP и Node.js: потоки и backpressure

    В Node.js сетевые соединения представлены потоками (stream). Важно понимать backpressure:

  • если вы читаете медленнее, чем приходит, буферы растут
  • если вы пишете быстрее, чем сеть может отправить, буферы растут
  • Ключевой сигнал при записи в поток: socket.write() возвращает false, когда внутренний буфер переполнен. Правильный паттерн — ждать события 'drain'.

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

  • Node.js: net
  • Node.js: stream
  • HTTP: прикладной протокол для API и сервисов

    HTTP — это то, чем чаще всего «разговаривают» frontend, мобильные клиенты и микросервисы.

    Обзор: MDN: HTTP.

    Модель HTTP: запрос и ответ

    Основные элементы запроса:

  • метод (GET, POST, PUT, DELETE, PATCH)
  • путь (/users/123)
  • заголовки (например, Content-Type, Authorization)
  • тело (не всегда)
  • Основные элементы ответа:

  • статус-код (200, 404, 500)
  • заголовки (например, Cache-Control)
  • тело
  • Семантика методов и идемпотентность

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

  • GET должен быть безопасным (не менять состояние) и обычно идемпотентным
  • PUT идемпотентен по задумке: повтор приводит к тому же результату
  • POST обычно не идемпотентен: повтор может создать дубликат
  • Практический вывод для highload:

  • если клиент/прокси ретраит POST, вы обязаны думать о дедупликации
  • частый паттерн: idempotency key (уникальный ключ операции, проверяемый на сервере)
  • Семантика HTTP описана в: RFC 9110: HTTP Semantics.

    HTTP/1.1, HTTP/2 и что меняется для backend

    | Версия | Транспорт | Ключевая идея | Практическое влияние | |---|---|---|---| | HTTP/1.1 | TCP | текстовый протокол, keep-alive, последовательные запросы в рамках соединения | много соединений, важны пулы и таймауты | | HTTP/2 | TCP | мультиплексирование потоков внутри одного соединения | меньше соединений, но возможен hot connection и важны лимиты | | HTTP/3 | QUIC (UDP) | транспорт с быстрым восстановлением и меньшей стоимостью потерь | обычно скрыто за инфраструктурой, но влияет на поведение клиентов |

    Спецификации:

  • RFC 9112: HTTP/1.1
  • RFC 9113: HTTP/2
  • Keep-alive и пул соединений в Node.js

    В Node.js HTTP-клиент по умолчанию использует http.Agent/https.Agent. Для highload почти всегда нужно:

  • включать keep-alive
  • ограничивать количество одновременных сокетов
  • ставить таймауты на запрос и на соединение
  • Документация:

  • Node.js: http.Agent
  • Node.js: https
  • Пример настройки агента:

    Что это решает:

  • уменьшает стоимость установления соединений
  • снижает риск исчерпания ресурсов ОС
  • делает нагрузку предсказуемее
  • Таймауты: обязательный минимум для сетевого кода

    Если у сетевого вызова нет таймаута, под деградацией зависимостей вы получаете:

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

  • таймаут установления соединения
  • таймаут ожидания ответа (request/response timeout)
  • idle timeout (соединение «молчит»)
  • В Node.js таймауты задаются на разных уровнях (клиент, сервер, агент, прокси). Поэтому важно документировать, где именно они выставлены.

    Ретраи: когда можно и когда нельзя

    Ретраи на уровне HTTP полезны при временных сбоях сети, но опасны без правил.

    Минимальные безопасные условия:

  • ретраим только ошибки, которые действительно временные (таймауты, 503, сетевые сбои)
  • используем экспоненциальную задержку и jitter
  • ограничиваем число попыток
  • учитываем идемпотентность операции
  • Это напрямую связано с темой надежности из предыдущей статьи: неконтролируемые ретраи создают retry storm.

    Наблюдаемость HTTP-взаимодействий

    Чтобы «видеть» проблемы сети, обычно измеряют:

  • распределение latency (p50/p95/p99) по каждому upstream
  • количество таймаутов отдельно от прочих ошибок
  • число открытых соединений и их состояние
  • долю ретраев
  • В Node.js дополнительно полезно коррелировать сетевые метрики с:

  • event loop lag
  • нагрузкой на пул соединений к БД/Redis
  • TCP-сервисы поверх net: когда HTTP не подходит

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

  • бинарные протоколы для эффективности
  • кастомные протоколы для стриминга
  • совместимость с существующей инфраструктурой
  • Пример TCP-сервера на Node.js:

    Критические моменты, которые нельзя игнорировать:

  • фрейминг сообщений (границы)
  • backpressure
  • лимиты на размер входных данных
  • таймауты и heartbeats
  • FTP: что это, где встречается и почему важно не перепутать с SFTP

    FTP — старый протокол передачи файлов. В backend он встречается до сих пор:

  • интеграции с банками, гос-системами, legacy-партнёрами
  • выгрузки/загрузки отчётов
  • Спецификация: RFC 959: File Transfer Protocol (FTP).

    Главная особенность FTP: два соединения

    FTP использует:

  • control connection (управление командами)
  • data connection (передача данных)
  • Из-за этого FTP часто вызывает проблемы с NAT и firewall.

    !Почему FTP сложнее в сетевой инфраструктуре из-за отдельного data-канала

    FTP, FTPS и SFTP — это разные вещи

    Важно различать:

  • FTP — без шифрования (в чистом виде небезопасен)
  • FTPS — FTP поверх TLS
  • SFTP — протокол передачи файлов поверх SSH, не имеет отношения к FTP по устройству
  • В вакансиях «знание FTP» часто означает: вы должны понимать, почему интеграция может ломаться из-за режимов active/passive и сетевых ограничений.

    Практический чеклист сетевого взаимодействия сервисов в Node.js

  • выставляйте таймауты на каждый сетевой вызов
  • включайте keep-alive и контролируйте пул соединений
  • ограничивайте параллельность запросов к одному upstream
  • проектируйте ретраи осознанно (идемпотентность, backoff, jitter)
  • учитывайте backpressure при стриминге
  • измеряйте p95/p99 по каждому направлению и отслеживайте таймауты отдельно
  • Связь с дальнейшими темами курса

    Понимание HTTP/TCP прямо влияет на следующие блоки:

  • производительность: как найти, что именно «тормозит» (DNS, connect, TLS, TTFB, чтение тела)
  • Linux и эксплуатация: лимиты файловых дескрипторов, очереди, настройки TCP
  • тестирование: как модульно тестировать код, который делает сетевые вызовы (изоляция через моки, контрактные тесты)
  • Документация Node.js по базовым сетевым модулям, к которой стоит регулярно возвращаться:

  • Node.js: http
  • Node.js: net
  • Node.js: tls
  • 5. Наблюдаемость и устранение проблем: логи, метрики, трассировка, профилирование

    Наблюдаемость и устранение проблем: логи, метрики, трассировка, профилирование

    Зачем нужна наблюдаемость именно в Node.js backend

    В прошлых темах курса мы разобрали, что под нагрузкой деградация обычно начинается из-за:

  • блокировок Event Loop синхронным CPU-кодом
  • очередей на I/O (пул БД, сеть, Redis), таймаутов и ретраев
  • проблем на сетевом уровне (keep-alive, лимиты соединений, TCP/HTTP таймауты)
  • Наблюдаемость нужна, чтобы быстро и доказательно отвечать на вопросы:

  • что именно медленно: наш код, база данных, сеть, Redis, внешний сервис
  • где узкое место: CPU, память, пул соединений, блокировки в БД, backpressure
  • почему это произошло сейчас: релиз, рост трафика, деградация зависимости
  • Цель этой темы — собрать практическую систему инструментов: логи, метрики, трассировки, профилирование, и понять, как ими пользоваться при инцидентах.

    !Как логи, метрики и трассировки связывают запрос пользователя с зависимостями

    Базовые понятия и «три столпа» наблюдаемости

    Логи

    Логи — это события, которые происходят в системе. Они отвечают на вопрос что случилось и с какими данными.

    Хорошие логи в backend обычно:

  • структурированные (JSON), чтобы их можно было фильтровать и агрегировать
  • с корреляцией (например, request_id или trace_id)
  • с корректными уровнями (debug, info, warn, error)
  • Метрики

    Метрики — это численные временные ряды. Они отвечают на вопрос насколько плохо и как меняется ситуация во времени.

    Типовые метрики для Node.js highload:

  • RPS, ошибки по кодам, p95/p99 latency
  • event loop lag
  • использование памяти, частота и длительность GC
  • состояние пулов (пул соединений к БД, активные сокеты)
  • Трассировки

    Трассировка (distributed tracing) — это дерево/граф операций одного запроса через несколько компонентов. Она отвечает на вопрос где именно в цепочке потеря времени.

    Ключевые сущности:

  • trace — весь путь запроса
  • span — отдельный участок работы (например, HTTP upstream, запрос к БД)
  • context propagation — перенос идентификаторов между сервисами
  • Профилирование

    Профилирование — это инструмент для доказательного ответа на вопрос какой участок кода сжигает CPU/память.

    Для Node.js это особенно важно из-за одного главного потока выполнения JavaScript (см. тему про Event Loop): если вы упёрлись в CPU или сделали тяжёлую синхронную работу, вы ухудшаете latency для всех запросов.

    Как связать логи, метрики и трассировки в одну систему

    Главная идея: любой запрос должен быть узнаваем во всех сигналах.

    Практический минимум корреляции:

  • на входе запроса генерировать request_id (или принимать из заголовка, например X-Request-Id)
  • включать request_id в каждый лог в рамках запроса
  • пробрасывать request_id в вызовы upstream (HTTP) и в фоновые задачи
  • Если вы используете OpenTelemetry, то часто достаточно trace_id и span_id, но request_id всё равно бывает удобен для прикладных сценариев.

    Логи: как логировать так, чтобы это реально помогало

    Структурированные логи

    Структурированные логи обычно пишут в JSON: это делает логи машиночитаемыми и позволяет быстро строить фильтры.

    В Node.js часто используют pino как быстрый логгер:

  • Pino
  • Пример (упрощённо):

    Ключевые моменты:

  • logger.child(...) добавляет контекст (например, request_id) ко всем логам
  • длительность запроса логируется как число, чтобы потом строить агрегаты
  • ошибка логируется как объект err, чтобы сохранить stack trace
  • Уровни логирования и правило «не утонуть в логах»

    Типовая дисциплина:

  • debug: подробности для локальной диагностики (обычно выключено в проде)
  • info: бизнес-события и завершение запросов
  • warn: подозрительное поведение, но сервис жив (долгие ответы upstream, ретраи)
  • error: запрос не выполнен или зависимость недоступна
  • Важно: большие объёмы логов под нагрузкой могут сами стать проблемой (I/O и стоимость хранения). Поэтому логирование должно быть дозированным.

    Что логировать в highload-сервисе

    Практический минимум для HTTP API:

  • На каждый запрос
  • - request_id, метод, путь (без секретов), статус, duration_ms
  • На каждый вызов зависимости (БД/Redis/HTTP upstream)
  • - имя зависимости, тип операции, duration_ms, результат (успех/таймаут/ошибка)
  • На ретраи
  • - номер попытки, причина, задержка
  • На деградацию
  • - сработал ли circuit breaker, fallback, rate limit

    Отдельно важно избегать:

  • логирования персональных данных и секретов
  • логирования огромных payload (это ломает стоимость и скорость)
  • Метрики: что измерять, чтобы видеть проблему до пользователей

    Типы метрик

    В прикладной эксплуатации полезно различать:

  • counter — только растёт (например, число запросов)
  • gauge — текущее значение (например, число активных соединений)
  • histogram — распределение (например, latency)
  • Для Node.js-экосистемы часто используют Prometheus + клиентскую библиотеку:

  • Prometheus
  • prom-client
  • Золотые сигналы

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

  • latency: p50/p95/p99 по ключевым ручкам и по upstream
  • traffic: RPS
  • errors: доля ошибок, отдельно таймауты
  • saturation: признаки упора в ресурс
  • В Node.js к saturation часто относятся:

  • event loop lag
  • рост очереди ожидания в пуле БД
  • рост числа активных сокетов и повторных подключений
  • рост RSS/heap и частоты GC
  • Event loop lag как индикатор блокировок

    Event loop lag показывает, насколько Event Loop запаздывает относительно реального времени. Если лаг растёт, значит:

  • вы выполняете слишком много синхронного CPU-кода
  • или создаёте слишком много микрозадач (например, неконтролируемые Promise)
  • Для продакшена удобно измерять event loop delay через встроенный модуль:

  • Node.js: perf_hooks
  • Пример:

    Как это связывается с предыдущими темами:

  • если p99 latency растёт вместе с event loop lag, вы почти наверняка блокируете основной поток
  • если lag нормальный, а latency растёт, чаще виноваты сеть/БД/пулы/таймауты
  • Метрики пулов и очередей

    Под highload система часто ломается не «в среднем», а из-за очередей.

    Практика:

  • Для БД и Redis
  • - количество активных соединений - число ожидающих получение соединения из пула - время ожидания соединения
  • Для HTTP-клиента
  • - число активных сокетов, число свободных сокетов - частота reconnect

    Это напрямую связано с темами про highload и сети: если вы не видите очереди, вы не понимаете p99.

    Трассировка: как быстро найти узкое место в цепочке запроса

    Когда трассировка незаменима

    Трассировка особенно полезна, когда:

  • один HTTP запрос включает несколько запросов к БД, Redis и внешним API
  • есть микросервисы и нужно понять, в каком компоненте деградация
  • проблема проявляется в p99, и по логам сложно понять, где именно потеря времени
  • OpenTelemetry как стандарт

    OpenTelemetry — это стандарт де-факто для инструментирования:

  • OpenTelemetry
  • Ключевая инженерная идея: вы генерируете trace/span в сервисе и экспортируете данные в коллектор/бекенд (Jaeger, Tempo и т.д.).

    В Node.js важно понимать один практический риск: контекст должен корректно переноситься через async-код. Это тесно связано с темой асинхронности.

    Что нужно трассировать в Node.js backend

    Базовый набор span:

  • Входящий HTTP запрос
  • Запросы к БД (SQL/NoSQL)
  • Команды Redis
  • HTTP/TCP вызовы в другие сервисы
  • Наиболее полезные атрибуты:

  • имя операции (db.system, http.method, http.route)
  • статус (успех/ошибка)
  • длительность
  • Смысл: в интерфейсе трассировки вы хотите увидеть, что именно съело, например, 800 мс из 1 секунды.

    Профилирование Node.js: CPU, память, GC

    Когда нужно профилирование, а не просто метрики

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

  • event loop lag растёт при росте трафика
  • CPU на одном ядре близок к 100%
  • p99 растёт даже без деградации БД/сети
  • CPU profiling

    Инструменты:

  • встроенный профилировщик V8 через --cpu-prof
  • Chrome DevTools через --inspect
  • Документация Node.js:

  • Node.js: Command-line API
  • Node.js: Inspector
  • Практический подход в продакшен-подобной среде:

  • Воспроизвести нагрузку (локально или на стенде)
  • Снять CPU профиль на отрезке деградации
  • Найти «горячие» функции и понять, почему они горячие (алгоритм, парсинг, сериализация, regex, лишние JSON)
  • Для нагрузочного теста часто используют:

  • autocannon
  • Memory profiling и утечки

    Симптомы утечек памяти:

  • RSS растёт, не возвращается вниз
  • heap usage растёт ступеньками
  • GC становится чаще и дольше
  • Инструменты:

  • heap snapshot через DevTools (--inspect)
  • анализ объектов, которые продолжают удерживаться
  • Практический совет: утечка в Node.js часто связана не с «низкоуровневой памятью», а с тем, что вы удерживаете ссылки:

  • глобальные кэши без ограничений
  • Map/Set, которые никогда не очищаются
  • подписки на события без отписки
  • Clinic.js как набор практических профайлеров

    Clinic.js удобен как «комбайн» для диагностики CPU/IO/Event Loop:

  • Clinic.js
  • Он помогает быстро получить отчёты, если вы ещё не уверены, где причина: CPU, I/O или блокировки.

    Типовые инциденты и как их расследовать

    Рост p99 latency без роста event loop lag

    Частые причины:

  • Проблемы сети или upstream
  • - вырос TTFB - истёк keep-alive, много reconnect
  • Очередь в пуле БД
  • - слишком много параллельных запросов - медленные запросы без индексов
  • Redis/кэш деградирует
  • - таймауты - hot key

    Что делать:

  • Сначала смотреть метрики по зависимостям (p95/p99 для БД/Redis/upstream)
  • Проверить таймауты и долю ретраев
  • Открыть трассировки и найти самый долгий span
  • Рост p99 latency вместе с ростом event loop lag

    Частые причины:

  • Синхронные тяжёлые операции в обработчиках
  • - сериализация больших объектов - дорогие regex - криптография синхронными методами
  • Слишком много работы в микрозадачах
  • - неконтролируемая генерация Promise/process.nextTick

    Что делать:

  • Снять CPU профиль и найти горячие функции
  • Убрать синхронную тяжёлую работу из Event Loop
  • - вынести в worker_threads - переписать алгоритм - сделать потоковую обработку вместо накопления в памяти

    Падения по памяти (OOM) или постоянный рост RSS

    Частые причины:

  • Утечки ссылок (кэши, глобальные структуры)
  • Накопление буферов из-за backpressure
  • Слишком большие ответы/запросы без лимитов
  • Что делать:

  • Включить метрики heap/RSS и частоты GC
  • Снять heap snapshot и найти «retainers» (кто удерживает объекты)
  • Добавить лимиты на размер входных данных и защиту от больших payload
  • Минимальный «продакшен-стандарт» для Node.js сервиса

    Обязательные практики

  • Логи
  • - структурированные JSON - request_id/trace_id в каждом запросе - единый формат ошибок
  • Метрики
  • - RPS, ошибки, latency p95/p99 - event loop lag - метрики зависимостей (БД/Redis/upstream)
  • Трассировка
  • - входящие запросы + зависимости - корректная передача контекста
  • Профилирование
  • - умение снять CPU/heap профили на стенде - понимание, как интерпретировать «горячие» места

    Почему это связано со всем курсом

  • Асинхронность и Event Loop дают вам язык, чтобы интерпретировать event loop lag, микрозадачи и блокировки.
  • Highload-архитектура объясняет, почему важны очереди, лимиты и saturation-метрики.
  • Базы данных и Redis — главные источники латентности и очередей, поэтому их нужно измерять и трассировать.
  • Сети (HTTP/TCP) объясняют, почему без таймаутов и keep-alive вы не сможете стабильно держать p99.
  • Куда дальше

    Следующие логичные шаги после этой темы:

  • практики модульного тестирования, чтобы безопасно менять код во время оптимизации
  • Linux-эксплуатация: лимиты файловых дескрипторов, сетевые очереди, диагностика соединений
  • углублённая оптимизация производительности: профилирование, аллокации, GC, оптимизация горячих путей
  • 6. Модульное тестирование в Node.js: стратегии, инструменты и практики

    Модульное тестирование в Node.js: стратегии, инструменты и практики

    Зачем модульные тесты в backend на Node.js

    В предыдущих темах курса мы постоянно упирались в реальные причины продакшен-инцидентов:

  • асинхронность и очереди внутри Event Loop
  • высокие нагрузки и деградация зависимостей (БД, сеть, Redis)
  • сетевые таймауты и ретраи
  • необходимость быстро локализовать проблему по логам/метрикам/трейсам
  • Модульные тесты помогают безопасно менять код, когда вы:

  • оптимизируете производительность и меняете «горячий путь»
  • вводите таймауты, ретраи, лимиты параллельности
  • переписываете слой доступа к данным, кэширование или сетевые клиенты
  • Цель модульного тестирования в backend — быстро получать уверенность, что бизнес-логика и контракты функций работают корректно при разных входных данных и ошибках зависимостей.

    Что такое модульный тест

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

  • без реальной базы данных
  • без реальной сети
  • без реального времени и таймеров
  • без обращения к файловой системе
  • Идея изоляции важна для Node.js особенно сильно:

  • асинхронный код легко становится нестабильным (flaky), если вы завязались на реальный тайминг
  • сетевые и БД-зависимости делают тесты медленными и недетерминированными
  • под нагрузкой цена ошибки высока, поэтому быстрый цикл проверки изменений критичен
  • Границы: модульные тесты, интеграционные и e2e

    Один из частых источников конфликтов в командах — когда под «юнитами» подразумевают разные вещи. Удобно договориться о границах так.

    | Тип теста | Что проверяет | С чем работает | Скорость | Когда нужен | |---|---|---|---|---| | Модульный | Логику модуля в изоляции | моки/фейки зависимостей | самая высокая | почти всегда | | Интеграционный | Взаимодействие нескольких компонентов | реальная БД/Redis/HTTP-сервис (часто в контейнере) | средняя | перед релизом, для критичных интеграций | | E2E | Сценарий как у пользователя | вся система целиком | низкая | для ключевых пользовательских путей |

    !Схема помогает увидеть, почему модульных тестов обычно больше всего

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

    Главные свойства хорошего модульного теста

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

    Структура теста: Arrange-Act-Assert

    Самая полезная дисциплина для читаемости — разделять тест на три части:

  • Arrange: подготовка данных и стабов
  • Act: выполнение действия
  • Assert: проверка результата
  • Даже если вы не подписываете эти блоки комментариями, полезно мыслить именно так: тест становится проще и реже превращается в «спагетти».

    Инструменты для модульного тестирования в Node.js

    Встроенный раннер Node.js

    Начиная с современных версий Node.js, есть встроенный тест-раннер node:test.

  • Документация: Node.js test runner
  • Ассерты: Node.js assert
  • Минимальный пример:

    Плюсы:

  • минимум зависимостей
  • хорошая база для инфраструктурных и библиотечных проектов
  • Минусы:

  • экосистема моков/фикстур часто удобнее в специализированных фреймворках
  • Jest

    Jest — популярный выбор для Node.js проектов из-за:

  • удобных моков и spies
  • снапшотов (используйте осторожно)
  • распространённости в командах
  • Vitest

    Vitest часто выбирают за скорость и удобство (особенно если проект использует Vite-инструменты), при этом API во многом совместим с Jest.

    Sinon для моков, шпионов и фейковых таймеров

    Sinon полезен, когда вы используете node:test или другой раннер, но хотите мощные test doubles:

  • spy: проверить, как вызывали функцию
  • stub: подменить реализацию
  • fake timers: стабилизировать время
  • Моки HTTP

    Для модульных тестов сетевого клиента удобно не поднимать сервер, а мокать HTTP-вызовы.

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

    Проблема большинства «нетестируемых» Node.js модулей не в тестах, а в дизайне:

  • модуль сам создаёт клиент БД или HTTP-клиент
  • модуль читает переменные окружения в глубине логики
  • модуль напрямую обращается к Date.now() и таймерам
  • Чтобы модуль было легко тестировать, зависимости обычно делают явными.

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

    Пример бизнес-логики, которая зависит от репозитория и часов:

    В продакшене вы передадите реальные зависимости, а в тесте — фейки.

    Почему это связано с highload

    Когда вы добавляете:

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

    Test doubles: моки, стабы, фейки

    Чтобы не путаться в терминах:

  • Stub (стаб): возвращает заранее заданный результат.
  • Spy (шпион): фиксирует, как вызывали функцию.
  • Mock (мок): обычно подразумевает и подмену, и ожидания.
  • Fake (фейк): упрощённая рабочая реализация (например, in-memory репозиторий).
  • Практический ориентир:

  • для логики удобнее фейки (меньше завязки на детали вызовов)
  • для проверки взаимодействий (например, «должны вызвать sendEmail ровно один раз») — spy
  • Тестирование асинхронного кода: что важно в Node.js

    Всегда дожидайтесь завершения

    Если тест проверяет async функцию, тест должен await её завершения. Иначе вы получите ложноположительные результаты.

    Пример на node:test:

    Проверка ошибок в async-коде

    Важно тестировать и ошибки зависимостей: таймауты, 5xx, «не найдено».

    Тесты не должны зависеть от реального времени

    Если логика завязана на Date.now(), TTL или «окна времени» (например, rate limiting), тесты начинают зависеть от тайминга Event Loop и реальных задержек.

    Подходы:

  • инъекция часов (clock.now())
  • фейковые таймеры через Sinon или возможности раннера
  • Что именно стоит тестировать в backend-модуле

    Бизнес-правила и ветвления

  • валидация входа
  • права доступа
  • идемпотентность (повтор операции не создаёт дубль)
  • поведение при частичных сбоях зависимостей
  • Контракты ошибок

    Для поддержки и наблюдаемости важно, чтобы ошибки были стабильны:

  • коды/типы ошибок
  • сообщения, пригодные для логирования
  • корректная классификация (например, VALIDATION_ERROR vs UPSTREAM_TIMEOUT)
  • Если вы оптимизируете производительность и переписываете код, тесты должны «зафиксировать» контракт ошибок, иначе вы ухудшите диагностику инцидентов.

    Границы, которые лучше не делать модульными

    Есть вещи, которые можно «замокать», но цена таких тестов часто выше пользы:

  • SQL-запросы и индексы лучше проверяются интеграционными тестами на реальной БД
  • реальное поведение HTTP keep-alive, таймаутов и ретраев часто требует стенда
  • Чтобы не потерять контроль, держите правило: модульный тест доказывает логику, интеграционный доказывает «мы правильно говорим с внешним миром».

    Пример: модульный тест сервиса с репозиторием и шпионом

    Ниже пример на node:test + простые ручные doubles.

    Код сервиса:

    Тест:

    Что здесь важно:

  • мы не поднимаем реальный платёжный сервис
  • мы проверяем бизнес-контракт: статус, транзакция, запись результата
  • тест быстрый и не зависит от сети
  • Типичные антипаттерны в модульных тестах Node.js

  • Тестировать детали реализации вместо поведения: например, «вызвали приватную функцию 3 раза».
  • Полагаться на setTimeout в тестах: это почти гарантирует нестабильность.
  • Делиться состоянием между тестами: общий in-memory кэш, общий мок, общий singleton.
  • Мокать слишком глубоко: тест превращается в повторение кода, а не проверку логики.
  • Пытаться модульно тестировать SQL-текст: лучше проверять контракт репозитория и покрывать SQL интеграционно.
  • Минимальная стратегия тестов для backend-инженера Node.js

  • покрывайте модульными тестами бизнес-логику, валидацию и контракты ошибок
  • делайте зависимости подменяемыми (инъекция зависимостей)
  • моделируйте ошибки зависимостей детерминированно (stubs/fakes)
  • держите небольшое число интеграционных тестов для БД/Redis/HTTP-контрактов
  • запускайте тесты в CI на каждый коммит, чтобы оптимизации и изменения под highload не ломали поведение
  • Связь с остальными темами курса

  • Асинхронность и Event Loop: модульные тесты должны быть устойчивыми к асинхронности, а ошибки ожидания Promise — частый источник ложных результатов.
  • Highload: при оптимизациях и введении лимитов параллельности тесты фиксируют контракты и предотвращают регрессии.
  • Базы данных и Redis: тестируйте логику кэширования и идемпотентности модульно, а реальные запросы и индексы — интеграционно.
  • Сети: поведение таймаутов/ретраев в клиенте часто проверяется модульно через моки, а сложные сценарии — на стенде.
  • Наблюдаемость: тесты помогают удерживать стабильные форматы ошибок и предсказуемое поведение, что напрямую улучшает диагностику инцидентов.
  • 7. Linux для backend: базовое администрирование и оптимизация производительности

    Linux для backend: базовое администрирование и оптимизация производительности

    Почему Linux нужно backend-инженеру Node.js

    Node.js-приложение почти всегда живёт внутри Linux-процесса и упирается в ресурсы ОС:

  • CPU и планировщик потоков
  • память и GC, а также ограничения контейнера (cgroups)
  • сеть (сокеты, очереди, TCP-таймауты)
  • файловая система и диски
  • лимиты (файловые дескрипторы, процессы, память)
  • То, что в прошлых темах курса выглядело как асинхронность и очереди (Event Loop, пул БД, keep-alive), на уровне Linux превращается в очереди сокетов, лимиты nofile, backlog, состояние TCP и конкуренцию за CPU. Поэтому умение диагностировать Linux — это часть навыка “устранение проблем” и “оптимизация производительности”.

    !Показывает, как симптомы highload на уровне Node.js соответствуют очередям и лимитам Linux

    Процессы, сигналы и жизненный цикл сервиса

    Процесс и PID

    Linux запускает ваш Node.js как процесс с PID. У процесса есть:

  • потребление CPU и памяти
  • открытые файловые дескрипторы (файлы, сокеты)
  • переменные окружения
  • лимиты ресурсов
  • Базовые команды для ориентации:

  • ps для просмотра процессов и аргументов запуска
  • top для понимания CPU/памяти и общей картины нагрузки
  • uptime для quick-check “а сервер вообще перегружен?”
  • Ссылки:

  • top(1)
  • ps(1)
  • Сигналы: зачем нужен корректный shutdown

    В backend-сервисах критично уметь завершаться “мягко”:

  • перестать принимать новые соединения
  • дождаться активных запросов (или ограниченно по времени)
  • закрыть подключения к БД/Redis
  • сбросить буферы логов
  • На Linux это обычно делается через сигналы:

  • SIGTERM — “попросили завершиться корректно”
  • SIGKILL — “убить немедленно” (процесс не может обработать)
  • systemd и оркестраторы (например, Kubernetes) обычно сначала отправляют SIGTERM, а затем, если процесс не завершился за timeout, применяют жёсткое завершение.

    Полезная практика для Node.js: обрабатывать SIGTERM и SIGINT, выключать приём новых соединений, и иметь “дедлайн” на завершение.

    Ссылка:

  • signal(7)
  • systemd: управление сервисом на продакшене

    systemd — стандартный менеджер сервисов в большинстве современных дистрибутивов Linux. Он отвечает за:

  • запуск/перезапуск вашего процесса
  • политику рестартов
  • сбор логов через journald
  • выставление лимитов (например, LimitNOFILE)
  • Минимально полезные команды

  • systemctl status <service> для статуса
  • systemctl restart <service> для перезапуска
  • journalctl -u <service> для логов
  • Ссылки:

  • systemctl(1)
  • journalctl(1)
  • Пример unit-файла и ключевые директивы

    Что важно для backend-инженера:

  • Restart=on-failure помогает переживать аварии процесса, но не заменяет исправление причин
  • TimeoutStopSec должен соответствовать вашему graceful shutdown
  • LimitNOFILE часто критичен для сетевых сервисов
  • Ссылка:

  • systemd.service(5)
  • Файловые дескрипторы и лимиты: типичный узкий ресурс

    Что такое файловый дескриптор

    Файловый дескриптор (FD) — это “ручка” на ресурс ядра:

  • файл
  • сокет (TCP соединение)
  • pipe
  • HTTP-сервис под нагрузкой может открыть огромное число FD, потому что каждое соединение — это как минимум один сокет.

    Симптомы проблем:

  • ошибки EMFILE или ENFILE
  • неожиданные отказы принимать соединения
  • деградация из-за того, что процесс или система упёрлись в лимит
  • Где смотреть лимиты

  • ulimit -n показывает лимит FD в текущей оболочке
  • cat /proc/<pid>/limits показывает лимиты конкретного процесса
  • lsof -p <pid> показывает открытые файлы и сокеты процесса
  • Ссылки:

  • proc(5)
  • lsof(8)
  • Практический вывод для Node.js

  • keep-alive полезен, но увеличивает число одновременно открытых соединений
  • при всплесках трафика “узким местом” легко становится не CPU, а nofile
  • лимиты стоит задавать на уровне systemd (и/или контейнера), а не “вручную в shell”
  • Сеть на Linux: диагностика и настройки, влияющие на highload

    Как видеть, что происходит с TCP

    Главный инструмент для быстрой диагностики — ss:

  • какие порты слушаем
  • сколько соединений в ESTABLISHED, TIME_WAIT, CLOSE_WAIT
  • не выросло ли число зависших соединений
  • Ссылка:

  • ss(8)
  • Что полезно уметь объяснить:

  • большое число TIME_WAIT часто появляется при большом числе коротких соединений и отсутствии нормального переиспользования
  • большое число CLOSE_WAIT часто указывает на утечки закрытия соединений на стороне приложения
  • Очередь accept и параметр backlog

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

    На поведение влияют:

  • backlog, который вы задаёте в приложении/сервере
  • системные лимиты ядра (например, net.core.somaxconn)
  • Если очередь переполнена, клиенты увидят ошибки подключения или резкий рост latency.

    Ссылки:

  • listen(2)
  • sysctl(8)
  • Порты, маршруты, DNS

    Практический минимум для backend-инженера:

  • ip addr и ip route чтобы проверить сетевые интерфейсы и маршрутизацию
  • dig или getent hosts чтобы проверить DNS-резолв
  • curl -v чтобы быстро понять, где задержка: DNS, connect, TLS, ожидание ответа
  • Ссылки:

  • ip(8)
  • curl(1)
  • CPU: как интерпретировать нагрузку для Node.js

    Load average — не “процент CPU”

    uptime показывает load average. Это не прямой процент использования CPU, а индикатор того, сколько задач в среднем:

  • выполняются на CPU
  • или ждут ресурсов (включая I/O ожидание)
  • Практический смысл:

  • высокий load при низком CPU может означать ожидание диска или сетевых ресурсов
  • для Node.js важно сопоставлять load с метриками event loop lag: если lag растёт и одно ядро “горит”, вероятен CPU-bound код
  • Ссылка:

  • uptime(1)
  • Планировщик и несколько процессов

    Из темы highload вы уже знаете, что один Node.js процесс имеет один основной поток JS. Поэтому на Linux типовой путь масштабирования:

  • несколько процессов (например, несколько реплик сервиса или cluster)
  • вынесение CPU-задач в worker_threads
  • Linux в этом месте важен тем, что именно ОС распределяет процессы по ядрам, и именно ОС ограничивает ресурсы контейнером.

    Память: RSS, heap, page cache и OOM

    Как Linux видит память процесса

    У процесса есть несколько важных “видов” памяти:

  • RSS: сколько физической памяти реально занято (то, что чаще всего “болит”)
  • виртуальная память: адресное пространство, само по себе не всегда проблема
  • Node.js отдельно управляет кучей V8 (heap), но Linux видит общий RSS процесса, куда попадают:

  • heap V8
  • native-аллоцирования (например, Buffers)
  • page cache при работе с файлами
  • OOM и почему процесс “убили”

    Если системе или контейнеру не хватает памяти, может сработать OOM killer.

    Практика эксплуатации:

  • смотреть логи ядра и dmesg, если процесс “внезапно умер”
  • помнить про лимиты памяти контейнера: приложение может быть “нормальным”, но лимит слишком низкий
  • Ссылка:

  • dmesg(1)
  • Диск и файловая система: latency важнее “скорости”

    Backend чаще ломается не из-за “малого места”, а из-за задержек и очередей на диске:

  • синхронная запись логов может добавлять latency
  • активный GC плюс активный диск могут ухудшать p99
  • переполненный диск может ломать не только записи, но и работу зависимостей
  • Минимальный набор проверок:

  • df -h для места
  • du -sh для поиска крупных директорий
  • Ссылки:

  • df(1)
  • du(1)
  • Наблюдаемость на уровне ОС: что полезно собирать вместе с метриками приложения

    Из предыдущей темы про наблюдаемость у вас уже есть логи, метрики и трассировка. На уровне Linux к этому стоит добавить системные метрики, потому что они объясняют “почему”.

    Минимальный продакшен-набор:

  • CPU: загрузка, steal time (в облаке), переключения контекста
  • память: RSS процесса, общий free/available, частота OOM
  • сеть: число соединений, ошибки, retransmits
  • файловые дескрипторы: текущие значения и приближение к лимитам
  • Полезный принцип корреляции:

  • если растёт p99 и event loop lag, смотрите CPU и профилирование
  • если растёт p99, но lag нормальный, смотрите сеть, БД/Redis и очереди пулов
  • если растёт число CLOSE_WAIT или TIME_WAIT, смотрите управление соединениями, keep-alive и утечки закрытия
  • Безопасность и эксплуатационный минимум

    Даже если вы не DevOps, backend-инженер должен понимать базовые практики:

  • запускать сервис не от root, а от отдельного пользователя
  • ограничивать права на файлы конфигурации и секреты
  • хранить секреты вне репозитория (переменные окружения, secret manager)
  • понимать, что открытые порты и firewall — часть поверхности атаки
  • Цель здесь не “настроить всё”, а уметь не создавать очевидных уязвимостей при деплое.

    Осторожный тюнинг sysctl: что можно трогать и почему

    sysctl позволяет менять параметры ядра. Это мощно и опасно: менять стоит только то, что вы можете измерить и объяснить.

    Ссылки:

  • sysctl(8)
  • Документация Linux по сетевым sysctl
  • Ниже — типовые параметры, которые часто упоминают в контексте backend, и что они обычно означают.

    | Параметр | Зачем может понадобиться | Типичный симптом без настройки | Риск неправильной настройки | |---|---|---|---| | net.core.somaxconn | увеличить максимум очереди ожидающих accept | отказы подключения при всплесках | маскировка проблем приложения, если реальная причина в обработке | | fs.file-max | повысить системный лимит открытых файлов | системные ошибки ENFILE | если поднять без контроля, можно упереться в память | | net.ipv4.ip_local_port_range | расширить диапазон исходящих портов для клиентов | проблемы при большом числе исходящих соединений | не решает проблемы отсутствия keep-alive |

    Перед изменениями:

  • измерьте симптом (ошибки, очередь соединений, лимиты)
  • убедитесь, что проблема не в дизайне (ретраи, отсутствие keep-alive, утечки)
  • меняйте параметр минимально и проверяйте эффект
  • Быстрый сценарий диагностики инцидента на Linux

    Когда “всё медленно” или “не принимает соединения”, полезен короткий порядок действий.

  • Проверить общую нагрузку
  • - uptime - top
  • Проверить память и возможный OOM
  • - dmesg на сообщения OOM - top на рост RSS
  • Проверить сеть и соединения
  • - ss -lntp (слушающие порты) - ss -s (сводка по TCP)
  • Проверить лимиты
  • - /proc/<pid>/limits - lsof -p <pid> | wc -l
  • Проверить логи сервиса
  • - journalctl -u <service> --since "10 min ago"

    Этот сценарий связывает темы курса:

  • асинхронность и Event Loop дают объяснение, почему CPU в одном потоке ломает latency
  • highload-архитектура объясняет очереди и перегрузку
  • сети объясняют состояния TCP и важность keep-alive
  • наблюдаемость позволяет коррелировать симптомы и быстро локализовать виновника
  • Что дальше

    На практике Linux-навыки сильнее всего проявляются в двух случаях:

  • когда нужно доказательно найти источник p99 деградации (код, сеть, база, лимит ОС)
  • когда нужно подготовить сервис к нагрузке заранее (лимиты, systemd, правильный shutdown, метрики)
  • Дальнейшие темы курса про производительность будут опираться на это: профилирование и оптимизация в Node.js всегда происходят внутри ограничений и поведения Linux.