Пошук уроків, статей та іншого контенту
Знайдете вузькі місця та оптимізуєте обробку запитів, серіалізацію й використання ресурсів.
Продуктивність NestJS залежить не лише від фреймворку. На час відповіді впливають:
виконання middleware, guards, pipes та interceptors;
робота з базою даних і зовнішніми сервісами;
серіалізація об’єктів у JSON;
розмір відповіді;
синхронні CPU-операції в обробнику;
кількість одночасних підключень і використання пам’яті;
конфігурація HTTP-адаптера.
Оптимізацію варто починати з вимірювань. Перед змінами зафіксуйте:
середній час відповіді;
перцентилі p95 і p99;
кількість запитів за секунду;
частку помилок;
використання CPU та пам’яті;
час виконання запитів до бази даних.
Середній час може приховувати повільні запити. Наприклад, середнє значення 100 мс не показує проблему, якщо кожен двадцятий запит триває 2 секунди.
Для початкової діагностики можна використати interceptor, який вимірює час від початку обробки до формування відповіді.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class RequestTimingInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.originalUrl ?? request.url;
const startedAt = process.hrtime.bigint();
return next.handle().pipe(
tap({
next: () => this.logRequest(method, url, startedAt),
error: () => this.logRequest(method, url, startedAt),
}),
);
}
private logRequest(
method: string,
url: string,
startedAt: bigint,
): void {
const durationMs =
Number(process.hrtime.bigint() - startedAt) / 1_000_000;
console.log(`${method} ${url} — ${durationMs.toFixed(2)} мс`);
}
}Такий interceptor можна підключити глобально:
app.useGlobalInterceptors(new RequestTimingInterceptor());У production не варто записувати в лог кожен запит без обмежень. Велика кількість синхронних або повільних операцій логування сама може погіршити продуктивність. Краще:
логувати лише повільні запити;
використовувати структуровані логи;
передавати логи в систему спостереження;
додавати ідентифікатор запиту для пошуку пов’язаних подій.
Наприклад, interceptor може логувати лише запити довші за 300 мс:
private logRequest(
method: string,
url: string,
startedAt: bigint,
): void {
const durationMs =
Number(process.hrtime.bigint() - startedAt) / 1_000_000;
if (durationMs >= 300) {
console.warn({
method,
url,
durationMs: Number(durationMs.toFixed(2)),
});
}
}Якщо endpoint працює повільно, розділіть його виконання на етапи:
отримання та перевірка вхідних даних;
виконання бізнес-логіки;
запити до бази даних;
виклики зовнішніх API;
серіалізація результату;
передавання відповіді клієнту.
Не слід одразу оптимізувати весь код. Спочатку визначте, який етап займає найбільше часу.
Незалежні асинхронні операції не потрібно виконувати послідовно:
const user = await this.usersService.findById(userId);
const orders = await this.ordersService.findByUserId(userId);Якщо другий запит не залежить від результату першого, їх можна виконати паралельно:
const [user, orders] = await Promise.all([
this.usersService.findById(userId),
this.ordersService.findByUserId(userId),
]);Це зменшує загальний час очікування приблизно до часу найдовшої операції, а не суми двох операцій.
Не запускайте необмежену кількість операцій паралельно. Наприклад, виконання сотень запитів до бази через Promise.all може вичерпати пул підключень. Для великих наборів потрібні пакетна обробка або обмеження паралелізму.
NestJS працює поверх Node.js. Тривалі синхронні операції блокують event loop і затримують обробку всіх інших запитів у цьому процесі.
До потенційно небезпечних операцій належать:
обробка великих масивів у синхронному циклі;
складні регулярні вирази;
синхронні методи файлової системи;
синхронне хешування великих даних;
обчислення, які займають десятки або сотні мілісекунд.
Наприклад, не слід виконувати синхронні операції з файлами в HTTP-обробнику:
// Погано: блокує event loop
const content = readFileSync(filePath, 'utf8');Для операцій введення-виведення використовуйте асинхронні API. Важкі CPU-обчислення варто винести в окремий worker або спеціальний фоновий процес.
Кожен middleware, guard, pipe та interceptor додає власний етап до обробки запиту. Це не означає, що від них потрібно відмовлятися. Потрібно лише уникати зайвої роботи.
Глобальний ValidationPipe захищає застосунок від некоректних даних, але налаштування transform: true створює додаткові об’єкти та перетворення.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);Таке налаштування часто виправдане:
whitelist видаляє властивості, яких немає в DTO;
forbidNonWhitelisted відхиляє зайві властивості;
transform перетворює вхідні значення відповідно до типів DTO.
Для високонавантажених endpoint варто перевірити вимірюваннями, чи справді потрібне автоматичне перетворення всіх параметрів. Але не вимикайте валідацію лише заради незначного виграшу, якщо це погіршує безпеку API.
Глобальний interceptor запускається для кожного запиту. Якщо він:
серіалізує великі об’єкти;
робить запит до бази даних;
виконує складне логування;
копіює великі структури;
то його вплив буде помітний у всьому застосунку.
Підключайте важкі interceptor лише до потрібних контролерів або маршрутів:
@UseInterceptors(SomeExpensiveInterceptor)
@Controller('reports')
export class ReportsController {}Після виконання контролера NestJS має перетворити результат у HTTP-відповідь. Для JSON це зазвичай означає серіалізацію об’єкта та передавання всіх його полів клієнту.
Проблеми виникають, коли:
повертається дуже великий масив;
об’єкт містить непотрібні поля;
результат проходить через class-transformer;
кожен елемент масиву перетворюється окремо;
у відповідь випадково потрапляють службові або конфіденційні дані.
Не повертайте з контролера сутність бази даних безпосередньо. Створіть DTO відповіді з потрібними полями:
export class UserResponseDto {
id: string;
name: string;
email: string;
}@Get(':id')
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
const user = await this.usersService.findById(id);
return {
id: user.id,
name: user.name,
email: user.email,
};
}Це має кілька переваг:
менший розмір відповіді;
менше роботи під час серіалізації;
відсутність випадкової передачі внутрішніх полів;
стабільний контракт API.
Особливо важливо не повертати поля на кшталт паролів, токенів, внутрішніх ідентифікаторів або службових метаданих.
ClassSerializerInterceptorЯкщо застосунок використовує class-transformer, поля можна явно позначити для включення або виключення:
import { Exclude, Expose } from 'class-transformer';
export class UserResponseDto {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
email: string;
@Exclude()
passwordHash: string;
}Interceptor підключається на рівні контролера:
import {
ClassSerializerInterceptor,
Controller,
Get,
UseInterceptors,
} from '@nestjs/common';
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
@Get(':id')
findOne(): UserResponseDto {
return Object.assign(new UserResponseDto(), {
id: 'user-1',
name: 'Олена',
email: 'olena@example.com',
passwordHash: 'не передається у відповідь',
});
}
}Для великих колекцій class-transformer може бути помітно повільнішим, ніж повернення вже підготовлених plain-об’єктів. Тому:
не використовуйте серіалізацію класів без потреби;
не створюйте складні getter, які виконують обчислення для кожного елемента;
не повертайте тисячі об’єктів одним запитом;
вимірюйте час серіалізації окремо від часу запиту до бази.
Повернення всього набору даних — одна з найчастіших причин повільних відповідей і високого використання пам’яті.
Замість цього використовуйте обмеження та пагінацію:
@Get()
findMany(
@Query('page') page = 1,
@Query('limit') limit = 20,
) {
const safePage = Math.max(Number(page) || 1, 1);
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
return this.usersService.findPage(safePage, safeLimit);
}Обмеження максимальної кількості елементів захищає endpoint від запиту на мільйони записів.
Пагінований формат відповіді може мати такий вигляд:
{
"items": [
{
"id": "user-1",
"name": "Олена"
}
],
"page": 1,
"limit": 20,
"total": 235
}Якщо підрахунок total є дорогим для конкретного сховища, його не обов’язково виконувати для кожного запиту. Це потрібно вирішувати на основі вимірювань і вимог клієнта.
NestJS підтримує Express і Fastify. Fastify часто показує вищу пропускну здатність у простих HTTP-сценаріях завдяки власній реалізації HTTP-обробки.
Перехід на Fastify не усуває повільні запити до бази даних, блокування event loop або надто великі відповіді. Це лише змінює HTTP-рівень, тому результат потрібно перевіряти навантажувальним тестом.
Приклад запуску NestJS із Fastify:
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000, '0.0.0.0');
}
void bootstrap();Під час переходу потрібно перевірити middleware та сторонні пакети. Деякі з них розраховані саме на Express і не працюють з Fastify без адаптації.
Не завантажуйте весь великий файл або набір записів у пам’ять, якщо його можна обробляти частинами. Також не створюйте непотрібні копії великих масивів через map, filter і sort, якщо endpoint уже працює на межі доступної пам’яті.
Підключення до бази даних або зовнішнього сервісу повинні створюватися через керований пул, а не для кожного HTTP-запиту окремо.
Небажаний підхід:
async findUser(id: string) {
// Погано: нове підключення створюється для кожного запиту
const connection = await createDatabaseConnection();
try {
return await connection.query('SELECT * FROM users WHERE id = $1', [id]);
} finally {
await connection.close();
}
}Підключення потрібно ініціалізувати під час запуску застосунку та повторно використовувати через сервіс або ORM. Розмір пулу має відповідати:
кількості екземплярів застосунку;
можливостям бази даних;
кількості одночасних запитів;
часу виконання операцій.
Надто малий пул створює чергу очікування. Надто великий може перевантажити базу даних.
Для зовнішніх сервісів і бази даних налаштовуйте тайм-аути. Запит без тайм-ауту може утримувати ресурси невизначено довго та накопичувати незавершені операції.
Під час обробки помилки звільняйте тимчасові ресурси та не залишайте відкриті з’єднання.
Кешування корисне для даних, які:
часто читаються;
рідко змінюються;
однакові для багатьох клієнтів;
дорого обчислюються або завантажуються.
Не кешуйте бездумно дані, пов’язані з правами доступу користувача. Помилковий ключ кешу може призвести до того, що один користувач отримає відповідь іншого.
Перед впровадженням кешу визначте:
ключ кешу;
час життя запису;
момент інвалідації;
поведінку при промаху кешу;
максимальний обсяг кешу.
Кеш не повинен маскувати проблему з неефективним запитом до бази даних. Спочатку оптимізуйте коректність і форму запиту, а потім вимірюйте користь кешування.
Ручний запит з браузера не є навантажувальним тестом. Для перевірки змін використовуйте сценарій, який виконує багато запитів із контрольованою кількістю одночасних клієнтів.
Перевіряйте щонайменше:
endpoint із типовою відповіддю;
endpoint із великою відповіддю;
помилкові запити;
одночасні запити до ресурсів із пулом підключень;
поведінку під час повільної зовнішньої залежності.
Порівнюйте результати до та після змін за однакових умов. Важливо перевіряти не лише пропускну здатність, а й p95, p99, кількість помилок та використання пам’яті.
У цьому прикладі:
встановлюється верхня межа limit;
незалежні операції виконуються паралельно;
відповідь містить лише потрібні поля;
не повертається необмежена колекція.
import {
Controller,
Get,
Injectable,
Module,
Param,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
type UserRecord = {
id: string;
name: string;
email: string;
passwordHash: string;
};
type UserResponse = {
id: string;
name: string;
email: string;
};
@Injectable()
class UsersService {
private readonly users: UserRecord[] = [
{
id: '1',
name: 'Олена',
email: 'olena@example.com',
passwordHash: 'internal-value',
},
{
id: '2',
name: 'Андрій',
email: 'andrii@example.com',
passwordHash: 'internal-value',
},
];
async findPage(
page: number,
limit: number,
): Promise<{ items: UserResponse[]; page: number; limit: number }> {
const start = (page - 1) * limit;
const items = this.users.slice(start, start + limit).map((user) => ({
id: user.id,
name: user.name,
email: user.email,
}));
return { items, page, limit };
}
async findStatistics(): Promise<{ total: number }> {
return { total: this.users.length };
}
}
@Controller('users')
class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findMany(
@Query('page', new ParseIntPipe({ optional: true })) page = 1,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
) {
const safePage = Math.max(page, 1);
const safeLimit = Math.min(Math.max(limit, 1), 100);
const [result, statistics] = await Promise.all([
this.usersService.findPage(safePage, safeLimit),
this.usersService.findStatistics(),
]);
return {
...result,
total: statistics.total,
};
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserResponse> {
const user = this.usersService['users'].find((item) => item.id === id);
if (!user) {
throw new Error('Користувача не знайдено');
}
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
@Module({
controllers: [UsersController],
providers: [UsersService],
})
class AppModule {}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
void bootstrap();У реальному застосунку сервіс не повинен надавати контролеру доступ до приватного масиву через індексацію. У прикладі це зроблено лише для компактності. Дані мають надходити через метод сервісу або репозиторію, а поля відповіді — формуватися на рівні DTO чи запиту до сховища.
Заміна Express на Fastify або вимкнення interceptor не гарантує помітного результату. Спочатку потрібно знати, де саме витрачається час.
Сутність може містити зайві або конфіденційні поля. Крім ризику витоку даних, це збільшує розмір відповіді та вартість серіалізації.
Endpoint, який повертає всі записи, рано чи пізно зіткнеться з проблемами пам’яті, мережі та часу серіалізації.
Promise.allПаралельний запуск сотень операцій може перевантажити пул підключень і зовнішні API. Паралелізм потрібно обмежувати.
Синхронне читання файлів, складні обчислення та великі цикли блокують event loop і погіршують час відповіді інших маршрутів.
Валідація є частиною контракту та безпеки API. Вимикайте її лише після вимірювань і лише для чітко обґрунтованого сценарію.
Запис великої кількості даних у лог, особливо синхронно, збільшує використання CPU, пам’яті та диска. Логуйте необхідні події та повільні запити.
Для оптимізації NestJS:
спочатку вимірюйте час відповіді, p95, p99, CPU та пам’ять;
визначайте, чи вузьке місце знаходиться в коді, базі даних, серіалізації або зовнішньому сервісі;
виконуйте незалежні асинхронні операції паралельно, але контролюйте їх кількість;
не блокуйте event loop синхронними та важкими CPU-операціями;
повертайте DTO з необхідними полями;
використовуйте пагінацію та обмеження розміру відповіді;
контролюйте витрати ClassSerializerInterceptor;
повторно використовуйте підключення через пул;
налаштовуйте тайм-аути;
перевіряйте зміни навантажувальними тестами, а не лише одиничними запитами.