Strona główna / Artykuły

Wewnątrz interceptorów NestJS: naprawa regresji opóźnienia na poziomie 96% w skali

Dowiedz się, jak pipeline wykonywania AOP w NestJS oraz pułapki związane z kończeniem pracy RxJS spowodowały gwałtowny wzrost opóźnienia P99, oraz jak stworzyć interceptor audytowy bez zużycia pamięci w celu jego naprawy.

7 min czytania1594 słów

Bliższe przyjrzenie się pipeline’owi wykonywania w AOP, zmianie kontekstu polimorficznemu oraz temu, jak strumienie RxJS bez przydzielania pamięci obniżyły opóźnienie P99 o 96 procent.

1. Wezwanie do działania: Kiedy „standardowy” interceptor zepsuł produkcję

W architekturach mikrosług na dużą skalę łatwo jest traktować interceptory NestJS jako rodzaj czarnej skrzynki middleware, którą po prostu wstawia się bez zastanowienia. Jednak ignorowanie sposobu, w jaki AOP (programowanie zorientowane na aspekty) faktycznie zarządza kontekstem wykonywania, może skutkować bolesnymi i żenującymi przerwami w działaniu, gdy pojawi się rzeczywy ruch.

Wyobraźmy sobie platformę fintech działającą przy mniej więcej 8 000 żądań na sekundę. Zespół inżynierów dodał coś, co wyglądało na nieszkodliwy interceptor audytowy, mający na celu rejestrowanie treści żądań i czasów odpowiedzi.

Konsekwencje pojawiły się niemal natychmiast po wdrożeniu:

  • Czas opóźnienia P99 wzrósł z 18 ms do 420 ms, a użytkownicy od razu zauważyli spowolnienie.
  • Heap w V8 funkcjonował bardzo niespójnie, co powodowało pełne cykle zbierania śmieci typu Mark-Sweep, które zamykały pętlę zdarzeń na okresy do 150 ms.
  • Pamięć zaczęła wyciekać, ponieważ obiekty RxJS Observables nigdy nie były prawidłowo zamykane po wygaśnięciu połączenia klienta.

Co poszło nie tak? Zespół w zasadzie napisał ten interceptor tak, jakby był to synchroniczny middleware Express. Wykonywali JSON.parse(JSON.stringify(req.body)) przy każdej prośbie, polegali na new Date() do mierzenia upływu czasu i całkowicie zaniedbali żywotny cykl dezinstalacji, który są wymagane przez obiekty Observables.

To przewodnik zgłębia wewnętrzną ścieżkę przetwarzania zapytań w NestJS, bada źródło ExecutionContext oraz buduje od zera mechanizm audytu działający w czasie krótszym niż pół milisekundy. Każdy, kto tworzy usługi Node.js o wysokiej przepustowości, powinien traktować to jako listę pułapek, których należy unikać.

2. Rozbieranie struktury AOP (dokładna kolejność wykonywania)

Zanim cokolwiek optymalizujesz, potrzebujesz dokładnego modelu mentalnego tej ścieżki. NestJS nie pozwala umieszczać logiki w dowolnym miejscu — narzuca ścisłą, warstwową strukturę typu „cebula”.

Umieszczenie logiki w niewłaściwej warstwie tej struktury jest najczęstszą przyczyną pogorszenia wydajności:

[ Przychodząca prośba HTTP / RPC ]
 │
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Middleware (Express/Fastify natywny - Globalny/Modułowy) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Zabezpieczenia (CanActivate - Autoryzacja/Upoważnienie) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Interceptory (Przed kontrolerem - Opakowywanie żądania) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Rury przekazywania (ValidationPipe - Transformacja/mutacja danych) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Obsługa wyjściowa (logika biznesowa kontrolera) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Interceptory (po kontrolerze – pakowanie odpowiedzi RxJS) │
└──────────────────────────────┬──────────────────────────────┘
 ▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Filtry wyjątków (formatowanie błędów globalnych) │
└──────────────────────────────┬──────────────────────────────┘
 │
 ▼
 [ Odpowiedź wyjściowa ]

Główny wniosek: każda kosztowna operacja — w tym głębokie klonowanie — wykonywana w warstwach 3 lub 6 bezpośrednio zatrzyma pętlę zdarzeń. Rury służą do przekształcania danych; interceptory służą do opakowywania zachowań, a nie do wykonywania intensywnych zadań synchronicznych.

3. Analiza kodu źródłowego: Wnętrze ExecutionContextHost i hierarchia Reflector

Inżynierowie, którzy lubią znać szczegóły techniczne, docenią możliwość zobaczenia, w jaki sposób NestJS zmienia typy kontekstu w swoim wnętrzu.

Tajemnica indeksowania tablic

W pliku @nestjs/core/helpers/execution-context-host.ts obiekt ExecutionContext jest w rzeczywistości jedynie cienką otoczką zwykłej tablicy argumentów. To właśnie dzięki takiemu projektowi NestJS może w sposób jednolity obsługiwać protokoły HTTP, gRPC, GraphQL oraz transporty mikrosług bez konieczności specjalnego traktowania każdego z nich:

// Uproszczona logika podstawowa
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
}

Łańcuch wyszukiwania w Reflectorze

Gdy używasz @SetMetadata(), usługa Reflector rozwiązuje wartości zgodnie z określonym porządkiem priorytetów:

  1. Metryki na poziomie metody (na przykład @Audit({ action: 'create' }) zastosowane do createOrder()).
  • Metryki na poziomie kontrolera (na przykład @Audit({ action: 'default' }) ustawione w OrderController).
  • Wartość domyślna awaryjna.
  • Ostrzeżenie dla środowiska produkcyjnego: ręczne przeglądanie łańcucha prototypów wewnątrz intercept() to błąd pod względem wydajności — pomija ono wewnętrzne mapy szybkiego wyszukiwania zapisane w frameworku. Korzystaj z Reflector.getAllAndOverride(), które gwarantuje rozwiązanie w czasie stałym, O(1).

    4. Praktyka: Budowa silnika audytu bez alokacji pamięci

    Aby funkcjonować przy obciążeniu powyżej 8 000 żądań na sekundę, Twój interceptor musi przestrzegać trzech surowych ograniczeń:

    1. Czas działania bez alokacji pamięci: korzystaj z process.hrtime.bigint() zamiast tworzenia obiektu new Date().
    2. Nieblokujące rozdzielanie zadań: odkładanie pracy związanej z logowaniem poza główny szlak za pomocą setImmediate lub dedykowanej kolejki wiadomości.
    3. Bезpieczeństwo przed przekroczeniem czasu: otaczanie strumienia funkcją timeout(5000), aby zapewnić rozwiązanie struktury RxJS nawet w przypadku błędu.

    Krok 1: Dekoratory o silnie określonym typie

    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);
    // Wyodrębnienie kontekstu klienta bez rozprzestrzeniania obiektu
    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>> {
     // Szybka ścieżka omijania dla tras, które nie są audytowane
     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; // Precyzja do nanosekundy, brak alokacji pamięci const startTime = process.hrtime.bigint(); return next.handle().pipe( // Narzucenie sztywnego terminu 5 sekund, aby zapobiec zatrzymaniu strumieni timeout(5000), tap({ next: () => { const durationNs = process.hrtime.bigint() - startTime; const durationMs = Number(durationNs) / 1_000_000; this.dispatchAuditLog({ traceIdig.action
  • Architektura B (przystępność optymalizowana): wykorzystanie process.hrtime.bigint() do mierzenia czasu oraz przenoszenie obciążenia za pomocą setImmediate.
  • Wyniki były wyraźne. Przepustowość wzrosła z 4 120 żądań na sekundę do 9 840, co stanowi wzrost o 138%. Czas oczekiwania w medianie (P50) spadł z 22 ms do 3,1 ms, czyli o 85,9%. Czas oczekiwania w zakresie P99 zmniejszył się z 385 ms do 14,2 ms, co oznacza poprawę o 96,3%. Zużycie pamięci V8 spadło z 340 MB/s do 18 MB/s, co stanowi redukcję o 94,7%.

    Te liczby nie są abstrakcyjne — wskazują one granicę pomiędzy usługą, która skaluje się prawidłowo, a tą, która zawodzi pod wpływem nagłego wzrostu ruchu.

    6. Często pomijane pułapki (unikaj ich)

    Pułapka 1: Przerwanie łańcucha RxJS

    Jeśli zapomnisz o wywołaniu return next.handle(), żądanie będzie blokowane na zawsze, ponieważ nic nie jest podpisane pod podstawowym strumieniem.

    // ❌ Nie rób tego
    intercept(ctx, next) {
     this.validate(ctx);
     // Brakuje return
    }
    
    // ✅ Rób to w ten sposób
    intercept(ctx, next) {
     return next.handle().pipe(...);
    }
    

    Pułapka 2: Wyciek „długowiecznych obserwowalnych”

    Pułapka 3: Niepotrzebne odzwierciedlanie metadanych

    Unikaj ręcznego przemierzania łańcucha prototypów. Polegaj wyłącznie na wbudowanej usłudze Reflector, która przechowuje globalną bazę danych kluczy metadanych i efektywnie je rozwiązuje.

    7. Zasady architektoniczne dla wysokiej konkurencji

    1. Zachowuj singletony bez stanu: interceptor jest tworzony tylko raz na cały okres życia aplikacji, dlatego nie może przechowywać danych specyficznych dla żądania jako pól instancji.
    2. Agresywne oddzielanie komponentów: jeśli ślad audytowy musi być przechowywany w bazie danych, przekaż to zadanie na przykład do kolejki BullMQ lub bufora pierścieniowego zamiast wykonywać operację zapisu bezpośrednio wewnątrz interceptora za pomocą await.
    3. Szanuj granice warstw: używaj Pipes do przekształcania danych wejściowych, a Interceptorów do obsługi zachowań wykonywania. Mieszanie tych obowiązków prowadzi do kodu, który jest trudny w utrzymaniu i wolny w działaniu.

    Literatura pokrewna

  • NestJS vs Node.js: Dlaczego struktura przewyższa swobodę w dużych systemach — Ten artykuł wyjaśnia, jak NestJS łączy architekturę warstwową, iniekcję zależności oraz ustalone konwencje z Node.js, aby rozwiązać problemy z utrzymanością, których nie potrafią zwykły Node lub Express.
  • Dlaczego NestJS zwycięża w rozwijających się zespołach backendu i bazach kodu — Dowiedz się, jak struktura NestJS, iniekcja zależności oraz podejście oparte na TypeScript pomagają zespołom inżynieryjnym rozwijać się skalowo, unikając chaosu.