Node.js Internals
Net Module: TCP/UDP низкоуровневая работа
Multiplayer игра, где миллисекунды решают всё. HTTP слишком медленный (headers overhead), WebSocket - слишком абстрактный. Нужен прямой доступ к TCP сокетам: отправка бинарных пакетов позиции игрока 60 раз в секунду, управление keep-alive для обнаружения disconnected игроков, настройка TCP_NODELAY для минимальной latency. Или IoT-платформа: миллионы датчиков отправляют данные через UDP - без гарантий, но с минимальным overhead. Модуль `net` даёт этот low-level контроль.
- **Redis** построен на собственном протоколе RESP поверх TCP. 100k+ операций/сек на одном ядре благодаря отсутствию HTTP overhead.
- **Database drivers** (PostgreSQL, MySQL, MongoDB) используют TCP connection pools для переиспользования соединений. Один handshake вместо тысяч → latency -90%.
- **Game servers** (PUBG, Fortnite) используют UDP для передачи позиций игроков. Один потерянный пакет лучше, чем 100ms задержка на retransmit.
- **StatsD/Graphite** собирают миллионы метрик через UDP fire-and-forget. Потеря 1% данных незаметна, зато нет overhead на acknowledgments.
Intro
Когда нужно построить собственный протокол для многопользовательской игры - HTTP слишком тяжеловесный (headers на каждый запрос), WebSocket слишком высокоуровневый. Нужен прямой доступ к TCP сокетам: отправка бинарных пакетов, контроль над буферизацией, управление keep-alive соединениями. Или для IoT-системы, где датчики отправляют данные через UDP - быстро, без гарантий доставки, но с минимальной задержкой.
**Модуль `net`** - это низкоуровневый API для работы с TCP сокетами в Node.js. Он построен поверх libuv, которая использует `epoll` (Linux), `kqueue` (macOS) или `IOCP` (Windows) для асинхронной обработки сетевых событий. Это тот же механизм, который использует HTTP модуль, но без парсинга протокола - работа идёт с чистыми байтами.
**Почему `net` модуль быстрее `http`?** HTTP парсит каждый запрос: разбивает headers на объекты, декодирует URL, обрабатывает chunked encoding. Для WebSocket-сервера с миллионами сообщений/сек это overhead. `net.Socket` даёт прямой доступ к TCP stream - разработчик сам решает, как интерпретировать байты. Результат: в 2-3 раза меньше CPU на обработку пакета.
Реальный кейс: Redis protocol
Redis использует собственный протокол RESP (REdis Serialization Protocol) поверх TCP. Почему не HTTP? **HTTP version:** ``` POST /set HTTP/1.1 Host: localhost Content-Length: 15 {"key":"value"} ``` 150+ байт для передачи 10 байт данных. **RESP (net module):** ``` *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n5\r\nvalue\r\n ``` 30 байт для той же операции. **Результат:** Redis обрабатывает 100k+ операций/сек на одном ядре. С HTTP это было бы невозможно из-за overhead на парсинг.
**Когда использовать `net` вместо `http`?** - Собственный протокол (игры, IoT, database drivers) - Бинарные данные без HTTP overhead (video streaming, file transfer) - Long-lived соединения с минимальной латентностью (чаты, real-time systems) - Прокси или туннели, где нужен прямой доступ к байтам
Чат-сервер с 10k активных WebSocket соединений. Средний пользователь отправляет 1 сообщение/минуту (60 байт полезной нагрузки). Используется стандартный HTTP-сервер с WS библиотекой. Сколько примерно CPU времени тратится на парсинг HTTP headers при первоначальном handshake vs обработку WS frames?
TCP Server
TCP сервер проходит через чёткий lifecycle: создание → bind к порту → listen → accept соединений → обработка данных → graceful shutdown. Каждый этап управляется libuv, которая использует системные вызовы `socket()`, `bind()`, `listen()`, `accept()`. Node.js оборачивает это в EventEmitter, давая удобный асинхронный API.
**Создание сервера** делается через `net.createServer([options], [connectionListener])`. Каждое новое соединение создаёт экземпляр `net.Socket`, который наследует Duplex Stream. Это значит, что доступны все методы Stream API: `pipe()`, `read()`, `write()`, обработка backpressure.
**Важно: backlog parameter.** При вызове `server.listen(port, backlog)` второй параметр - это размер очереди pending соединений. Если клиенты подключаются быстрее, чем сервер вызывает `accept()`, они попадают в SYN queue (managed by OS kernel). Когда очередь заполнена, новые соединения получают TCP RST (connection refused). Дефолт в Node.js - 511, но kernel может ограничить меньшим значением (`net.core.somaxconn` в Linux).
Реальная проблема: backlog exhaustion
Production сервер получает DDoS атаку: 100k SYN пакетов/сек. Backlog queue (511 слотов) заполняется мгновенно. Легитимные клиенты получают connection refused. **Решение 1:** Увеличить backlog ```typescript server.listen(3000, 4096); // Больше очередь ``` Но это помогает только если сервер успевает обрабатывать соединения. **Решение 2:** SYN cookies (kernel feature) ```bash sysctl -w net.ipv4.tcp_syncookies=1 ``` Kernel не хранит SYN в памяти, а кодирует состояние в sequence number. Защищает от SYN flood. **Решение 3:** Rate limiting на уровне firewall/load balancer Ограничить новые соединения с одного IP: 10/sec.
**Опасность: не закрывать сокеты при shutdown.** Если просто вызвать `server.close()`, это только перестанет принимать новые соединения. Активные сокеты останутся открытыми, и процесс не завершится! В Docker/k8s контейнер будет висеть до timeout, затем получит SIGKILL (грубое убийство). Активные соединения нужно закрывать явно.
TCP сервер с backlog=512. Сервер обрабатывает каждое соединение за 100ms (медленная операция в handler). Клиенты подключаются со скоростью 1000 conn/sec. Что произойдёт?
TCP Client
TCP клиент создаётся через `net.connect()` или `new net.Socket()` + `socket.connect()`. Под капотом это системные вызовы `socket()` → `connect()`, которые инициируют TCP three-way handshake. Важное отличие от сервера: клиент обычно short-lived (подключился, отправил данные, закрылся), хотя connection pooling может поддерживать long-lived соединения.
**Connection pooling для TCP.** При большом количестве запросов к одному серверу (например, database driver), создание нового соединения на каждый запрос - дорого: - TCP handshake: 1-3 RTT (round-trip time) - TLS handshake (если используется): +2-3 RTT - Итого: 50-200ms overhead на каждое соединение Connection pool держит N активных соединений открытыми. Запрос переиспользует готовое соединение → 0ms overhead. Именно так работают библиотеки вроде `pg` (PostgreSQL) или `ioredis`.
Реальный кейс: PostgreSQL connection pool
Библиотека `pg` (PostgreSQL driver для Node.js) использует connection pool по умолчанию. Почему это критично? **Без pool:** 1000 запросов/сек → 1000 новых TCP соединений/сек - PostgreSQL создаёт новый процесс на соединение (fork) - Каждый fork: ~10-50ms + несколько MB памяти - При 1000 conn/sec сервер БД умрёт от нагрузки **С pool (10 соединений):** - 1000 запросов/сек → переиспользуем 10 соединений - PostgreSQL видит только 10 активных процессов - Запросы выполняются последовательно через доступные соединения - Если запрос занимает 5ms, 10 соединений обработают 2000 req/sec **Вывод:** Connection pool - обязателен для production. Размер pool подбирается по формуле: `pool_size = (core_count 2) + effective_spindle_count` (для HDD) или просто 10-20 для SSD.
**Опасность: connection leaks.** Если соединение взято из pool и не возвращено (забыт вызов `release()` или вылетела ошибка), pool постепенно истощается. В итоге все соединения "зависают" в waiting state, новые запросы блокируются навсегда. Для гарантированного возврата ресурсов нужен `try/finally` или `using` (TC39 proposal).
API-сервер делает 500 запросов/сек к PostgreSQL. Средний запрос занимает 10ms. Connection pool размером 5. Какая будет средняя задержка на получение соединения из pool?
UDP
UDP (User Datagram Protocol) - это противоположность TCP. Никаких гарантий доставки, никакого порядка пакетов, никакого flow control. Просто отправил датаграмму и забыл. Звучит ненадёжно? Но для многих задач это идеально: онлайн-игры (лучше пропустить пакет, чем ждать retransmit), видео-стриминг, DNS, метрики/логи.
**Модуль `dgram`** предоставляет API для UDP сокетов. Основное отличие от `net`: нет "подключения" к серверу, датаграммы просто отправляются на адрес. Каждая датаграмма независима, может прийти не в том порядке или потеряться. За надёжность отвечает прикладной протокол (или не отвечает - если не нужна).
**Почему UDP быстрее TCP?** 1. **Нет handshake:** TCP тратит 1 RTT на установку соединения (SYN/SYN-ACK/ACK). UDP сразу отправляет данные. 2. **Нет acknowledgments:** TCP ждёт ACK на каждый пакет. UDP отправил и забыл. 3. **Меньше overhead:** TCP header - 20-60 байт (с options), UDP header - 8 байт. 4. **Нет congestion control:** TCP замедляет отправку при потере пакетов. UDP продолжает слать на максимальной скорости. Результат: UDP latency может быть в 2-5 раз ниже TCP для small messages.
Реальный кейс: онлайн-игра на UDP
Multiplayer шутер отправляет позицию игрока 20 раз/сек. TCP vs UDP: **TCP проблемы:** - Один потерянный пакет (позиция в момент T=0) блокирует все следующие - TCP retransmit → 100ms задержка - Игрок на экране "телепортируется" (приходит T=0, затем сразу T=5) - Head-of-line blocking убивает gameplay **UDP решение:** - Пакет T=0 потерян? Не важно, T=1 уже пришёл (через 50ms) - Позиция игрока всегда актуальная, без "застывания" - Интерполяция между T=1 и T=2 даёт плавное движение - Packet loss 5% незаметен (20 пакетов/сек → один потерянный = 50ms gap) **Вывод:** Для real-time данных, которые "устаревают" - UDP идеален. Для критичных событий (выстрел, смерть) можно добавить application-level acknowledgments.
**Опасность: UDP amplification DDoS.** UDP не требует handshake - можно подделать source IP. Атакующий отправляет маленький пакет на UDP-сервер с подделанным IP жертвы. Сервер отправляет большой ответ жертве. Если ответ в 100 раз больше запроса - это 100x amplification. **Защита:** 1. Rate limiting по IP 2. Не отвечать на запросы больше, чем сам запрос (или требовать cookie/token) 3. Firewall правила (block spoofed IPs) 4. Использовать DTLS (UDP + TLS) для валидации клиента
Система мониторинга собирает метрики с 10000 серверов. Каждый сервер отправляет 100 метрик/сек (по 100 байт каждая). Что произойдёт при использовании TCP вместо UDP?
Socket Options
TCP сокеты имеют десятки опций, которые влияют на производительность, надёжность и поведение при закрытии. Самые важные для production: `TCP_NODELAY` (отключение Nagle's algorithm), `SO_KEEPALIVE` (обнаружение мёртвых соединений), `SO_REUSEADDR` (быстрый перезапуск сервера), `SO_LINGER` (контроль закрытия сокета).
**`socket.setNoDelay(true)`** - отключает алгоритм Nagle. По умолчанию TCP буферизует маленькие пакеты (< MSS, обычно 1460 байт) и отправляет их batch'ами для эффективности. Но это добавляет latency (до 200ms!). Для real-time приложений (игры, чаты, WebSocket) это неприемлемо. `setNoDelay(true)` отправляет данные немедленно.
**`SO_KEEPALIVE` vs `socket.setTimeout()`** - разные механизмы! **`socket.setTimeout(timeout)`** - JavaScript-таймер в Event Loop. Если за `timeout` мс не было событий `data`, генерируется `timeout` event. НЕ проверяет, жив ли клиент на уровне TCP. **`socket.setKeepAlive(enable, initialDelay)`** - TCP-уровень keep-alive. ОС отправляет TCP probe пакеты через `initialDelay` мс неактивности. Если клиент не отвечает на N probes (зависит от ОС, обычн 9), соединение закрывается с `ETIMEDOUT`. Обнаруживает: - Клиент упал (kernel panic, power off) - Сеть разорвана (кабель вытащили) - Firewall убил NAT mapping Оба механизма нужны: `setTimeout` для application logic, `keepAlive` для обнаружения мёртвых соединений.
Реальная проблема: SO_REUSEADDR при перезапуске
Деплой новой версии сервера: ```bash kill <old_process> node server.js ``` **Ошибка:** `EADDRINUSE: address already in use` **Причина:** Старые TCP соединения в состоянии `TIME_WAIT` (ждут 2×MSL, обычно 60-120 секунд). Kernel не даёт переиспользовать порт до истечения таймера. **Решение 1:** Включить `SO_REUSEADDR` (Node.js делает это по умолчанию для серверов) ```typescript server.listen({ port: 3000, reuseAddress: true }); ``` **Решение 2:** Graceful shutdown - закрытие соединений перед перезапуском ```typescript process.on('SIGTERM', () => { server.close(() => process.exit(0)); activeSockets.forEach(s => s.end()); }); ``` **Решение 3:** Zero-downtime deployment - новый процесс стартует на другом порту, load balancer переключается
**Опасность: неправильный linger при закрытии.** По умолчанию `socket.end()` ждёт отправки всех буферизованных данных. Но если клиент не читает (hung connection), данные висят в send buffer вечно → memory leak. **Решение:** Таймаут на graceful close: ```typescript socket.end('Goodbye'); setTimeout(() => { if (!socket.destroyed) { socket.destroy(); // Force close } }, 5000); // Даём 5 секунд на graceful close ```
WebSocket-сервер отправляет маленькие JSON сообщения (50-200 байт) клиентам каждые 100ms. Без `setNoDelay(true)` latency сообщений 150-250ms. С `setNoDelay(true)` latency падает до 20-30ms. Почему такая разница?
Performance
Производительность TCP сервера зависит от множества факторов: размер буферов, backpressure handling, kernel tuning, использование clustering. Один неправильно настроенный параметр может снизить throughput в 10 раз или увеличить latency в разы. Разберём ключевые оптимизации.
**Backpressure** - это ситуация, когда запись в сокет идёт быстрее, чем клиент читает. `socket.write()` возвращает `false`, когда internal buffer заполнен. Если игнорировать это и продолжать писать, данные копируются в память → memory leak → OOM. Правильная обработка: pause source stream до `drain` event.
**Kernel tuning для high-performance TCP:** Linux параметры, которые влияют на throughput: ```bash # Увеличить max buffer sizes sysctl -w net.core.rmem_max=16777216 # 16MB receive sysctl -w net.core.wmem_max=16777216 # 16MB send # TCP buffer auto-tuning (min, default, max) sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216" sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216" # Increase connection backlog sysctl -w net.core.somaxconn=4096 # TCP fast open (reduce handshake latency) sysctl -w net.ipv4.tcp_fastopen=3 # BBR congestion control (better than cubic for high-latency) sysctl -w net.ipv4.tcp_congestion_control=bbr ``` Это может дать 2-5x улучшение throughput для bulk transfer.
Реальный кейс: оптимизация Redis proxy
Компания построила Redis proxy на Node.js. Проблема: throughput 50k ops/sec, хотели 500k. **Узкие места:** 1. Single process → одно ядро CPU 2. Nagle's algorithm → latency 100ms для маленьких команд 3. Маленькие kernel buffers → backpressure на 10k ops/sec 4. Нет connection pooling к Redis → каждый клиентский запрос = новое соединение **Оптимизации:** 1. Cluster mode: 8 workers на 8-core сервере 2. `setNoDelay(true)` на клиентских и Redis соединениях 3. Kernel tuning: `tcp_wmem/rmem` до 4MB 4. Connection pool к Redis: 100 persistent соединений на worker 5. Pipelining: батчим команды к Redis (100 команд за один round-trip) **Результат:** - Throughput: 50k → 600k ops/sec (12x улучшение) - Latency p99: 100ms → 5ms (20x улучшение) - CPU utilization: 20% → 80% (эффективно используем железо)
TCP автоматически управляет скоростью отправки данных, поэтому не нужно проверять return value socket.write()
TCP управляет скоростью НА УРОВНЕ СЕТИ (congestion control), но не защищает от переполнения application buffers. Нужно обрабатывать backpressure явно.
TCP congestion control регулирует, как быстро пакеты отправляются в сеть (чтобы не перегрузить маршрутизаторы). Но если клиент читает медленно, данные копятся сначала в kernel buffer (ограничен), затем в Node.js internal buffer (НЕ ограничен по умолчанию). Игнорирование `write()` return value приводит к unbounded memory growth. Stream API решает это через pause/resume механизм, который нужно использовать.
TCP сервер стримит большие файлы клиентам. При тестировании с клиентом на медленном 4G соединении (1 Mbps) процесс Node.js съедает 2GB RAM и падает с OOM. В чём причина?
Ключевые идеи
- **`net` модуль** - низкоуровневый API для TCP, построен на libuv + epoll/kqueue. Используется для custom протоколов без HTTP overhead.
- **TCP server lifecycle:** socket() → bind() → listen() → accept(). Backlog queue ограничена (default 511) → SYN flood protection + graceful shutdown обязателен.
- **TCP client + connection pooling:** переиспользование соединений экономит handshake latency (50-200ms). Критично для database drivers.
- **UDP (`dgram`)** - fire-and-forget, нет гарантий доставки/порядка. Идеален для метрик, real-time игр, streaming где latency важнее reliability.
- **Socket options:** `setNoDelay(true)` отключает Nagle (latency -80%), `setKeepAlive()` обнаруживает мёртвые соединения, backpressure handling через pause/resume предотвращает OOM.
- **Performance:** cluster mode для multi-core scaling, kernel tuning (tcp_wmem/rmem), обработка backpressure через pipe() или drain events. Результат: 10-100x throughput improvement.
Связанные темы
Net модуль - это фундамент для высокоуровневых протоколов. Связи:
- HTTP Module — HTTP построен поверх net.Server - парсит TCP stream в HTTP requests. Net даёт более прямой доступ к байтам.
- Streams — net.Socket наследует Duplex Stream - все методы pipe(), backpressure работают одинаково. Stream - абстракция над I/O.
- Libuv — net модуль использует libuv для async I/O: epoll (Linux), kqueue (macOS), IOCP (Windows). Event Loop обрабатывает socket events.
- Cluster — Для scaling TCP серверов используется cluster - каждый worker слушает один порт благодаря SO_REUSEPORT.
Вопросы для размышления
- Когда стоит использовать UDP вместо TCP? Приведите примеры, где reliability не нужна, но важна latency.
- Как backpressure связан с memory leaks? Что происходит, если игнорировать `socket.write()` return value?
- Почему connection pooling критичен для database drivers? Посчитайте overhead на handshake для 1000 запросов/сек.
- Как `setNoDelay(true)` влияет на latency vs throughput? В каких случаях Nagle's algorithm полезен?