Глубокая подготовка по Node.js: структуры данных, алгоритмы, кластеризация, Worker Threads, отладка, Streams и GC

Курс системно закрывает ключевые темы, необходимые для уверенной работы и собеседований по Node.js: от базовой алгоритмики и структур данных до параллелизма, потоков ввода-вывода, отладки и управления памятью. Упор на практику в среде V8/Node.js и понимание производительности.

1. Алгоритмическое мышление в Node.js: оценка сложности и практические паттерны

Алгоритмическое мышление в Node.js: оценка сложности и практические паттерны

Зачем алгоритмическое мышление именно в Node.js

Node.js часто воспринимают как платформу про I/O: HTTP, базы данных, очереди, файлы. Но как только в обработчике запроса появляется CPU-работа (парсинг, агрегации, сортировки, дедупликация, компрессия, криптография, сериализация, валидация больших структур), производительность начинает определяться не фреймворком, а алгоритмами и структурами данных.

Ключевой факт: большинство JS-кода в Node.js выполняется в одном потоке (главный поток event loop). Это значит, что плохая асимптотика или лишние проходы по данным могут блокировать обработку других запросов.

В курсе дальше мы будем углубляться в структуры данных, кластеры, Worker Threads, Streams и GC. Эта статья задаёт базу: как думать об эффективности, чтобы затем правильно выбирать инструменты.

Базовая модель производительности Node.js

Event loop и цена CPU

Node.js обрабатывает множество соединений, но ваш JS выполняется последовательно. Если вы делаете CPU-тяжёлую операцию в обработчике запроса, вы увеличиваете latency для всех, потому что event loop не может переключиться на другие задачи.

Полезная оптика:

  • I/O-bound задачи: время уходит на сеть/диск/БД. Тут помогают параллелизм I/O, кеширование, батчинг.
  • CPU-bound задачи: время уходит на вычисления в JS. Тут важны алгоритмы, структуры данных, Worker Threads, иногда нативные аддоны.
  • Официальная точка входа в тему event loop:

  • The Node.js Event Loop
  • !Схема помогает понять, почему тяжёлые вычисления в JS блокируют обработку других событий.

    Оценка сложности: что считать и почему этого недостаточно без практики

    Big O простыми словами

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

  • : время не зависит от размера входа (например, доступ по индексу массива).
  • : растёт медленно (например, бинарный поиск по отсортированному массиву).
  • : один проход по данным.
  • : обычно сортировки и задачи типа "сначала отсортировать, потом один проход".
  • : вложенные циклы по одному и тому же набору данных (часто проблема).
  • Справочник по Big O (общий, но корректный):

  • Big O notation
  • Что такое в backend-задачах

    В Node.js часто не "количество элементов в массиве" из учебника, а что-то прикладное:

  • число записей, пришедших из БД;
  • число байт в JSON/CSV;
  • число строк логов за интервал;
  • число ключей в объекте конфигурации;
  • число запросов, которые вы агрегируете в батч.
  • Правильный выбор помогает видеть риск до продакшена: если "обычно 100", но "иногда 200 000", то превращается в инцидент.

    Время и память: две оси, а не одна

    Алгоритм может быть быстрее, но потреблять больше памяти, и наоборот.

    Примеры компромиссов:

  • Хранить Set для дедупликации: память , зато поиск в среднем.
  • Делать дедупликацию сортировкой: время , память может быть меньше (особенно если можно сортировать "на месте"), но вы платите сортировкой.
  • Почему Big O не заменяет измерения

    Big O не учитывает:

  • константы (например, парсинг JSON имеет большую константу);
  • особенности движка V8 (оптимизации, деоптимизации);
  • распределение данных (почти отсортированный массив vs случайный);
  • реальные лимиты (GC, кеши CPU, пропускная способность памяти).
  • Поэтому правило такое:

  • Сначала выбрать правильный класс сложности.
  • Затем измерить и подтвердить.
  • Официальная документация по инструментам производительности Node:

  • Performance Hooks
  • Практический паттерн: превращаем "медленно" в формулу

    Шаг 1. Найдите горячую точку и выразите её через

    Если функция делает:

  • один цикл по массиву длины → вероятно
  • сортировку массива → вероятно
  • два вложенных цикла по → вероятно
  • Шаг 2. Определите верхнюю границу

    В Node.js важно спрашивать: какой максимум может прийти в одном запросе/пакете/сообщении.

  • HTTP: есть лимиты тела запроса, но они часто отключены или слишком высокие.
  • Очереди: ретраи могут принести большие батчи.
  • БД: забытый LIMIT превращает в "всё".
  • Шаг 3. Сопоставьте с бюджетом времени

    Для API полезно мыслить бюджетом на CPU в рамках запроса. Если ваш сервис целится в p95 100–200 мс, то выделить 50–100 мс на чистый CPU часто уже рискованно.

    Шаг 4. Выберите преобразование

    Типовые преобразования:

  • через хеш-таблицу (Map/Set)
  • через сортировку + линейный проход
  • через кеширование (где — размер изменившейся части)
  • "всё в память" \to обработка потоком (Streams)
  • Паттерны, которые чаще всего спасают Node.js-код

    Set/Map вместо вложенных циклов

    Задача: пересечение двух массивов.

    Плохо:

    Лучше: в среднем

    Что важно в Node.js:

  • Set и Map обычно дают быстрый поиск, но потребляют память.
  • Если ключи — строки, иногда полезно нормализовать их заранее (например, привести к нижнему регистру один раз, а не в каждой проверке).
  • Справка по Map:

  • MDN: Map
  • Два указателя (two pointers) вместо лишней памяти

    Задача: найти пары в отсортированном массиве, дающие сумму target.

  • Наивно: для каждого элемента искать второй через includes/вложенный цикл.
  • Паттерн: два индекса слева и справа, сдвигаем в зависимости от суммы.
  • Сложность: по времени и по памяти.

    Где это встречается в Node.js:

  • обработка отсортированных временных рядов;
  • merge двух списков событий;
  • оптимизация проверок условий после сортировки.
  • Сортировка + линейный проход как универсальная замена

    Если задача про "найти одинаковые", "сгруппировать", "удалить дубликаты":

  • Отсортировать.
  • Один проход, сравнивая соседние.
  • Это часто вместо , и без дополнительного Set (если память критична).

    Важно: сортировка по умолчанию в JS сортирует как строки, поэтому для чисел нужен компаратор.

  • MDN: Array.prototype.sort
  • Кеширование и мемоизация (осторожно с памятью)

    Если вычисление повторяется и вход маленький (или имеет ограниченное множество значений), кеш может превратить повторные вызовы в .

    Риски в Node.js:

  • бесконечный рост кеша → давление на GC → деградация latency;
  • кеширование объектов по ссылке часто бесполезно (два одинаковых объекта — разные ключи).
  • Практический вывод: кеш почти всегда должен иметь стратегию ограничения (размер/TTL). В следующих статьях (GC и память) мы к этому вернёмся.

    Отложенная работа и батчинг

    Частая проблема: код делает много мелких операций (например, 10 000 маленьких запросов в БД или 10 000 сериализаций), хотя можно объединить.

    Алгоритмическая идея:

  • уменьшить количество операций (уменьшить "внешний" множитель), даже если асимптотика по не меняется.
  • В Node.js батчинг особенно важен для:

  • логирования;
  • записи в БД;
  • отправки метрик;
  • чтения файлов мелкими кусками.
  • Node.js-специфика: синхронный CPU и "скрытые"

    Скрытая квадратичность в конкатенации строк

    Если вы строите большую строку через += в цикле, иногда это может вести к лишним копированиям. Надёжный шаблон:

  • собирать куски в массив;
  • один раз сделать join.
  • Почему это важно: лог-агрегация и генерация текстовых отчётов — типичный backend-кейс.

    Синхронные API в горячих путях

    В Node.js много удобных синхронных API (например, fs.readFileSync). Они полезны в скриптах, но в сервере блокируют event loop.

    Даже если сложность "хорошая", синхронность создаёт стоп-мир для других запросов.

  • Node.js: Synchronous and asynchronous fs APIs
  • JSON как алгоритмический фактор

    JSON.parse и JSON.stringify — это линейные операции по размеру текста (условно по количеству символов), но с большой константой.

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

  • не парсить одно и то же повторно;
  • не сериализовать большие объекты без необходимости;
  • использовать Streams, когда данные большие (к этой теме мы придём в отдельном модуле про Streams).
  • Измерения: минимальный набор практики

    Локальная микрооценка через performance.now()

    Важно интерпретировать корректно:

  • один запуск не показателен (есть шум);
  • JIT может "разогреть" функцию (первые прогоны медленнее);
  • сравнивайте подходы на одинаковых данных.
  • Профилирование CPU

    Когда "непонятно, где медленно", нужны профили.

    Базовые инструменты:

  • встроенный CPU profiler через --inspect и Chrome DevTools
  • отчёты --cpu-prof
  • Документация:

  • Node.js: Debugging Guide
  • Как выбирать между алгоритмом, Streams, Cluster и Worker Threads

    Эта статья про алгоритмы, но в реальном Node.js вы почти всегда выбираете из нескольких рычагов:

  • Улучшить алгоритм и структуру данных.
  • Если данные большие — обрабатывать потоком (Streams) и не держать всё в памяти.
  • Если задача CPU-bound и её нельзя упростить — вынести вычисления в Worker Threads.
  • Если нужно масштабирование по ядрам на уровне процесса — использовать cluster или несколько процессов (например, через менеджер процессов).
  • Правильная последовательность обычно такая:

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

  • Определи (что именно растёт) и максимальный .
  • Найди подозрительные места: вложенные циклы, find/includes внутри цикла, сортировки, повторный JSON.parse.
  • Выбери структуру данных: Map/Set часто меняют игру.
  • Проверь память: быстрый подход не должен убить GC.
  • Измерь: perf_hooks, профилирование.
  • Если CPU всё равно много — готовь почву для Worker Threads (в следующих модулях курса).
  • 2. Структуры данных в JavaScript/V8: массивы, хеш-таблицы, деревья, кучи и графы

    Структуры данных в JavaScript/V8: массивы, хеш-таблицы, деревья, кучи и графы

    Как эта тема связана с предыдущей статьёй и почему она критична в Node.js

    В прошлой статье мы смотрели на код через призму асимптотики и того, что любой CPU-кусок в Node.js потенциально блокирует event loop. Следующий шаг после выбора класса сложности — выбрать структуру данных, которая реально даст нужную асимптотику и не убьёт память/GC.

    Node.js исполняет JavaScript на движке V8, а значит поведение Array, Object, Map, Set и даже “обычных” паттернов вроде “добавляю элементы в массив” может заметно отличаться по скорости и по количеству аллокаций. Это особенно важно в:

  • обработчиках HTTP-запросов и webhook’ов
  • консьюмерах очередей (батчи сообщений)
  • ETL-джобах, которые читают гигабайты логов
  • сервисах агрегации метрик и событий
  • Дальше по курсу, когда перейдём к Streams, Worker Threads, Cluster и GC, вы будете принимать решения “делать ли это в одном процессе”, “стримить или грузить в память”, “распараллеливать ли CPU”. Но фундамент всегда один: правильно выбрать структуру данных и форму хранения данных в памяти.

    !Диаграмма причинно-следственной связи между выбором структуры данных и задержками в Node.js

    Ментальная модель: что именно выбираем

    Структура данных — это ответ на три вопроса:

  • Как храним элементы: непрерывно (как массив), по ссылкам (как дерево), по бакетам (как хеш-таблица)
  • Какие операции будут “горячими”: поиск, вставка, удаление, сортировка, выбор min/max
  • Какой профиль памяти допустим: много мелких объектов (давление на GC) или меньше объектов, но больше “плотных” буферов
  • Практически для backend-кода полезно думать не только “ vs ”, но и:

  • сколько временных объектов создаётся
  • насколько предсказуемо растёт память
  • можно ли обрабатывать данные потоком, не держа всё целиком
  • Официальные справочники по базовым структурам:

  • MDN: Array
  • MDN: Map
  • MDN: Set
  • Массивы в V8: не просто “список значений”

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

    Array обычно идеален, когда:

  • вы часто итерируетесь по всем элементам
  • порядок важен
  • вы добавляете в конец (push) и редко вставляете в середину
  • доступ по индексу — основная операция
  • В терминах сложности:

  • доступ по индексу: обычно близко к
  • проход:
  • поиск элемента без индекса:
  • V8-специфика: “плотные” и “дырявые” массивы

    В V8 у массивов есть разные внутренние представления (упрощённо):

  • плотные массивы: индексы идут подряд, мало пропусков
  • дырявые массивы: большие пропуски индексов, частые delete arr[i], присваивания далеко за пределами текущей длины
  • Практический эффект для Node.js:

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

    Лучше:

  • хранить разреженные данные в Map
  • или сжимать индексы (ремаппинг)
  • Числа и типы элементов

    JavaScript числа — это Number (IEEE 754 double), но V8 может оптимизировать хранение некоторых чисел. На практике правило для прикладного кода простое:

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

  • MDN: TypedArray
  • Очередь на массиве: частая ловушка

    Наивная очередь:

    shift() в массиве обычно стоит , потому что нужно переиндексировать элементы.

    Если вам нужна очередь в горячем пути:

  • используйте кольцевой буфер
  • или храните head индекс
  • Пример “очередь с индексом”:

    Это компромисс: быстрые shift(), но иногда случается slice.

    Хеш-таблицы: Object vs Map vs Set

    Что такое хеш-таблица в прикладном смысле

    Хеш-таблица даёт быстрый доступ по ключу (в среднем близко к ) за счёт:

  • вычисления хеша от ключа
  • размещения в “бакете”
  • разрешения коллизий
  • На практике в Node.js это основной инструмент, чтобы:

  • заменить вложенные циклы
  • делать дедупликацию
  • строить индексы по id
  • Object как словарь: когда можно

    Object удобен, когда:

  • ключи — небольшие строки
  • не нужна гарантия порядка итерации как “контракт” (полагаться на порядок опасно)
  • вам не нужны ключи-объекты
  • Но есть нюансы:

  • у объекта есть прототип (можно “наехать” на __proto__ и похожие свойства)
  • ключи всегда строковые или символы
  • Если вы используете объект строго как словарь, часто уместно создавать его без прототипа:

    Справка:

  • MDN: Object.create
  • Map: предсказуемый словарь для backend

    Map обычно предпочтительнее для “словаря” в серверном коде, потому что:

  • ключом может быть что угодно (включая объект)
  • нет проблем с прототипом
  • размер доступен через map.size
  • Практические правила:

  • если ключи — строки из внешнего ввода, Map безопаснее, чем “голый” объект
  • если нужен счётчик, Map часто чище, чем объект
  • Пример счётчика:

    Set: дедупликация и проверки принадлежности

    Set — это Map, где “значение не важно”. Типовые применения:

  • убрать дубликаты
  • проверять “видели ли уже”
  • Память и GC: цена хеш-таблиц

    Map и Set обычно дают скорость, но:

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

    Практический вывод:

  • кеши и “таблицы виденных элементов” почти всегда должны иметь ограничение (TTL, max size, LRU)
  • Деревья: когда массив и хеш-таблица не подходят

    Под деревом будем понимать структуру “узел + ссылки на дочерние узлы”. В JS это почти всегда объекты.

    Почему деревья в Node.js встречаются чаще, чем кажется

  • AST (парсинг кода, шаблонов, выражений)
  • DOM-подобные структуры (HTML, XML)
  • конфиги и политики (наследование, композиция)
  • индексирование по префиксу (trie)
  • Бинарное дерево поиска и балансировка

    Бинарное дерево поиска (BST) концептуально даёт:

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

    В Node.js обычно не реализуют BST руками для “словаря”, потому что Map проще и быстрее в среднем. Деревья появляются, когда нужна структура:

  • диапазонные запросы
  • упорядоченное хранение с обходом
  • префиксный поиск
  • Trie (префиксное дерево) для поиска по префиксу

    Если вам нужно быстро находить все ключи по префиксу (например, автокомплит, роутинг, фильтры), Map не помогает напрямую: придётся перебирать все ключи.

    Trie хранит ключи “по символам”. Упрощённый набросок узла:

    Компромиссы:

  • быстрее поиск по префиксу
  • больше объектов в памяти, выше нагрузка на GC
  • Практическое правило для Node.js:

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

    Кучи (heap) как структура данных: основа приорити-очереди

    Не путать с heap-памятью

    В JavaScript слово heap встречается в двух смыслах:

  • heap-память (область памяти, где живут объекты)
  • куча как структура данных (обычно бинарная куча для приоритетной очереди)
  • Здесь речь про структуру данных.

    Когда нужна приоритетная очередь

    Приоритетная очередь нужна, когда вы постоянно делаете операции:

  • добавить элемент с приоритетом
  • достать минимальный или максимальный элемент
  • Типовые кейсы в Node.js:

  • планировщики задач
  • симуляции, обработка событий по времени
  • алгоритмы на графах (Dijkstra, A*)
  • Мини-куча на массиве

    Бинарная куча обычно хранится в массиве, где для индекса i:

  • левый ребёнок: 2 * i + 1
  • правый ребёнок: 2 * i + 2
  • Операции:

  • push:
  • popMin/popMax:
  • просмотр min/max:
  • Упрощённая реализация min-heap:

    Важно для Node.js:

  • куча хорошо работает на массиве, то есть без “леса” объектов
  • но если вы кладёте внутрь кучи большие объекты, вы всё равно создаёте давление на GC
  • Графы: как представлять связи в JavaScript

    Граф — это:

  • вершины (nodes)
  • рёбра (edges), ориентированные или нет
  • Где графы встречаются в backend

  • зависимости сервисов и модулей
  • social graph (подписки, друзья)
  • маршрутизация, оптимальные пути
  • построение пайплайнов задач (DAG)
  • Представление графа: матрица смежности vs список смежности

    Основные варианты:

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

    Таблица сравнения:

    | Представление | Память | Проверка ребра (u,v) | Перебор соседей | |---|---|---|---| | Матрица смежности | много, примерно | быстро | | | Список смежности | обычно меньше, зависит от рёбер | зависит от структуры | быстро |

    Практическая реализация списка смежности:

  • Map<Node, Array<Node>> для простого случая
  • Map<Node, Set<Node>> если часто проверяете “есть ли ребро”
  • BFS и DFS: почему важна структура очереди/стека

    BFS (поиск в ширину) обычно требует очередь. Если сделать очередь через shift(), вы получаете лишние внутри алгоритма и итоговую деградацию.

    Правильнее:

  • очередь с индексом (как выше)
  • или deque-реализация
  • Пример BFS с очередью на индексе:

    Эта деталь часто решает судьбу алгоритма в продакшене: “вроде BFS” превращается в неожиданный тормоз из-за неудачной структуры очереди.

    !Иллюстрация BFS и почему очередь с head-индексом не делает сдвиг элементов

    Node.js-специфика: память, аллокации и “стоимость” объектов

    Почему “много маленьких объектов” опасно

    В JS деревья и графы часто реализуются как множество узлов-объектов. Это удобно, но:

  • каждый объект создаётся в куче (heap-памяти)
  • больше объектов означает больше работы для GC
  • GC-паузы могут ухудшать p95/p99 latency
  • Справка по флагам и диагностике GC в Node.js:

  • Node.js: CLI options
  • Практические эвристики:

  • если можно хранить данные “плотно” (массивы, TypedArray, буферы) — это часто дешевле для GC
  • если структура строится на один запрос, подумайте о потоковой обработке (к теме Streams вернёмся отдельно)
  • Выбор структуры данных как подготовка к Worker Threads

    Если у вас CPU-bound задача и вы планируете выносить её в Worker Threads, структура данных влияет на стоимость передачи:

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

  • Node.js: Buffer
  • Практический чеклист выбора структуры данных в Node.js

  • Если нужен быстрый доступ по ключу: Map (или Object.create(null) для простого словаря)
  • Если нужна дедупликация и проверки принадлежности: Set
  • Если нужно много проходов и важен порядок: Array
  • Если нужна очередь: не shift(), а очередь с индексом или deque
  • Если нужен “всегда минимальный/максимальный”: куча (priority queue)
  • Если нужны связи “кто с кем”: граф через список смежности (Map -> Array/Set)
  • Если данных много: минимизируйте аллокации и думайте о потоковой обработке
  • Куда дальше по курсу

    Следующие темы курса будут опираться на эту статью:

  • Streams: как обрабатывать большие данные без “загрузить всё в массив”
  • GC: как структуры данных и аллокации влияют на паузы и стабильность latency
  • Worker Threads: как устроить CPU-параллелизм и какие структуры данных выгодно передавать
  • Cluster: как масштабировать по ядрам на уровне процессов, когда оптимизации структур данных уже недостаточно
  • 3. Алгоритмы для задач Node.js: поиск, сортировка, графы, строки и оптимизации

    Алгоритмы для задач Node.js: поиск, сортировка, графы, строки и оптимизации

    Как эта статья продолжает курс

    В первых двух статьях мы закрепили две опоры для производительного Node.js-кода:

  • алгоритмическое мышление: оценка времени и памяти, поиск горячих мест, понимание, что CPU в основном потоке блокирует event loop
  • структуры данных в V8: Array, Map, Set, очереди без shift(), графы как списки смежности
  • Теперь соберём это в прикладной набор алгоритмов, которые чаще всего встречаются в backend-задачах на Node.js: поиск, сортировка, базовые графовые алгоритмы, строковые задачи и практические оптимизации.

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

    Поиск: линейный, бинарный и индексирование

    Линейный поиск как дефолт

    Линейный поиск — это проход по массиву/коллекции до нахождения элемента.

  • время: , где — количество элементов
  • память:
  • В Node.js линейный поиск часто подходит, если:

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

    Опасность начинается, когда линейный поиск оказывается вложенным (например, find внутри цикла) и превращается в .

    Бинарный поиск: быстрый, но требует сортировки

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

  • время:
  • память:
  • Здесь — длина массива, а — “сколько раз можно делить массив пополам, пока не останется 1 элемент”.

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

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

    Важно в продакшене:

  • бинарный поиск выгоден, если вы много раз ищете по одному и тому же отсортированному массиву
  • если “сортировать перед каждым поиском”, вы платите сортировкой и можете проиграть обычному Map
  • Индексирование через Map: часто лучше бинарного поиска

    Если данные обновляются, а поиск по ключу частый, то самый практичный “алгоритм поиска” в Node.js — это построение индекса.

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

  • MDN: Map
  • Сортировка: когда она помогает и как не ошибиться в JavaScript

    Зачем сортировка в backend-задачах

    Сортировка — это не только “вывести по алфавиту”. В Node.js сортировка часто используется как преобразование для ускорения дальнейших шагов:

  • дедупликация: сортировка + линейный проход
  • группировка одинаковых ключей рядом
  • two pointers (два указателя) для задач на пары/диапазоны
  • подготовка данных для бинарного поиска
  • Сложность сортировки сравнениями обычно .

    Array.prototype.sort: компаратор и типичные ловушки

    В JS сортировка без компаратора сортирует элементы как строки.

    Правильно для чисел:

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

  • MDN: Array.prototype.sort
  • Практическое правило для Node.js:

  • если компаратор тяжёлый (например, внутри sort делаете localeCompare с подготовкой строк), вы получаете дорогую константу
  • выносите нормализацию ключа из компаратора, чтобы не повторять её тысячи раз
  • Пример: сортировка по нормализованному email.

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

    Дедупликация: Set vs сортировка

    Есть два типовых решения.

    Вариант A: Set.

  • время: в среднем
  • память:
  • Вариант B: сортировка + один проход.

  • время:
  • память: может быть меньше, если можно сортировать “на месте”
  • Как выбрать в Node.js:

  • если у вас есть запас памяти и важна скорость — чаще побеждает Set
  • если память под давлением (GC, большие входы) — сортировка может быть приемлемым компромиссом
  • Графовые алгоритмы для типовых задач backend

    Графы в Node.js всплывают чаще всего в “задачах про зависимости”: порядок выполнения задач, связи сервисов, цепочки обработки.

    Представление графа

    Самый практичный вариант — список смежности:

  • Map<вершина, Array<соседи>> для простых обходов
  • Map<вершина, Set<соседи>>, если часто проверяете наличие ребра
  • BFS: кратчайшее число шагов в невзвешенном графе

    BFS (поиск в ширину) полезен, когда:

  • все рёбра “одинаковой цены”
  • нужно найти минимальное число переходов
  • В Node.js важная деталь: не используйте shift() для очереди, иначе внутри BFS появится лишнее .

    Где это встречается:

  • “на сколько шагов сервис A зависит от сервиса B”
  • поиск ближайших узлов в графе маршрутизации
  • анализ связей между сущностями (без весов)
  • DFS: достижимость, компоненты связности, циклы

    DFS (поиск в глубину) полезен для:

  • проверки достижимости
  • поиска компонент
  • детекта циклов в ориентированном графе (важно для зависимостей)
  • Итеративный DFS часто предпочтительнее рекурсивного в Node.js, чтобы не упереться в ограничение стека вызовов.

    Топологическая сортировка: порядок выполнения задач

    Топологическая сортировка применяется к DAG (ориентированному ациклическому графу): графу зависимостей без циклов.

    Backend-кейсы:

  • порядок миграций
  • порядок сборки модулей
  • пайплайн задач (job A должен завершиться до job B)
  • Алгоритм Кана (через входные степени) — практичный и итеративный.

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

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

    Строковые задачи в Node.js — это JSON, логи, HTTP-пути, заголовки, user input, шаблоны.

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

    Для простых задач используйте:

  • includes
  • indexOf
  • startsWith / endsWith
  • Они оптимизированы в движке и обычно быстрее самописных “наивных” циклов.

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

  • MDN: String.prototype.includes
  • Если вам нужно искать много разных паттернов или строить индекс по большим текстам, это уже отдельный класс задач (и часто повод вынести обработку из request-path или перейти к потоковой обработке).

    Нормализация строк: делайте её один раз

    Типовая ошибка: сравнивать строки в разных форматах много раз (регистр, пробелы, Unicode).

    Правильный паттерн:

  • нормализовать “вход”
  • нормализовать “ключи”
  • сравнивать нормализованное
  • Регулярные выражения и ReDoS

    Регулярки в Node.js мощные, но опасны, если они могут привести к катастрофическому бэктрекингу (ReDoS) на специально подобранном вводе.

    Практические рекомендации:

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

  • OWASP: Regular expression Denial of Service - ReDoS
  • Сборка больших строк: join вместо += в цикле

    При генерации больших текстов (отчётов, логов, SQL) надёжнее собирать куски и делать join, чтобы не провоцировать лишние копирования.

    Оптимизации, специфичные для Node.js: CPU, event loop, память

    Алгоритм “на бумаге” может быть хорошим, но в Node.js нужно учитывать исполнение в одном потоке и стоимость аллокаций.

    Убирайте лишние аллокации: меньше работы GC

    Частые источники мусора:

  • arr.map(...).filter(...).reduce(...) в горячих местах, создающие временные массивы
  • создание объектов в циклах, если можно хранить примитивы или переиспользовать структуры
  • Иногда один аккуратный цикл быстрее и стабильнее по latency:

    Это напрямую связывается с будущей темой Garbage Collection: чем больше временных объектов, тем выше шанс пауз и деградации p95/p99.

    “Разбивайте” тяжёлый CPU, если нельзя вынести в Worker

    Если задача CPU-тяжёлая и выполняется в основном потоке, можно хотя бы периодически отдавать управление event loop, чтобы не “замораживать” процесс.

    Пример: обработка большого массива батчами через setImmediate.

    Это компромисс: суммарное CPU-время не исчезает, но снижается риск “залипнуть” на сотни миллисекунд.

    Далее по курсу более правильное решение для CPU-bound задач — worker_threads:

  • Node.js: Worker threads
  • Выбирайте правильный формат данных для передачи и обработки

    Если вы планируете параллелить вычисления (Worker Threads) или просто уменьшить нагрузку на память:

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

  • MDN: TypedArray
  • Node.js: Buffer
  • Идея, важная для практики: сложные графы из объектов дорого передавать между потоками, а плотные буферы можно передавать эффективнее.

    Измеряйте на реальных данных

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

    Минимальный инструмент для таймингов:

  • Node.js: perf_hooks
  • И для профилирования:

  • Node.js: Debugging Guide
  • Сводный чеклист выбора алгоритма для Node.js

  • Поиск по ключу много раз: строим индекс Map, а не делаем линейный поиск
  • Нужен поиск по отсортированным данным: бинарный поиск, но сортировка должна быть “амортизирована” многими запросами
  • Дедупликация: Set (быстро) или сортировка + проход (компромисс по памяти)
  • Граф зависимостей: топологическая сортировка; если есть цикл, это ошибка входных данных
  • Обход графа: BFS для минимального числа шагов, DFS для достижимости/циклов; очередь без shift()
  • Строки: нормализовать один раз; regex применять осторожно; большие строки собирать через join
  • Под нагрузкой: меньше аллокаций, меньше временных массивов, контролировать CPU в основном потоке
  • В следующих модулях курса эти решения станут “строительными блоками” для масштабирования (Cluster), параллелизма (Worker Threads), потоковой обработки (Streams) и стабильности (GC).

    4. Параллелизм и масштабирование: Cluster, процессы, IPC и балансировка нагрузки

    Параллелизм и масштабирование: Cluster, процессы, IPC и балансировка нагрузки

    Почему эта тема логично продолжает блок про алгоритмы и структуры данных

    В предыдущих статьях мы рассматривали производительность Node.js через призму алгоритмов и структур данных: неудачная асимптотика и лишние аллокации блокируют event loop и ухудшают задержки.

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

  • несколько процессов (через cluster, child_process или оркестрацию)
  • балансировку нагрузки между ними
  • IPC (межпроцессное взаимодействие), чтобы координировать работу
  • Ключевое ограничение остаётся тем же: основной JavaScript в каждом процессе выполняется в одном потоке, а значит один процесс эффективно использует только одно ядро CPU. Чтобы задействовать несколько ядер, обычно нужны несколько процессов.

    Ментальная модель: процесс, поток, Worker Threads и Cluster

    Чтобы не путаться, зафиксируем термины в контексте Node.js:

  • Процесс: отдельный экземпляр Node.js со своей памятью (heap), своим GC и своим event loop.
  • Поток (thread): единица исполнения внутри процесса. В Node.js есть системные потоки (например, пул libuv), но ваш JS по умолчанию исполняется в одном основном потоке.
  • Worker Threads: потоки внутри одного процесса Node.js, удобны для CPU-задач и позволяют разделять память (например, через SharedArrayBuffer).
  • Cluster: встроенный модуль Node.js, который запускает несколько процессов-воркеров и помогает им слушать один и тот же порт.
  • Практическое правило выбора:

  • Если задача CPU-bound и нужно распараллелить вычисления внутри сервиса, часто начинают с Worker Threads.
  • Если нужно использовать несколько ядер для HTTP-сервера и повысить отказоустойчивость через изоляцию памяти, часто выбирают несколько процессов и балансировку (включая cluster).
  • Документация:

  • Node.js: cluster
  • Node.js: child_process
  • Node.js: worker_threads
  • !Общая картина: несколько процессов Node.js, балансировка нагрузки и IPC

    Зачем вообще несколько процессов в Node.js

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

  • Использовать все ядра CPU: один процесс Node.js не даст линейного прироста на многопроцессорной машине.
  • Изоляция отказов: утечка памяти или падение в одном воркере не обязаны убить весь сервис.
  • Разные профили нагрузки: иногда выгодно выделять отдельные процессы под разные типы запросов.
  • Снижение влияния GC: GC-паузы и давление на память ограничены одним процессом; несколько процессов уменьшают “радиус поражения” (но увеличивают суммарное потребление памяти).
  • Важно: несколько процессов почти всегда означают, что вы больше не можете хранить “глобальное состояние сервиса” в памяти одного процесса.

    Cluster: как он устроен и что реально делает

    Что такое cluster на практике

    cluster запускает несколько воркеров-процессов, обычно равных числу CPU-ядер, и позволяет им совместно обслуживать входящие подключения.

    В современных версиях Node.js архитектура называется primary/worker (раньше master/worker):

  • Primary процесс управляет воркерами.
  • Workers — отдельные процессы Node.js, которые выполняют ваш серверный код.
  • Минимальный пример HTTP-сервера на cluster

    Что важно понять:

  • Воркеры не являются “потоками”; это полноценные процессы.
  • У каждого воркера свой heap и свой GC.
  • Если один воркер занят CPU, другие воркеры могут продолжать обслуживать запросы.
  • Политика распределения соединений

    Балансировка входящих соединений может быть:

  • Встроенная (Node.js принимает соединения и раздаёт воркерам) — часто обсуждается как round-robin, но детали зависят от платформы и версии Node.js.
  • На уровне ОС (несколько процессов слушают один порт) — поведение зависит от системы.
  • У cluster есть параметр cluster.schedulingPolicy (см. документацию), но в продакшене чаще мыслят крупнее: “где у меня настоящий балансировщик и какая модель сессий”.

    Процессы без cluster: когда лучше внешний балансировщик

    cluster удобен для локального масштабирования на одной машине. Но во многих production-сценариях масштабирование делают так:

  • запуск нескольких процессов Node.js (не обязательно через cluster)
  • балансировка перед ними через Nginx, Envoy или L4 балансировщик
  • Плюсы внешнего балансировщика:

  • единая точка для TLS, лимитов, таймаутов и буферизации
  • проще “раскатывать” версии и делать canary
  • легче масштабировать на несколько машин
  • Официальная документация Nginx по upstream:

  • Nginx: HTTP Upstream Module
  • IPC: как процессы в Node.js общаются

    Что такое IPC в Node.js

    IPC (Inter-Process Communication) — это обмен сообщениями между процессами. В cluster IPC-канал создаётся автоматически между primary и каждым worker.

    Формат сообщений — обычные JS-объекты, которые Node.js сериализует и передаёт.

    Пример: worker отправляет метрику в primary

    Практические замечания про IPC:

  • IPC удобно для координации и контроля, но плохо подходит для передачи больших объёмов данных.
  • Сериализация сообщений — это CPU и аллокации, которые влияют на latency.
  • IPC не превращает память в общую: процессы по-прежнему изолированы.
  • Если вам нужна общая память, это уже область Worker Threads (а не процессов).

    !Наглядно показывает направления IPC и его назначение

    Балансировка нагрузки: stateless, sticky sessions и WebSocket

    Stateless как целевое состояние

    Самый надёжный и масштабируемый подход: воркеры должны быть stateless.

    Это означает:

  • не хранить “сессии” в памяти воркера
  • не держать критическое состояние только в RAM одного процесса
  • выносить состояние в внешние системы: Redis, БД, объектное хранилище
  • Такой дизайн упрощает:

  • горизонтальное масштабирование
  • перезапуск воркеров
  • rolling updates
  • Sticky sessions: когда всё-таки нужны

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

  • WebSocket (долгое соединение)
  • in-memory state, который трудно вынести (часто это временный компромисс)
  • Sticky sessions решаются либо на уровне балансировщика (например, по cookie/IP hash), либо на уровне вашего прокси.

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

    Надёжность: graceful shutdown и перезапуски

    Почему “убить процесс” недостаточно

    Если просто завершить воркер, вы рискуете:

  • оборвать активные соединения
  • потерять in-flight запросы
  • получить ретраи и всплеск нагрузки
  • Цель graceful shutdown:

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

    В cluster обычно делают так:

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

    CPU и пропускная способность

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

    На практике рост ограничивают:

  • конкуренция за общие ресурсы (БД, сеть, диск)
  • накладные расходы IPC и балансировки
  • неравномерные запросы (один воркер может “залипнуть” на тяжёлом запросе)
  • Память и GC

    Важное следствие: память не делится между процессами.

    Если один воркер потребляет 300 МБ, то 8 воркеров могут потреблять около 2.4 ГБ плюс накладные расходы.

    Плюсы:

  • GC-паузы и утечки локализованы в воркере
  • Минусы:

  • общий расход RAM растёт
  • кэши в памяти дублируются (часто их нужно выносить во внешний кэш)
  • Типовые ошибки при использовании cluster

  • Хранить пользовательские сессии или важное состояние в памяти воркера.
  • Делать тяжёлую CPU-работу в обработчике запроса и надеяться, что cluster “всё решит”. Он лишь распределит запросы, но каждый воркер всё равно однопоточный для JS.
  • Передавать большие данные через IPC.
  • Не делать graceful shutdown и получать обрывы соединений и всплески ретраев.
  • Не ограничивать потребление памяти и не мониторить перезапуски воркеров.
  • Практический чеклист

  • Если нужно задействовать несколько ядер для HTTP: несколько процессов и балансировка.
  • Если нужно распараллелить CPU внутри процесса и возможна общая память: Worker Threads.
  • Делайте сервис stateless: состояние во внешних хранилищах.
  • Планируйте graceful shutdown.
  • Осторожно с IPC: сообщения должны быть маленькими и редкими.
  • Следите за памятью: много процессов почти всегда означает больше RAM и дублирование кэшей.
  • Куда дальше по курсу

    Следующий логичный шаг после процессов и cluster:

  • Worker Threads: параллелизм CPU без отдельных процессов и с возможностью разделять память.
  • Отладка и профилирование: как диагностировать “почему один воркер залипает”, как смотреть CPU-профили, heap snapshots.
  • Streams и GC: как уменьшить память и аллокации, чтобы воркеры были стабильнее под нагрузкой.
  • 5. Worker Threads: пул воркеров, передача данных, SharedArrayBuffer и Atomics

    Worker Threads: пул воркеров, передача данных, SharedArrayBuffer и Atomics

    Зачем Worker Threads в Node.js, если есть Cluster

    В прошлой статье мы разобрали cluster и несколько процессов как способ задействовать все ядра CPU для HTTP-сервера и изолировать сбои по памяти: каждый процесс имеет свой heap, свой GC и свой event loop.

    worker_threads решает другую задачу: распараллелить CPU-вычисления внутри одного процесса Node.js.

    Ключевые отличия:

  • Cluster масштабирует процессами и чаще применяется для серверов и изоляции.
  • Worker Threads масштабируют потоками и чаще применяются для CPU-bound задач и пайплайнов обработки данных.
  • Официальная документация:

  • Документация Node.js: worker_threads
  • Документация Node.js: cluster
  • !Диаграмма помогает понять, что процессы изолируют память, а потоки работают внутри одного процесса

    Ментальная модель Worker Threads

    Worker в Node.js:

  • это отдельный поток выполнения внутри одного процесса;
  • имеет свой event loop;
  • может выполнять JS-код параллельно с основным потоком;
  • общается с основным потоком сообщениями (message passing) или через общую память (SharedArrayBuffer).
  • Что важно для производительности:

  • если вынести CPU-работу в воркер, основной event loop перестаёт блокироваться;
  • но появляется накладная стоимость передачи данных, сериализации и синхронизации.
  • Типовые кандидаты для Worker Threads:

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

    Основной поток

    Использование пула

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

  • Piscina: worker thread pool for Node.js
  • Передача данных: structured clone, копирование и цена сериализации

    Коммуникация с воркерами идёт через postMessage().

    По умолчанию Node.js использует structured clone:

  • примитивы копируются;
  • обычные объекты и массивы копируются рекурсивно;
  • для некоторых типов есть поддержка передачи.
  • Главная практическая проблема: копирование больших структур данных создаёт:

  • CPU-расходы на сериализацию;
  • аллокации памяти;
  • давление на GC (и в основном потоке, и в воркере).
  • Это напрямую связывает Worker Threads с темами курса про структуры данных и GC: “правильная” структура данных может быть не только быстрее в одном потоке, но и дешевле в передаче.

    Transferable Objects: как передавать без копирования

    Для бинарных данных ключевой инструмент это передача владения ArrayBuffer.

    Идея:

  • вы передаёте ArrayBuffer вторым аргументом postMessage как transfer list;
  • буфер не копируется, а “переезжает” в другой поток;
  • в отправителе буфер становится недоступен (его byteLength станет 0).
  • Пример: передача ArrayBuffer в воркер без копирования

    Когда это особенно полезно:

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

    SharedArrayBuffer: общая память между потоками

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

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

  • SharedArrayBuffer не “переезжает”, он разделяется;
  • изменения видны всем потокам, которые держат ссылку;
  • из-за гонок данных обычного чтения/записи недостаточно: нужны атомарные операции.
  • SharedArrayBuffer особенно полезен для:

  • высокочастотного обмена небольшими состояниями (счётчики, флаги, индексы очереди);
  • сценариев “один producer, один consumer”;
  • передачи результатов без постоянных postMessage.
  • !Иллюстрация показывает, что память общая, поэтому без синхронизации возможны некорректные чтения

    Atomics: как синхронизировать доступ к общей памяти

    Atomics работает поверх типизированных массивов, созданных на SharedArrayBuffer, чаще всего Int32Array.

    Базовые операции:

  • Atomics.load и Atomics.store для корректных чтения и записи;
  • Atomics.add, Atomics.sub и другие RMW-операции для атомарных обновлений;
  • Atomics.wait и Atomics.notify для блокирующего ожидания значения (актуально в воркерах).
  • Документация:

  • MDN: Atomics
  • MDN: SharedArrayBuffer
  • Пример: сигнал “готово” через Atomics

    Схема:

  • в shared[0] лежит флаг готовности;
  • воркер делает работу, затем выставляет 1 и будит ожидающего.
  • Важное уточнение по архитектуре Node.js:

  • Atomics.wait блокирует поток, в котором вызван.
  • поэтому Atomics.wait обычно используют внутри воркеров, а основной поток проектируют так, чтобы не блокировать event loop.
  • Как выбрать между message passing, transfer и shared memory

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

    | Подход | Что происходит | Плюсы | Минусы | Когда применять | |---|---|---|---|---| | postMessage (копия) | structured clone, копирование данных | простота | дорого на больших данных, нагрузка на GC | небольшие объекты, редкие сообщения | | postMessage + transfer list | передача владения ArrayBuffer без копии | быстро для больших буферов | отправитель теряет доступ к буферу | большие бинарные данные, пайплайны | | SharedArrayBuffer + Atomics | общая память, синхронизация атомиками | минимум копий, высокая частота | сложнее, риск гонок и дедлоков | высокочастотный обмен состоянием, очереди |

    Типовые ошибки и как их избежать

  • Запускать new Worker() на каждую задачу вместо пула.
  • Передавать большие графы из объектов через postMessage и получать проблемы с латентностью и GC.
  • Делать CPU-работу в основном потоке “чуть-чуть” и надеяться, что event loop выдержит.
  • Использовать SharedArrayBuffer без Atomics там, где есть конкурентные записи.
  • Блокировать основной поток через Atomics.wait.
  • Интеграция с HTTP-сервисом: безопасный шаблон

    Цель: чтобы обработчик запроса был максимально лёгким.

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

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

    Практические меры:

  • лимитировать размер очереди и возвращать 429 или деградировать функциональность;
  • ставить таймаут на задачу;
  • измерять p95/p99, потому что “среднее” скрывает проблемы очередей.
  • Что дальше по курсу

    Worker Threads дают параллелизм CPU, но сами по себе не отвечают на вопросы:

  • почему один воркер “залипает” и как это увидеть;
  • как профилировать CPU и память по потокам;
  • как уменьшить аллокации, чтобы GC не разрушал стабильность задержек;
  • как обработать большие данные без загрузки всего в память.
  • Следующие темы логично продолжают эту:

  • отладка и профилирование (Inspector, CPU profiles, heap snapshots);
  • Streams для потоковой обработки больших данных;
  • Garbage Collection и управление памятью, чтобы пул воркеров не деградировал под нагрузкой.
  • 6. Streams в Node.js: backpressure, pipeline, transform-потоки и высокопроизводительный I/O

    Streams в Node.js: backpressure, pipeline, transform-потоки и высокопроизводительный I/O

    Как Streams продолжают темы алгоритмов, структур данных, Cluster и Worker Threads

    В предыдущих модулях курса мы смотрели на производительность Node.js через три ключевые призмы:

  • Алгоритмы и структуры данных: неэффективные проходы по данным и лишние аллокации повышают задержки и нагружают GC.
  • Многопроцессность (Cluster): масштабирует обработку запросов по ядрам, но каждый воркер всё равно должен работать с памятью аккуратно.
  • Worker Threads: выносят CPU-bound работу из основного потока, но не отменяют проблему больших входных данных и копирований.
  • Streams решают ещё один класс проблем: как обрабатывать большие объёмы данных без “загрузить всё в память” и без потери управляемости по скорости. Это напрямую влияет на:

  • стабильность latency (меньше GC-пауз из-за гигантских временных буферов);
  • пропускную способность (I/O идёт параллельно с обработкой чанков);
  • надёжность (централизованная обработка ошибок и закрытие ресурсов через pipeline).
  • Официальная документация:

  • Node.js Stream API
  • Node.js stream.pipeline
  • Node.js stream/promises
  • Ментальная модель: поток как “конвейер чанков”

    Stream в Node.js — это абстракция над источником и/или приёмником данных, который работает частями (чанками), а не целиком.

    Важные термины:

  • chunk: порция данных, которая проходит через поток (обычно Buffer, иногда строка, иногда объект).
  • Readable: источник данных (файл, сокет, HTTP request, генератор).
  • Writable: приёмник данных (файл, HTTP response, сокет).
  • Duplex: и читает, и пишет (например, TCP-сокет).
  • Transform: разновидность duplex, которая преобразует входные чанки в выходные.
  • Ключевая идея: вы не обязаны держать весь файл/ответ/архив в памяти, чтобы сделать преобразование.

    !Визуально объясняет, что данные идут порциями и могут обрабатываться по пути

    Backpressure: почему Streams не “заливают память”

    Что такое backpressure

    Backpressure — это механизм, который не позволяет быстрому источнику бесконтрольно “залить” медленный приёмник данными.

    Пример из практики:

  • вы читаете файл с SSD очень быстро;
  • вы отправляете его по сети медленнее;
  • без backpressure вы бы накапливали всё прочитанное в RAM.
  • В Node.js backpressure выражается через состояние буферов и сигналы write():

  • writable.write(chunk) возвращает true, если приёмник готов принимать ещё;
  • false, если внутренний буфер заполнен, и нужно подождать событие drain.
  • С этим связан параметр highWaterMark — “порог” буфера (в байтах для бинарных потоков или в количестве элементов для objectMode). Он не гарантирует точный размер, но задаёт целевой уровень буферизации.

    !Объясняет связь highWaterMark, возврата write(false) и паузы источника

    Почему pipe() “работает”, а ручная запись часто ломает backpressure

    Метод readable.pipe(writable) автоматически:

  • уважает backpressure;
  • ставит паузы чтения, когда запись не успевает;
  • возобновляет чтение после drain.
  • Когда вы пишете вручную (читаете всё data-событиями и пишете в res.write), легко:

  • игнорировать write() == false;
  • разогнать буферизацию;
  • получить рост памяти и давление на GC.
  • pipe() против pipeline(): безопасность, ошибки и завершение

    pipe()

    pipe() удобен, но в реальном коде есть два типовых риска:

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

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

  • корректно прокидывает ошибки;
  • закрывает все участники пайплайна;
  • даёт одну точку, где вы обрабатываете успех/ошибку.
  • Синхронный callback-вариант:

    Promise-вариант удобен для async/await:

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

  • Node.js stream.pipeline
  • Node.js stream/promises pipeline
  • Node.js zlib
  • Основные виды Streams и их типичные роли

    Readable

    Readable встречается постоянно:

  • fs.createReadStream() читает файл чанками;
  • req в HTTP-сервере — это Readable (тело запроса);
  • Readable.from(iterable) строит поток из итератора.
  • Документация:

  • Node.js fs.createReadStream
  • Node.js Readable
  • Writable

    Writable — это:

  • fs.createWriteStream();
  • res в HTTP-сервере (ответ клиенту);
  • любые “потребители” данных.
  • Документация:

  • Node.js fs.createWriteStream
  • Node.js Writable
  • Transform

    Transform — ключ к потоковой обработке: вы преобразуете данные по пути, не накапливая всё целиком.

    Примеры Transform в стандартной библиотеке:

  • zlib.createGzip();
  • crypto.createHash() (в режиме потока);
  • stream.Transform для собственного кода.
  • Документация:

  • Node.js Transform
  • Node.js crypto
  • Свой Transform-поток: практический шаблон

    Ниже пример Transform, который считает количество строк (newline) в потоке текста. Он показывает важный нюанс: чанк может разрезать строку посередине, поэтому нужен “хвост” между чанками.

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

  • потоковые Transform’ы часто требуют небольшого состояния между чанками;
  • состояние должно быть ограниченным, иначе вы снова придёте к росту памяти;
  • если вы не обязаны “пропускать данные дальше”, можно вообще ничего не push-ить и использовать поток только как обработчик.
  • objectMode: когда чанк — это объект, а не Buffer

    По умолчанию потоки ориентированы на бинарные данные (Buffer) и строки.

    objectMode меняет модель: каждый chunk — это произвольный JS-объект. Это удобно для ETL, парсинга строк в записи, конвейеров обработки событий.

    Но есть важный компромисс:

  • objectMode чаще создаёт много объектов;
  • много объектов = больше работы для GC;
  • поэтому objectMode особенно нуждается в корректном backpressure, иначе очередь объектов раздуется.
  • Пример идеи (упрощённо):

  • Readable читает файл строками;
  • Transform превращает строку в объект { ts, level, msg };
  • Writable пишет агрегаты.
  • Если агрегация CPU-тяжёлая, следующий шаг по курсу — вынести часть обработки в Worker Threads, но вход всё равно лучше подавать потоком, чтобы не держать гигабайты в RAM.

    Streams в HTTP: правильный путь для больших тел

    Тело запроса req — это Readable

    Классическая ошибка в Node.js: собрать всё тело запроса в строку/Buffer, а потом обработать.

    Так делать можно только если:

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

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

  • Node.js HTTP
  • Отдача файла клиенту: pipeline вместо ручного чтения

    Что это даёт:

  • данные идут чанками;
  • backpressure учитывается автоматически;
  • при ошибках чтения/сети пайплайн завершится корректно.
  • Производительность: что реально ускоряет Streams и что их ломает

    Что обычно ускоряет

  • Потоковая обработка вместо “всё в память” для больших данных.
  • Низкая аллокация в Transform: меньше временных объектов, меньше конкатенаций строк.
  • Использование pipeline для предсказуемого управления ошибками и ресурсами.
  • Transferable/SharedArrayBuffer в Worker Threads для CPU-пайплайнов с бинарными данными, если нужно распараллелить обработку.
  • Что часто ломает

  • Превращение потоков в “накопители” через большие буферы и игнорирование backpressure.
  • Transform, который внутри собирает весь результат в строку/массив, сводя пользу Streams к нулю.
  • Слишком маленькие чанки, если вы делаете тяжёлую обработку на каждый чанк (растёт overhead на события и вызовы).
  • Слишком большие чанки, если вы делаете дорогое преобразование и увеличиваете p99 (один большой кусок дольше блокирует поток выполнения).
  • Как Streams связаны с GC и стабильностью задержек

    Streams помогают GC не магией, а ограничением живых данных:

  • вы держите в памяти небольшой “скользящий” набор чанков;
  • вы меньше создаёте гигантских временных буферов;
  • вы снижаете риск, что GC будет вынужден собирать очень большой объём мусора за раз.
  • Но важно помнить:

  • если ваш Transform создаёт много объектов (особенно в objectMode) и не контролирует скорость, GC снова станет проблемой;
  • правильный backpressure — это не “опция”, а часть устойчивости сервиса.
  • В следующем модуле про Garbage Collection мы разберём, как аллокации, размеры heap и долгоживущие структуры данных (кэши, Map, очереди) влияют на паузы, throughput и p95/p99.

    Практический чеклист

  • Для цепочек потоков в продакшене используйте pipeline, а не только pipe.
  • Никогда не игнорируйте backpressure, если пишете вручную: write() === false означает “ждём drain”.
  • В Transform учитывайте, что границы чанков не совпадают с логическими границами данных (строки, JSON-объекты).
  • Аккуратно используйте objectMode: удобно, но может дорого стоить по памяти и GC.
  • Если обработка CPU-тяжёлая, комбинируйте Streams с Worker Threads, но думайте о цене передачи данных.
  • 7. Отладка и производительность: инспектор, профилирование, утечки памяти и Garbage Collection

    Отладка и производительность: инспектор, профилирование, утечки памяти и Garbage Collection

    Как эта тема связывает весь курс

    В предыдущих модулях курса мы последовательно добавляли рычаги производительности в Node.js:

  • алгоритмы и структуры данных помогают не блокировать event loop лишним CPU и не создавать лишние аллокации
  • Cluster даёт масштабирование по ядрам через процессы
  • Worker Threads выносят CPU-bound работу из основного потока
  • Streams позволяют обрабатывать большие объёмы данных без загрузки всего в память и с корректным backpressure
  • Эта статья отвечает на практический вопрос: как доказать, что проблема действительно в CPU, памяти, GC, блокировках event loop или в конкретном фрагменте кода, и как это чинить на основе фактов.

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

    !Диаграмма-дерево помогает быстро выбрать правильный инструмент диагностики

    Инспектор Node.js: базовая отладка, которая полезна и для производительности

    Что такое Inspector в Node.js

    Node.js поддерживает протокол Chrome DevTools (Inspector). Это даёт:

  • точки останова и пошаговую отладку
  • просмотр стека, переменных и замыканий
  • профилирование CPU
  • снимки памяти (heap snapshots)
  • Основные ссылки:

  • Node.js Debugging Guide
  • Node.js CLI options
  • Запуск с инспектором

  • node --inspect server.js включает инспектор (по умолчанию слушает порт 9229)
  • node --inspect-brk server.js включает инспектор и останавливается на первой строке (удобно для отладки старта)
  • Дальше подключаются через Chrome:

  • Chrome DevTools: Remote Debugging
  • Практический совет для production-подобной диагностики:

  • не включайте --inspect на публичном интерфейсе
  • ограничивайте доступ (локально, через SSH tunnel, через защищённую сеть)
  • На что смотреть при "подвисаниях"

    Если у вас есть ощущение, что процесс иногда подвисает, чаще всего причина одна из двух:

  • долгий CPU-кусок в основном потоке
  • паузы GC на большом heap или при сильном давлении на аллокации
  • Inspector полезен тем, что позволяет быстро проверить гипотезу: где именно висим.

    Метрики диагностики до профилирования

    Профилирование почти всегда эффективнее, когда вы заранее измерили простые вещи и зафиксировали симптом.

    Event loop delay: быстрый индикатор блокировок

    monitorEventLoopDelay() показывает задержки event loop. Если задержки растут, значит основной поток регулярно занят чем-то долгим.

    Ссылка:

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

    Как интерпретировать:

  • если p99 и max заметно растут, у вас есть редкие, но длинные блокировки
  • если mean стабильно высокий, у вас постоянная нагрузка CPU в main thread
  • Память процесса: process.memoryUsage()

    Быстрый снимок памяти:

    Поля важны так:

  • rss это память, которую ОС отдала процессу (включая native части)
  • heapUsed это сколько JS-объектов реально живёт в heap
  • heapTotal это размер выделенного heap (может быть больше heapUsed)
  • external это память за пределами heap, но управляемая через JS-обёртки (часто Buffers)
  • Ссылка:

  • Node.js process.memoryUsage
  • Если rss растёт, а heapUsed нет, частые причины:

  • утечки в native-части или рост Buffer/external
  • удержание больших буферов в потоках, кэшах, очередях
  • CPU-профилирование: как найти, где реально тратится время

    Когда нужен CPU profile

    CPU-профиль нужен, когда:

  • высокий CPU и низкая пропускная способность
  • растёт latency, и event loop delay подтверждает блокировки
  • вы подозреваете алгоритмическую проблему, но нужно найти конкретную функцию
  • CPU profiling через DevTools

    Через Inspector в Chrome DevTools вкладка Performance или Profiler позволяет:

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

    CPU profiling через флаг --cpu-prof

    Для воспроизводимых сценариев удобно собрать профиль в файл:

    Node запишет файл вида CPU.<pid>.<timestamp>.cpuprofile, который можно открыть в DevTools.

    Ссылка:

  • Node.js CLI options
  • Что делать с результатом

    Типовые находки в backend на Node.js:

  • сортировка больших массивов в обработчике запроса
  • JSON.parse или JSON.stringify на больших объектах
  • регулярные выражения на больших строках
  • вложенные find/includes внутри циклов
  • сериализация данных для postMessage() в Worker Threads
  • Связь с предыдущими модулями курса:

  • если видите CPU-bound кусок в main thread, сначала проверьте алгоритмику и структуры данных
  • если алгоритм уже адекватен, переносите тяжёлое в Worker Threads или дробите работу
  • Heap snapshots и поиск утечек памяти

    Что такое утечка памяти в Node.js

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

    В Node.js самые частые утечки:

  • кэши без лимита: Map, Set, объекты-словари
  • очереди без backpressure: накопление задач, сообщений или объектов
  • удержание больших объектов через замыкания или обработчики событий
  • хранение request/response объектов в глобальном состоянии
  • Heap snapshot: что он показывает

    Heap snapshot фиксирует:

  • какие объекты живут в heap
  • сколько памяти они занимают
  • кто их удерживает (retainers)
  • Это основной инструмент для ответа на вопрос: почему объект не собирается GC.

    Снимать можно через DevTools (Memory) или через подходящие CLI-режимы и инструменты.

    Ссылки:

  • Node.js Debugging Guide
  • Chrome DevTools: Memory
  • Практический алгоритм поиска утечки

  • Сделайте сервис воспроизводимым: фиксированный сценарий нагрузки.
  • Дайте прогреться, затем снимите snapshot A.
  • Выполните сценарий ещё раз, снимите snapshot B.
  • Сравните доминаторы и рост по типам объектов.
  • Для подозрительных объектов откройте путь удержания.
  • Что искать глазами:

  • огромные Array и большие строки, которые растут с течением времени
  • Map/Set с тысячами и миллионами элементов
  • большое количество однотипных объектов, связанных с запросами
  • Пример типовой утечки: бесконечный кэш

    Почему это течёт:

  • cache живёт весь процесс
  • ключи никогда не удаляются
  • Map удерживает всё, пока вы явно не очистите
  • Как исправлять:

  • ограничивать размер и TTL
  • использовать LRU
  • хранить состояние во внешнем кэше, если нужно шарить между процессами
  • Связь с Cluster:

  • при нескольких процессах кэш умножается на число воркеров, поэтому неконтролируемый кэш ещё быстрее приводит к проблемам по RAM
  • Garbage Collection в V8: что важно знать разработчику Node.js

    Модель V8 без погружения в лишнюю теорию

    V8 управляет памятью автоматически и периодически запускает сборку мусора (GC), чтобы освободить объекты, на которые больше нет ссылок.

    В прикладном смысле важно:

  • GC иногда останавливает выполнение JS на короткое время
  • чем больше heap и чем больше аллокаций, тем выше риск заметных пауз
  • долгоживущие объекты обычно дороже для сборки, чем короткоживущие
  • !Иллюстрация помогает понять, почему временные аллокации и бесконечные кэши по-разному влияют на паузы GC

    Почему GC становится проблемой именно в Node.js

    Node.js часто работает как долгоживущий процесс, который:

  • принимает тысячи запросов
  • постоянно создаёт временные объекты
  • держит долгоживущие структуры: кэши, пулы, индексы
  • Если вы создаёте слишком много мусора, вы получаете:

  • рост времени на GC
  • рост p95 и p99 latency
  • нестабильное поведение под нагрузкой
  • Связь со Streams:

  • Streams помогают, когда проблема в том, что вы держите слишком много данных одновременно
  • но если Transform создаёт много объектов и не уважает backpressure, GC всё равно станет проблемой
  • Как увидеть влияние GC

    #### Флаги трассировки GC

    В учебной и диагностической среде можно включать логирование GC:

    Это создаёт поток сообщений в stderr о событиях GC. Их используют, чтобы заметить:

  • частые сборки
  • крупные сборки
  • корреляцию с пиками latency
  • Ссылка:

  • Node.js CLI options
  • #### Измеряйте не только CPU, но и паузы

    Практичный подход:

  • подтвердить, что latency пики совпадают с event loop delay
  • затем проверить, совпадают ли эти моменты с событиями GC
  • Если совпадают, у вас обычно две причины:

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

    CPU-bound код в main thread

    Симптомы:

  • рост event loop delay
  • высокий CPU
  • CPU profile показывает одну или несколько горячих функций
  • Что делать:

  • улучшать алгоритм и структуру данных
  • переносить в Worker Threads
  • дробить работу на чанки, если нельзя вынести
  • Очереди без backpressure

    Симптомы:

  • растёт память
  • растёт latency со временем
  • в heap snapshot видно много объектов одного типа, связанных с очередью
  • Где это встречается:

  • самописные очереди задач
  • worker pool без лимита очереди
  • streams/HTTP код, который игнорирует write() === false
  • Связь с Worker Threads:

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

    Симптомы:

  • heapUsed стабильно растёт
  • в snapshot видно, что объекты удерживаются через listeners
  • Типовая ошибка:

    Как исправлять:

  • отписываться
  • использовать once
  • не хранить request-scoped данные в долгоживущих обработчиках
  • Ссылка:

  • Node.js events
  • Практический плейбук: от симптома к исправлению

    Сценарий "высокая задержка"

  • Зафиксируйте p95/p99 и включите измерение event loop delay.
  • Если event loop delay высокий, снимите CPU profile.
  • Если CPU profile показывает горячую точку в вашем коде, исправляйте алгоритм или переносите в Worker Threads.
  • Если CPU profile не объясняет проблему полностью, включите --trace-gc и проверьте корреляцию с пиками.
  • После фикса повторите нагрузку и сравните метрики.
  • Сценарий "растёт память"

  • Снимайте process.memoryUsage() периодически и проверьте, что растёт: heapUsed или rss.
  • Если растёт heapUsed, снимайте heap snapshots и ищите удерживающие ссылки.
  • Проверьте кэши, очереди, накопление данных в streams, подписки на события.
  • Добавьте лимиты: TTL, max size, backpressure.
  • Подтвердите на длительном прогоне, что память стабилизируется.
  • Инструменты и когда что выбирать

    | Инструмент | Что диагностирует | Когда использовать | Где смотреть результат | |---|---|---|---| | --inspect, DevTools | отладка, профили CPU/heap | воспроизведение локально или в staging | Chrome DevTools | | --cpu-prof | горячие функции CPU | когда нужно записать профиль в файл | DevTools (открыть .cpuprofile) | | Heap snapshots (DevTools) | утечки, удержание объектов | рост heapUsed, подозрение на утечку | DevTools Memory | | monitorEventLoopDelay | блокировки main thread | latency пики, подвисания | логи/метрики | | --trace-gc | частота и характер GC | подозрение на GC-паузы | stderr логи |

    Закрывающие выводы

  • Без измерений оптимизация превращается в гадание. Inspector и профили дают факты.
  • CPU profile отвечает на вопрос "где мы тратим время".
  • Heap snapshot отвечает на вопрос "что удерживает память".
  • GC важен, потому что Node.js это долгоживущий процесс, а паузы и давление на аллокации напрямую влияют на p95/p99.
  • Streams и Worker Threads решают разные проблемы, но обе темы упираются в диагностику: без профилей вы не поймёте, что именно ограничивает систему.
  • Дальше по курсу эта база позволит осмысленно оптимизировать конвейеры обработки (Streams), пулы воркеров (Worker Threads) и многопроцессные конфигурации (Cluster) так, чтобы они были не только быстрыми, но и стабильными по памяти и задержкам.