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.
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
ExecutionContextoraz 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:
- Metryki na poziomie metody (na przykład
@Audit({ action: 'create' })zastosowane docreateOrder()).
@Audit({ action: 'default' }) ustawione w OrderController). 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ń:
- Czas działania bez alokacji pamięci: korzystaj z
process.hrtime.bigint()zamiast tworzenia obiektunew Date(). - Nieblokujące rozdzielanie zadań: odkładanie pracy związanej z logowaniem poza główny szlak za pomocą
setImmediatelub dedykowanej kolejki wiadomości. - 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.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>> {
// 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.actionprocess.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
- 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.
- 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. - 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
- Kompilator TypeScript w Go i wykonywanie natywne: Przewodnik po migracji — Dowiedz się, jak kompilator TypeScript oparty na Go oraz wykonywanie natywne w Node.js wpłyną na projekty React i Next.js oraz co należy natychmiast poprawić w pliku tsconfig.
- Zamiana Jest na natywnego wykonywacza testow w Node 24 — Przykład z praktyki pokazuje, jak wbudowany wykonywacz testow w Node 24 oraz natywna obsługa TypeScript skracają czas CI, jednocześnie eliminując cztery zależności.