Сетевой стек Node.js базируется на низкоуровневых абстракциях операционной системы, предоставляемых библиотекой libuv (uv_tcp_t, epoll в Linux, kqueue в macOS, IOCP в Windows), и встроенном парсере сетевых протоколов llhttp (написанном на TypeScript/C и заменившем устаревший http-parser). Понимание пути сетевого пакета от физического сетевого интерфейса (NIC) до колбэка маршрутизатора критично для проектирования сервисов с пропускной способностью свыше 50 000 – 80 000 запросов в секунду (RPS) на ядро.
Сетевой конвейер обработки входящего HTTP-запроса в Node.js:
[Сетевой пакет TCP SYN/ACK]
│
▼
[Ядро ОС: TCP/IP Stack & Socket Receive Buffer]
│ (epoll_wait / kqueue готовность дескриптора)
▼
[libuv: uv_tcp_t -> uv_read_cb]
│ (Выделение системного слайса памяти через Buffer.allocUnsafe)
▼
[C++ Binding: node::StreamWrap / TCPWrap]
│
▼
[llhttp Parser]: Потоковый синтаксический анализ байтов (URL, Headers, Body chunks)
│
▼
[node:http: net.Socket & IncomingMessage]
│ (Эмиссия события 'request' в экземпляр http.Server)
▼
[HTTP Framework Routing Engine]: Поиск маршрута и выполнение цепочки плагинов
│ Fastify: Radix Tree (O(k)) ── vs ── Express: Linear Array (O(n))
▼
[V8 Serialization]: Ответ клиенту
Fastify: fast-json-stringify (JIT Bytecode конкатенация)
Express: JSON.stringify (Runtime C++ инспекция свойств объекта)В основе каждого HTTP-сервера Node.js лежит TCP-сервер node:net. При вызове http.createServer() рантайм инициализирует экземпляр net.Server, который слушает входящие TCP-соединения (listening socket). Когда клиент выполняет трехстороннее рукопожатие TCP (SYN, SYN-ACK, ACK), ядро операционной системы помещает дескриптор в очередь готовых соединений (accept queue).
libuv вызывает uv_accept(), создавая двунаправленный потоковый дуплекс net.Socket. Сокет инкапсулирует файловый дескриптор, буферы чтения/записи и дескриптор событий.llhttp. Парсер не аллоцирует строки V8 до тех пор, пока заголовки не будут полностью собраны, что минимизирует давление на сборщик мусора (GC pressure).http.IncomingMessage (Readable-поток) и http.ServerResponse (Writable-поток), после чего инициируется событие 'request'.Протокол HTTP/1.1 страдает от архитектурного ограничения: один TCP-сокет может одновременно обрабатывать только одну транзакцию "запрос-ответ" (Head-of-Line Blocking на уровне HTTP-транзакций). Для параллельной загрузки ресурсов клиенты вынуждены открывать пул из 6-8 TCP-соединений на каждый домен, что приводит к избыточным накладным расходам на медленный старт TCP (Slow Start) и согласование TLS-ключей.
Модуль node:http2 использует бинарное фреймирование и мультиплексирование поверх единственного TCP-соединения:
Stream ID). Данные передаются дискретными бинарными кадрами: HEADERS, DATA, SETTINGS, RST_STREAM, WINDOW_UPDATE, GOAWAY.В высоконагруженных распределенных системах HTTP-сервер — это не просто маршрутизатор URL, а вычислительный конвейер преобразования сетевых байтовых потоков в структуры данных с жестким контролем задержки и управления системными ресурсами.
HTTP/2 Frame HTTP/2 Stream Radix Tree (find-my-way) HTTP Keep-Alive AOT JSON Serialization UndiciСравнение архитектур диспетчеризации: Express (Linear List) vs Fastify (Radix Tree)
EXPRESS (Linear Middleware Pipeline - O(N)):
URL: /api/v1/users/42/orders
[Router]
│
├── Layer 0: match('/')? ──────────> No
├── Layer 1: match('/health')? ────> No
├── Layer 2: match('/auth/*')? ────> No
├── Layer 3: match('/api/v1/posts')> No
└── Layer N: match('/api/v1/users/:id/orders') ──> Match (После N сравнений)
FASTIFY (find-my-way Radix Tree - O(k)):
URL: /api/v1/users/42/orders
[Root Node: "/api/v1/"]
│
├── Node: "posts"
└── Node: "users/"
│
└── Node: [param: id] (users/:id)
│
├── Node: "/profile"
└── Node: "/orders" ───────> Handler найден за 4 перехода!Ядро ОС завершает 3-way handshake. Libuv передает байтовый поток сокета в парсер llhttp без аллокации строк V8.
Движок find-my-way за время O(k) находит обработчик в префиксном дереве по сегментам URL-пути.
Тело, параметры и query-строка верифицируются через предварительно скомпилированный мономорфный JS-код валидатора.
Выполнение асинхронной функции обработчика. Контекст сохраняет стабильный Shape скрытых классов V8.
AOT-сериализатор конкатенирует байты ответа по схеме без использования дорогого JSON.stringify и сбрасывает буфер в net.Socket.
За счет чего маршрутизатор Fastify (find-my-way) обеспечивает масштабируемость O(k) в отличие от O(N) в классическом Express?
undici.Agent. Сохранение сокетов в открытом состоянии устраняет латентность повторных TLS-хэндшейков (снижая p99 latency с 45 мс до 1.8 мс).node:http2 для параллельной отправки сотен телеметрических потоков от устройств через постоянные мультиплексированные каналы без создания тысяч раздельных TCP-сокетов на балансировщиках.keepAliveTimeout и headersTimeout в связке с обратными прокси (NGINX, Cloudflare, AWS ALB) исключает спонтанные генерации HTTP 502 Bad Gateway в моменты пиковых всплесков трафика.Превосходство Fastify над Express обусловлено четырьмя ключевыми архитектурными решениями:
find-my-way): В Express роутер представляет собой линейный массив Layer[]. Каждый входящий запрос проверяется регулярными выражениями поочередно. В Fastify роутер построен на базе префиксного дерева (Radix Tree). Сложность поиска обработчика зависит исключительно от длины запрашиваемого пути $k$, а не от общего количества зарегистрированных маршрутов $N$.ajv): Fastify использует спецификацию JSON Schema. При старте сервера модуль ajv компилирует каждую схему в мономорфный JavaScript-код (JIT-код валидатора). Проверка параметров и тела запроса происходит на порядок быстрее динамических валидаторов, проверяющих свойства через reflection.fast-json-stringify): Стандартный метод JSON.stringify() является универсальным: он выполняет рекурсивный обход объекта, считывает скрытые классы V8, проверяет методы toJSON и экранирует спецсимволы. Модуль fast-json-stringify на основе предопределенной схемы ответа компилирует функцию прямой строковой конкатенации вида:
'{"id":' + obj.id + ',"name":"' + sanitize(obj.name) + '"}'.FastifyRequest, FastifyReply) с фиксированной топологией свойств. Отсутствие динамического monkey-patching (привычного для middleware Express) защищает коллсайты V8 от перехода в мегаморфное состояние, сохраняя инлайнинг функций в машинном коде.import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
// Интерфейс полезной нагрузки DTO
interface CreateOrderBody {
userId: string;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
priority: boolean;
}
interface OrderParams {
orderId: string;
}
// 1. Инициализация Fastify с тюнингом сетевого ядра Node.js
const app: FastifyInstance = Fastify({
logger: {
level: "info",
serializers: {
req(req) {
return { method: req.method, url: req.url, hostname: req.hostname };
},
},
},
// Защита от истощения файловых дескрипторов и Slowloris
connectionTimeout: 10000,
keepAliveTimeout: 65000, // Согласовано с таймаутами L7 балансировщика
maxParamLength: 128,
bodyLimit: 1048576, // Жесткий лимит тела запроса: 1 MB
});
// 2. Декларативная JSON-схема для компиляции в AJV и fast-json-stringify
const orderResponseSchema = {
type: "object",
properties: {
id: { type: "string" },
status: { type: "string" },
totalAmount: { type: "number" },
processedAt: { type: "string" },
},
required: ["id", "status", "totalAmount", "processedAt"],
} as const;
const createOrderSchema = {
body: {
type: "object",
required: ["userId", "items", "priority"],
properties: {
userId: { type: "string", format: "uuid" },
priority: { type: "boolean" },
items: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["productId", "quantity", "price"],
properties: {
productId: { type: "string" },
quantity: { type: "integer", minimum: 1 },
price: { type: "number", minimum: 0 },
},
},
},
},
},
response: {
201: orderResponseSchema,
},
} as const;
// 3. Регистрация маршрута с привязкой схемы
app.post<{ Body: CreateOrderBody }>(
"/api/v1/orders",
{ schema: createOrderSchema },
async (request: FastifyRequest<{ Body: CreateOrderBody }>, reply: FastifyReply) => {
const { userId, items } = request.body;
const totalAmount = items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
// Fastify использует скомпилированный сериализатор fast-json-stringify
reply.status(201);
return {
id: "ord_9873491823",
status: "CONFIRMED",
totalAmount,
processedAt: new Date().toISOString(),
};
}
);
// 4. Запуск сервера с правильной обработкой сигналов завершения (Graceful Shutdown)
async function bootstrap() {
try {
const address = await app.listen({ port: 3000, host: "0.0.0.0" });
console.log(`[Fastify] Кластерный узел запущен на ${address}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
for (const signal of signals) {
process.on(signal, async () => {
console.log(`\n[Fastify] Получен сигнал ${signal}, закрытие пула соединений...`);
await app.close();
process.exit(0);
});
}
bootstrap();import http2, { Http2Server, Http2Stream, IncomingHttpHeaders } from "node:http2";
import fs from "node:fs";
import path from "node:path";
// Чтение сертификатов для TLS и mTLS взаимной аутентификации
const certPath = path.resolve(process.cwd(), "certs");
const server: Http2Server = http2.createSecureServer({
key: fs.readFileSync(path.join(certPath, "server-key.pem")),
cert: fs.readFileSync(path.join(certPath, "server-cert.pem")),
ca: fs.readFileSync(path.join(certPath, "ca-cert.pem")),
// Требуем аутентификацию клиента сертификатом (mTLS)
requestCert: true,
rejectUnauthorized: true,
// Тюнинг HTTP/2 спецификации
settings: {
maxConcurrentStreams: 1000,
initialWindowSize: 1048576, // 1 MB Window Size для контроля потока (Flow Control)
maxHeaderListSize: 16384,
enablePush: false, // Отключаем Server Push согласно актуальным практикам RFC 9113
},
});
server.on("error", (err) => {
console.error("[HTTP2 Server Error]:", err);
});
// Обработка мультиплексированных сессий
server.on("session", (session) => {
session.on("error", (err) => {
console.error("[HTTP2 Session Error]:", err);
});
});
// Обработка логических потоков (Streams) внутри одного TCP-соединения
server.on("stream", (stream: Http2Stream, headers: IncomingHttpHeaders) => {
const method = headers[":method"];
const pathHeader = headers[":path"];
console.log(`[HTTP2 Stream ${stream.id}] Запрос: ${method} ${pathHeader}`);
if (method === "GET" && pathHeader === "/api/v2/stream-data") {
// Отправка заголовков через бинарный HEADERS фрейм с HPACK-сжатием
stream.respond({
":status": 200,
"content-type": "application/json; charset=utf-8",
"x-protocol-transport": "h2-multiplexed",
});
// Потоковая передача данных через фрагментированные DATA фреймы
let counter = 0;
const interval = setInterval(() => {
counter++;
const payload = JSON.stringify({
sequence: counter,
timestamp: Date.now(),
nodeAllocatedMemory: process.memoryUsage().heapUsed,
});
// Передача данных без закрытия потока (endStream = false)
stream.write(payload + "\n");
if (counter >= 10) {
clearInterval(interval);
// Завершение стрима отправкой финального фрейма с флагом END_STREAM
stream.end();
}
}, 50);
stream.on("close", () => {
clearInterval(interval);
});
} else {
stream.respond({ ":status": 404 });
stream.end(JSON.stringify({ error: "Route not found" }));
}
});
server.listen(8443, "0.0.0.0", () => {
console.log("[HTTP2 mTLS] Высокопроизводительный HTTP/2 сервер слушает порт 8443");
});Спонтанные всплески ошибок HTTP 502 Bad Gateway на балансировщиках нагрузки (NGINX, AWS ALB, Envoy) в 99% случаев вызваны несоответствием таймаутов Keep-Alive между прокси и Node.js сервером.
Когда клиент или балансировщик повторно использует открытый TCP-сокет, может возникнуть состояние гонки (Race Condition): Node.js сервер решает закрыть сокет по истечении server.keepAliveTimeout ровно в тот момент, когда балансировщик отправляет в этот сокет новый входящий запрос. Балансировщик получает TCP-пакет RST и возвращает клиенту ошибку 502.
Состояние гонки (Race Condition) при некорректном KeepAliveTimeout:
[Балансировщик AWS ALB / NGINX] [Node.js Сервер]
(KeepAliveTimeout = 60s) (KeepAliveTimeout = 60s)
│ │
│ ─── 59.99s: Повторный запрос в сокет ─────────> │
│ │ ── 60.00s: Срабатывает таймаут!
│ │ ── Node.js закрывает сокет (FIN/RST)
│ <─── Пакет TCP RST (Connection Reset) ───────── │
│
[HTTP 502 Bad Gateway!]Золотое правило конфигурации: server.keepAliveTimeout приложения Node.js обязан быть строго больше таймаута Keep-Alive внешнего балансировщика (минимум на несколько секунд). Кроме того, server.headersTimeout должен быть больше, чем keepAliveTimeout.
// Конфигурация предотвращения Race Condition
const server = http.createServer(app);
// Если таймаут NGINX/ALB равен 60 секундам:
server.keepAliveTimeout = 65000; // 65 секунд (> 60s)
server.headersTimeout = 66000; // 66 секунд (> keepAliveTimeout)
server.requestTimeout = 120000; // 120 секунд общий таймаут обработки телаСтандартный http.Agent в Node.js имеет серьезные архитектурные недостатки: высокое потребление памяти из-за накладных расходов EventEmitter, медленный парсинг через устаревшие потоковые обертки и блокировки цикла событий при высокой нагрузке.
В современных продакшен-системах стандартом де-факто является библиотека Undici (официальный HTTP/1.1 клиент Node.js, лежащий в основе глобального fetch).
Запрос попадает в очередь Client или Pool без создания промежуточных JavaScript-объектов EventEmitter.
Undici выбирает свободный TCP-сокет. Если включен pipelining, запрос отправляется в сокет до завершения предыдущего ответа.
Парсинг байтов ответа осуществляется нативным парсером llhttp напрямую в буферы памяти Node.js.
Тело ответа возвращается в виде ReadableStream без лишних промежуточных аллокаций в куче V8.
import { Pool, Dispatcher } from "undici";
import { IncomingHttpHeaders } from "node:http";
export interface ServiceClientOptions {
baseUrl: string;
maxConnections?: number;
pipelining?: number;
}
export class HighThroughputServiceClient {
private pool: Pool;
constructor(options: ServiceClientOptions) {
// Инициализация пула соединений к конкретному Origin
this.pool = new Pool(options.baseUrl, {
// Максимальное количество активных TCP-сокетов в пуле
connections: options.maxConnections ?? 128,
// Глубина HTTP/1.1 конвейеризации (отправка запросов без ожидания ответа)
pipelining: options.pipelining ?? 4,
// Таймаут Keep-Alive на удержание сокета в режиме ожидания (idle)
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 60000,
// Таймаут установления TCP-соединения
connectTimeout: 5000,
// Таймаут ожидания тела ответа
bodyTimeout: 10000,
// Защита от зависших заголовков
headersTimeout: 5000,
});
}
async sendRequest<T>(
path: string,
method: Dispatcher.HttpMethod = "GET",
body?: string | Buffer,
headers?: IncomingHttpHeaders
): Promise<T> {
const response = await this.pool.request({
path,
method,
headers: {
"content-type": "application/json",
"accept-encoding": "gzip, deflate, br",
...headers,
},
body,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
const errorText = await response.body.text();
throw new Error(
`[ServiceClient Error ${response.statusCode}]: ${errorText}`
);
}
// response.body.json() в Undici оптимизирован и работает быстрее стандартного JSON.parse
return (await response.body.json()) as T;
}
async destroy(): Promise<void> {
await this.pool.destroy();
}
}Почему server.keepAliveTimeout в Node.js обязан быть строго больше таймаута Keep-Alive вышестоящего балансировщика (AWS ALB, NGINX)?
В классическом Express (версии до 4.x и частично 5.x) асинхронные ошибки внутри async/await обработчиков, не обернутые в try/catch или вспомогательные обертки, приводят к необработанному отклонению промиса (UnhandledPromiseRejection). В современных версиях Node.js это приводит к немедленному падению всего процесса (process.exit(1)). Fastify изначально спроектирован для работы с промисами и автоматически перехватывает любые исключения в async-маршрутах, возвращая корректный HTTP 500.
Установка одинакового времени ожидания Keep-Alive на обратном прокси (например, AWS ALB со значением 60 секунд) и в server.keepAliveTimeout Node.js (также 60 секунд). В моменты закрытия соединения сервером Node.js запросы от прокси попадают в закрытый сокет, генерируя нерегулярные всплески ошибок 502 Bad Gateway. Таймаут Node.js должен быть минимум на 5 секунд больше таймаута балансировщика.
Практика динамического добавления произвольных полей в объекты req или res (например, req.customData = {}, req.auth = user) внутри различных middleware. Это разрушает мономорфные структуры скрытых классов V8 (Shapes/Hidden Classes), переводя доступ к свойствам в мегаморфный режим (деоптимизация Inline Cache), что снижает общую скорость обработки запросов на 25–40%. В Fastify для расширения контекста следует использовать официальный API декораторов (fastify.decorateRequest).
Создание новых экземпляров new http.Agent() внутри функций-обработчиков запросов вместо переиспользования единого глобального пула. Каждый новый экземпляр http.Agent аллоцирует собственный пул, сокеты в котором остаются открытыми в состоянии TIME_WAIT или ESTABLISHED, что быстро приводит к ошибке операционной системы EMFILE: too many open files.