NestJSSeniorExperience

Представьте, endpoint на NestJS иногда отвечает за секунды вместо миллисекунд. Как вы будете искать причину?

Добавить timing-interceptor для фиксации времени каждого этапа, проверить пул соединений БД (pg_stat_activity), запустить clinic.js для обнаружения блокировки event loop, добавить OpenTelemetry для корреляции медленных span-ов с конкретным слоем.

Диагностика нестабильно медленного endpoint в NestJS

Периодические замедления (иногда миллисекунды, иногда секунды) — один из наиболее коварных сценариев. Причин может быть несколько: конкуренция за соединение с БД, GC-паузы, медленные внешние вызовы, блокирующий код в event loop. Ниже — последовательный план расследования.

Шаг 1: Сбор данных — добавить детальное измерение времени

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('Timing');

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest();
    const start = process.hrtime.bigint();
    return next.handle().pipe(
      tap(() => {
        const ms = Number(process.hrtime.bigint() - start) / 1_000_000;
        this.logger.log(`${req.method} ${req.url}${ms.toFixed(2)}ms`);
      }),
    );
  }
}

Разбить endpoint на этапы: время до первого await, время ORM-запроса, время после. Это сразу покажет, какой этап «прыгает».

Шаг 2: Проверить пул соединений с БД

Самая частая причина intermittent slowness — ожидание свободного соединения в пуле.

// TypeORM DataSource
const dataSource = new DataSource({
  type: 'postgres',
  extra: {
    max: 20,          // размер пула
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000, // если нет свободного — выбросить ошибку, а не ждать вечно
  },
});

Посмотреть в `pg_stat_activity`: сколько соединений в состоянии `active` и `idle in transaction`. Долгие транзакции удерживают соединение и блокируют остальные запросы.

Шаг 3: Проверить блокировку event loop

Синхронный CPU-интенсивный код (парсинг JSON, криптография, RegExp на больших строках) блокирует event loop и делает все запросы медленными одновременно. Выявить можно через `clinic.js`:

npx clinic doctor -- node dist/main.js
# Генерировать нагрузку параллельно:
npx autocannon -c 50 -d 30 http://localhost:3000/api/slow-endpoint

Если clinic показывает «Event Loop Blocked», искать синхронные операции в hot path. Решение: вынести в `worker_threads` или `child_process`.

Шаг 4: Проверить внешние HTTP-вызовы

  • Добавить таймаут ко всем исходящим запросам: `axios.get(url, { timeout: 3000 })`.
  • Проверить DNS-резолюцию: без кэша каждый запрос к внешнему сервису может занять 50–200 мс на резолвинг.
  • Проверить keep-alive: по умолчанию axios не использует HTTP keep-alive, что приводит к TCP handshake на каждый запрос.
import { HttpModule } from '@nestjs/axios';
import * as http from 'http';
import * as https from 'https';

HttpModule.register({
  timeout: 5000,
  httpAgent: new http.Agent({ keepAlive: true }),
  httpsAgent: new https.Agent({ keepAlive: true }),
});

Шаг 5: GC-паузы и память

Node.js V8 major GC может останавливать event loop на 50–500 мс. Признак — периодические задержки без корреляции с нагрузкой на БД.

# Запустить с GC-логами:
node --expose-gc --trace-gc dist/main.js 2>&1 | grep -E "(Scavenge|Mark-sweep)"

Если есть частые major GC — искать утечки памяти или уменьшить размер кэшей в памяти.

Шаг 6: Трассировка через OpenTelemetry

// tracing.ts — запустить до импорта NestJS
import { NodeSDK } from '@opentelemetry/sdk-node';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  traceExporter: new JaegerExporter({ endpoint: 'http://jaeger:14268/api/traces' }),
  instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();

Jaeger UI покажет waterfall всех span-ов — сразу видно, какой внешний вызов или запрос к БД занял лишние секунды.

Подводные камни

  • Intermittent slowness часто не воспроизводится в dev — тестировать под реалистичной конкурентной нагрузкой (autocannon, k6).
  • N+1 запросы к БД проявляются только при реальных данных: 10 записей в dev, 10 000 в prod — разница в 3 порядка.
  • Таймаут без circuit breaker: если внешний сервис начинает отвечать медленно, все потоки event loop блокируются в ожидании — добавить `@nestjs/throttler` или `opossum` circuit breaker.
  • Long-running transaction без явного COMMIT/ROLLBACK удерживает соединение и блокирует пул.
  • DNS кэш Node.js по умолчанию отсутствует — каждый `fetch`/`axios` запрос к внешнему API делает системный DNS lookup.
  • REQUEST-scoped провайдеры под нагрузкой создают тысячи объектов — давление на GC нарастает постепенно и проявляется как intermittent slowness.
  • Логирование в синхронном режиме (запись в файл без буфера) может блокировать event loop на высоком трафике — использовать `pino` с `transport`.
  • `JSON.stringify` на больших объектах (ответ с тысячами записей) — блокирующая операция; для потоковой отдачи использовать `fast-json-stringify`.

What hurts your answer

  • Сразу обвинять NestJS, не проверив соседние слои системы
  • Чинить симптом без минимального воспроизведения и evidence
  • Не учитывать версии, конфигурацию, окружение и recent changes

What they're listening for

  • Умеет локализовать проблему вокруг NestJS
  • Двигается от симптома к гипотезам и проверкам
  • Отличает баг инструмента от ошибки использования или окружения

Related topics