Усередині інтерцепторів NestJS: усунення проблеми затримки 96% у масштабних системах
Дізнайтеся, як конвеєр виконання AOP у NestJS та проблеми під час закриття об’єктів у RxJS спричинили різкий зростання латентності на рівні P99, та як створити інтерцептор для аудиту без використання додаткових ресурсів, щоб це виправити.
Детальний аналіз процесу виконання AOP, механізму зміни контексту в поліморфному середовищі та того, як потоки RxJS без виділення пам’яті дозволили знизити значення P99 на 96 відсотків.
1. Сигнал до дії: коли „стандартний“ інтерцептор зруйнував роботу системи
У архітектурах великомасштабних мікросервісів існує спокуса розглядати інтерцептори NestJS як своєрідний проміжний модуль у вигляді чорної скриньки, який можна просто додати без роздумів. Однак ігнорування того, як насправді AOP (програмування з орієнтацією на аспекти) керує контекстом виконання, може призвести до болісних та ганебних перерв у роботі під час справжнього навантаження.
8 000 запитів на секунду. Інженерна команда додала те, що здавалося безневинним аудиторським інтерцептором, призначеним для запису тіл запитів та часів відповідей.
Негативні наслідки проявилися майже відразу після впровадження:
- Затримка P99 зросла з 18 мс до 420 мс, і користувачі відразу помітили уповільнення.
- Heap у V8 активно змінювався, що спричиняло повні цикли збирання сміття типу Mark-Sweep, які заморожували цикл подій на протязі 150 мс.
- Пам’ять почала витікати, оскільки об’єкти RxJS Observables ніколи належним чином не закривалися після тайм-ауту клієнтського з’єднання.
Що пішло не так? Команда фактично написала інтерцептор так, ніби це синхронний middleware Express. Вони виконували JSON.parse(JSON.stringify(req.body)) для кожного запиту, покладалися на new Date() для вимірювання часу, що минув, та повністю проігнорували цикл розбирання, необхідний для об’єктів Observables.
У цьому посібнику детально розглядається внутрішня схема обробки запитів у NestJS, аналізується джерело
ExecutionContext, а також з нуля створюється механізм аудиту з точністю до півмілісекунди. Кожен, хто розробляє сервіси на Node.js з високою пропускною здатністю, повинен використовувати цей посібник як список перешкод, яких слід уникати.
2. Розбір структури AOP „Цибулина“ (точний порядок виконання)
Перш ніж щось оптимізувати, потрібно мати точну уявну модель цієї схеми. NestJS не дозволяє вставляти логіку довільно — він забезпечує сувору, багатошарову структуру типу „Цибулина“.
Розміщення логіки у неправильному шарі цієї структури є найпоширенішою причиною погіршення продуктивності:
[ Вхідний запит HTTP / RPC ]
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Мідлвейр (нативний Express/Fastify - Глобальний/Модуль) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Захисники (CanActivate - Аутентифікація/Авторизація) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Інтерцептори (До контролеру - Обгортання запиту) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Трубки (ValidationPipe - Трансформація/Зміна даних) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Обробник вихідних даних (бізнес-логіка контролера) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Інтерцептори (після контролера – обгортання відповіді RxJS) │
└──────────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Фільтри винятків (форматування глобальних помилок) │
└──────────────────────────────┬──────────────────────────────┘
│
▼
[ Вихідна відповідь ]
Ключовий висновок: будь-яка ресурсоємна операція — включаючи глибоке клонування — яка виконується у шарах 3 або 6, безпосередньо зупинить цикл подій. Трубки існують для перетворення даних; інтерцептори існують для обгортання поведінки, а не для виконання важкої синхронної роботи.
3. Розбір вихідного коду: що криється всередині ExecutionContextHost та ієрархії Reflector
Інженери, які цікавляться внутрішньою структурою, оцінять можливість побачити, як NestJS змінює типи контексту в своїй основі.
Секрет індексації масиву
У файлі @nestjs/core/helpers/execution-context-host.ts об’єкт ExecutionContext насправді є лише простим обгортком навколо звичайного масиву аргументів. Саме така конструкція дозволяє NestJS єдиним чином підтримувати протоколи HTTP, gRPC, GraphQL та мікросервісні транспорти, не потребуючи окремої налаштування для кожного з них:
// Спрощена основна логіка
export class ExecutionContextHost implements ExecutionContext {
constructor(private readonly args: any[]) {}
switchToHttp(): HttpArgumentsHost {
return new HttpArgumentsHostImpl(this.args);
}
// HTTP: args[0] = Request, args[1] = Response, args[2] = Next
// RPC: args[0] = Data, args[1] = Context
// GraphQL: args[0] = Root, args[1] = Args, args[2] = Context, args[3] = Info
}
Ланцюг пошуку Reflector
Коли ви використовуєте @SetMetadata(), сервіс Reflector знаходить значення відповідно до визначеної послідовності пріоритетів:
- Метадані на рівні методу (наприклад,
@Audit({ action: 'create' }), встановлені уcreateOrder()).
@Audit({ action: 'default' }), встановлений у OrderController). Попередження для продакшну: ручне обходження ланцюга прототипів усередині intercept() є помилкою з точки зору продуктивності — це усуває можливість використання внутрішніх кешованих таблиць пошуку фреймворку. Краще використовувати Reflector.getAllAndOverride(), який гарантує швидке вирішення завдань з часом обробки O(1).
4. Практика: створення двигуна аудиту без виділення пам’яті
Щоб витримувати навантаження понад 8 000 запитів на секунду, ваш інтерцептор має дотримуватися трьох суворих обмежень:
- Часові показники без виділення пам’яті: використовуйте
process.hrtime.bigint()замість створення об’єктаnew Date(). - Неблокуюче розподілення завдань: відкладає роботу з логування з основного потоку за допомогою
setImmediateабо спеціалізованої черги повідомлень. - Безпека через таймаут: обгортає потік за допомогою
timeout(5000), щоб гарантувати завершення роботи RxJS навіть у разі збою.
Крок 1: Декоратори зі сильною типізацією
import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
export const AUDIT_ACTION_KEY = 'AUDIT_ACTION_KEY';
export interface AuditMetadata {
action: string;
resource: string;
}
export const Audit = (metadata: AuditMetadata) => SetMetadata(AUDIT_ACTION_KEY, metadata);
// Вилучення контексту клієнта без розповсюдження об’єктів
export const ClientContext = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return {
ip: req.ip || req.socket.remoteAddressimport {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { tap, catchError, timeout } from 'rxjs/operators';
import { AUDIT_ACTION_KEY, AuditMetadata } from './audit.decorator';
@Injectable()
export class PerformanceAuditInterceptor implements NestInterceptor {
private readonly logger = new Logger('AuditEngine');
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any>> {
// Швидкий прохід для маршрутів без аудиту
const auditConfig = this.reflector.getAllAndOverride<AuditMetadata>>(
AUDIT_ACTION_KEY,
[context.getHandler(), context.getClass()],
);
if (!auditConfig) {
return next.handle();
}
const http = context.switchToHttp();
const request = http.getRequest();
const traceId = request.headers['x-trace-id'] || 'trace-none';
const method = request.method;
const url = request.url;
// Точність до наносекунди, жодної виділеної пам’яті
const startTime = process.hrtime.bigint();
return next.handle().pipe(
// Встановлення суворого терміну у 5 секунд, щоб запобігти зависанню потоків
timeout(5000),
tap({
next: () => {
const durationNs = process.hrtime.bigint() - startTime;
const durationMs = Number(durationNs) / 1_000_000;
this.dispatchAuditLog({
traceIdig.actionprocess.hrtime.bigint() для вимірювання часу та перенесення обчислень за допомогою setImmediate.Результати були вражаючими. Пропускна здатність зросла з 4 120 запитів на секунду до 9 840, що становить зростання на 138%. Медіанний (P50) час відгуку зменшився з 22 мс до 3,1 мс — на 85,9%. Час відгуку для P99 знизився з 385 мс до 14,2 мс — на 96,3%. Обсяг використання пам’яті V8 зменшився з 340 МБ/сек до 18 МБ/сек — на 94,7%.
Ці цифри не є абстрактними — вони позначають межу між сервісом, який ефективно масштабується, та тим, який зазнає краху під раптовим сплеском трафіку.
6. Часто ігноровані проблеми (уникайте їх)
Проблема 1: Розрив ланцюга RxJS
Якщо ви забудете виконати return next.handle(), запит застрягне назавжди, оскільки ніщо не підписане на основний потік.
// ❌ Не робіть цього
intercept(ctx, next) {
this.validate(ctx);
// Відсутнє return
}
// ✅ Робіть так
intercept(ctx, next) {
return next.handle().pipe(...);
}
Підводна камінь 2: Витік „довгоживучих Observable“
Підводна камінь 3: Непотрібне відображення метаданих
Уникайте ручного обходу ланцюга прототипів. Повністю покладайтеся на вбудовану службу Reflector, яка підтримує глобальну кеш-пам’ять ключів метаданих та ефективно їх обробляє.
7. Архітектурні принципи для високої конкурентності
- Зберігайте синглтони без стану: інтерцептор створюється лише один раз протягом терміну існування додатку, тому він ніколи не повинен зберігати дані, специфічні для запиту, у якості полів екземпляра.
- Агресивно розділяйте компоненти: якщо ваша історія аудиту потребує збереження в базі даних, передайте це завдання щось на кшталт черги BullMQ або кільцевого буфера замість того, щоб безпосередньо в інтерцепторі використовувати
awaitдля запису. - Дотримуйтесь меж шарів: використовуйте Pipes для перетворення вхідних даних та призначайте Interceptors для обгортання поведінки виконання. Змішування цих функцій призводить до коду, який важко підтримувати та повільно працює.