Мережевий стек Node.js базується на низькорівневих системних абстракціях ядра ОС, що надаються бібліотекою libuv (uv_tcp_t, epoll у Linux, kqueue у macOS/BSD, IOCP у Windows), та вбудованому парсері протоколів llhttp (написаному на TypeScript/C, який замінив застарілий http-parser). Розуміння життєвого циклу мережевого пакета від мережевої карти (NIC) до колбеку маршрутизатора є фундаментальним для проектування сервісів із пропускною здатністю 50 000 – 80 000+ запитів на секунду (RPS) на одне процесорне ядро.
Конвеєр обробки вхідного HTTP-запиту в Node.js:
[Мережевий пакет TCP SYN/ACK]
│
▼
[Ядро ОС: Стек TCP/IP та буфер прийому сокета]
│ (epoll_wait / kqueue сповіщення про готовність дескриптора)
▼
[libuv: uv_tcp_t -> uv_read_cb]
│ (Виділення пам'яті через Buffer.allocUnsafe)
▼
[C++ Binding: node::StreamWrap / TCPWrap]
│
▼
[llhttp Parser]: Потоковий аналіз байтів (URL, Заголовки, Чанки тіла)
│
▼
[node:http: net.Socket та IncomingMessage]
│ (Генерація події 'request' на екземплярі http.Server)
▼
[HTTP Framework Routing Engine]: Маршрутизація та виконання плагінів
│ Fastify: Radix Tree (O(k)) ── проти ── Express: Лінійний масив (O(n))
▼
[V8 Serialization]: Відправка відповіді клієнту
Fastify: fast-json-stringify (AOT-компільована конкатенація рядків)
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, доки всі заголовки не будуть зібрані, що мінімізує навантаження на Garbage Collector (GC).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-сервер — це потоковий конвеєр, що трансформує сирі байти мережі в оптимізовані структури пам'яті під жорстким контролем затримок.
HTTP/2 Frame HTTP/2 Stream Radix Tree (find-my-way) HTTP Keep-Alive AOT JSON Serialization UndiciПорівняння диспетчеризації: Express (Лінійний список) проти Fastify (Radix Tree)
EXPRESS (Linear Middleware Pipeline - O(N)):
URL: /api/v1/users/42/orders
[Router]
│
├── Layer 0: match('/')? ──────────> Ні
├── Layer 1: match('/health')? ────> Ні
├── Layer 2: match('/auth/*')? ────> Ні
├── Layer 3: match('/api/v1/posts')> Ні
└── Layer N: match('/api/v1/users/:id/orders') ──> Співпадіння (Після 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" ───────> Обробник знайдено за 4 переходи!undici.Agent. Утримання відкритих сокетів знижує p99 tail latency з 45 мс до 1.8 мс завдяки усуненню повторних TLS-хендшейків.node:http2 для мультиплексування сотень потоків даних від сенсорів через постійні канали без перевантаження таблиць балансувальників TCP.keepAliveTimeout та headersTimeout зі зворотними проксі (NGINX, Cloudflare, AWS ALB) усуває спонтанні сплески помилок HTTP 502 Bad Gateway під час піків навантаження.Продуктивність Fastify над Express досягається за рахунок чотирьох рішень:
find-my-way): В Express роутер є лінійним масивом Layer[]. У Fastify маршрути індексуються в префіксному дереві. Складність пошуку залежить лише від довжини шляху $k$, а не від загальної кількості зареєстрованих маршрутів $N$.ajv): Fastify спирається на JSON Schema. Під час старту додатка ajv компілює кожну схему в оптимізований мономорфний JavaScript-байткод. Валідація відбувається в рази швидше за динамічні валідатори на базі рефлексії.fast-json-stringify): Стандартний JSON.stringify() динамічно інспектує типи об'єктів V8. Модуль fast-json-stringify на основі схеми компілює функцію прямої конкатенації рядків:
'{"id":' + obj.id + ',"name":"' + sanitize(obj.name) + '"}'.FastifyRequest, FastifyReply) зі стабільною структурою полів. Відмова від динамічного monkey-patching запобігає переходу колсайтів 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 з тюнінгом мережевого ядра
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";
// Завантаження сертифікатів для взаємної 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) здебільшого спричинені станом перегонів (Race Condition) таймаутів Keep-Alive.
Коли балансувальник повторно використовує відкритий сокет, виникає колізія: сервер 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 зобов'язаний бути суворо більшим за таймаут зовнішнього проксі (мінімум на 5 секунд). Крім того, server.headersTimeout повинен перевищувати keepAliveTimeout.
// Конфігурація для запобігання стану перегонів
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).
Запит стає в чергу пулу без створення зайвих об'єктів EventEmitter.
Undici вибирає доступний Keep-Alive сокет. При pipelining запит надсилається без очікування відповіді на попередній.
Байтова послідовність парситься нативним парсером llhttp без проміжних копій у пам'яті.
Тіло повертається як оптимізований 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,
// Таймаут утримання сокета у стані спокою (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();
}
}У класичному Express помилки всередині 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), переводячи доступ до полів у мегаморфний режим та знижуючи швидкість виконання коду на 25–40%. У Fastify слід використовувати офіційний API декораторів (fastify.decorateRequest).
Створення нових екземплярів new http.Agent() всередині функцій обробників замість використання спільного синглтона. Кожен новий http.Agent виділяє власний пул, сокети в якому зависають у стані TIME_WAIT або ESTABLISHED, що призводить до помилки операційної системи EMFILE: too many open files.
Ядро ОС завершує 3-way handshake. Libuv передає байтовий потік сокета в парсер llhttp без зайвих алокацій рядків у купі V8.
Движок find-my-way за час O(k) знаходить відповідний обробник у префіксному дереві за сегментами URL.
Параметри запиту, query та корисне навантаження верифікуються заздалегідь скомпільованою функцією валідатора.
Асинхронний обробник виконує бізнес-логіку, зберігаючи мономорфну стабільність Hidden Classes V8.
AOT-серіалізатор конкатенує байти відповіді за схемою без повільного JSON.stringify та скидає буфер у сокет.