Главная / Статьи

Внутри интерцепторов NestJS: устранение регрессии задержки на уровне 96% в масштабных системах

Узнайте, как конвейер выполнения AOP в NestJS и проблемы при разборе объектов RxJS привели к резкому увеличению задержки P99, и как создать интерцептор для аудита без использования дополнительной памяти для её устранения.

7 мин чтения1594 слов

Подробный разбор процесса выполнения 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 разрешает значения в соответствии с определённым порядком приоритетов:

  1. Метаданные на уровне метода (например, @Audit({ action: 'create' }), применённые к методу createOrder()).
  • Метаданные на уровне контроллера (например, @Audit({ action: 'default' }), заданное в OrderController).
  • Значение по умолчанию в качестве резерва.
  • Предупреждение для производственной среды: ручное просмотрение цепочки прототипов внутри intercept() является ошибкой с точки зрения производительности — это приводит к игнорированию внутренних кэшированных таблиц поиска фреймворка. Используйте Reflector.getAllAndOverride(), который гарантирует решение задачи за постоянное время, O(1).

    4. Практика: создание движка аудита без выделения памяти

    Чтобы функционировать при нагрузке свыше 8,000 запросов в секунду, ваш интерцептор должен соблюдать три строгих ограничения:

    1. Временные показатели без выделения памяти: используйте process.hrtime.bigint() вместо создания объекта new Date().
    2. Неблокирующая обработка: откладывание работы по логированию с основного потока с помощью setImmediate или специальной очереди сообщений.
    3. Безопасность при истечении времени: оборачивание потока с помощью 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.remoteAddress
    import {
     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.action
  • Архитектура B (оптимизированный подход): использование функции process.hrtime.bigint() для измерения времени и перенос работы с помощью setImmediate.
  • Результаты были впечатляющими. Пропускная способность выросла с 4,120 запросов в секунду до 9,840, что на 138% больше. Среднее время отклика (P50) сократилось с 22 мс до 3,1 мс, что на 85,9% меньше. Время отклика для 99% запросов снизилось с 385 мс до 14,2 мс, что на 96,3% лучше. Интенсивность использования памяти V8 уменьшилась с 340 МБ/сек до 18 МБ/сек, что на 94,7% меньше.

    Эти цифры не являются абстрактными — они определяют границу между сервисом, который эффективно масштабируется, и тем, который разрушается под воздействием внезапного скачка нагрузки.

    6. Часто упускаемые из виду ошибки (избегайте их)

    Ошибка 1: нарушение цепочки RxJS

    Если вы забудете вызвать return next.handle(), запрос застрянет навсегда, поскольку ничто не подписано на основной поток данных.

    // ❌ Не делайте этого
    intercept(ctx, next) {
     this.validate(ctx);
     // Отсутствует возврат
    }
    
    // ✅ Делайте так
    intercept(ctx, next) {
     return next.handle().pipe(...);
    }
    

    Подводная камня 2: утечка «долгоживущих observable»

    Подводная камня 3: ненужное отражение метаданных

    Избегайте ручного просмотра цепочки прототипов. Полностью полагайтесь на встроенную службу Reflector, которая хранит глобальную кэш-коллекцию ключей метаданных и эффективно их разрешает.

    7. Архитектурные принципы для высокой конкурентности

    1. Сохраняйте синглтоны без состояния: интерцептор создается только один раз на весь срок жизни приложения, поэтому он никогда не должен хранить данные, связанные с конкретным запросом, в качестве полей экземпляра.
    2. Агрессивно декуплируйте: если требуется сохранять информацию о аудите в базе данных, передайте эту задачу на обработку таким инструментам, как очередь BullMQ или кольцевая буферизация, вместо того чтобы выполнять запись непосредственно внутри интерцептора с использованием await.
    3. Соблюдайте границы слоев: используйте компоненты Pipes для преобразования входных данных, а интерцепторы — только для изменения поведения выполнения. Смешивание этих функций приводит к коду, который трудно обслуживать и медленно работает.