Витискаємо максимум перформансу з NestJS
Привіт! Мене звуть Павло, я — Senior Software Engineer в компанії Yalantis. За моїми плечима понад чотири роки досвіду роботи з Node.js, а також близько року досвіду на Rust.
Протягом карʼєри мене завжди цікавило використання останніх найновіших і найкрутіших технологій для досягнення максимальної якості продукту. Також мене дуже цікавило, як отримати максимальну швидкодійність і ефективність Node.js-застосунків, які я розробляв.
Підвищення продуктивності не лише забезпечує економію коштів на утримання інфраструктури, але й робить систему більш готовою до викликів майбутнього. Ця стаття націлена на тих, хто вже працював з NestJS і добре знає його основи — у ній я розкрию прості, але досить потужні стратегії, які допоможуть вашому NestJS-бекенд-застосунку стати не лише швидшим, але й ефективнішим, забезпечуючи менші витрати на інфраструктуру.
Дисклеймер: поради, описані у цій статті, стосуються саме оптимізації Node.js-застосунку з погляду системного програмування, а не інфраструктури, бізнес-логіки, баз даних та інших даунстрім-сервісів. Найбільш влучними дані поради будуть у випадку, якщо ботлнеком швидкості вашої системи є саме бекенд-застосунок.
Спосіб оцінювання
Я створив репозиторій з простим NestJS-проєктом, який ми будемо крок за кроком оптимізувати. На кожен з кроків буде створено окремий бранч і після кожного кроку будемо вимірювати швидкодію за допомогою
Для того, щоб зробити наш бенчмарк більше наближеним до реального світу, ми не будемо «дудосити» порожній проєкт з ендпоінтом, який просто віддає нам Hello World. Замість цього ми будемо тестувати ендпоінт, який повертає простий JSON з Redis, оскільки це є досить типовою ситуацією в Node.js-застосунках, де відповіді сервера кешуються, щоб збільшити швидкодію і не навантажувати постійно основну базу даних, наприклад PostgreSQL. Також до проєкту буде підключений логер.
Для тестування використовувався MacBook Pro 16″ 32 RAM/CPU M1 Pro з версією Node.js v20.12.2 і Redis v7.0.11 піднятий у Docker, команда для запуску бенчмарку: wrk -c200 -t8 -d20 http://127.0.0.1:3000/cached (вісім потоків по 200 віртуальних юзерів на кожному, протягом 20 секунд).
Початок: наш базовий NestJS-застосунок
Знаходиться він за посиланням, у бранчі base. В якості логера використано Pino, підключений він через nestjs-pino, його налаштовано таким чином, щоб логувати всі запити в JSON-форматі. Для кешування використовується NestJS Cache Manager у поєднанні з ioredis. Для уникнення бойлерплейту логер і кешування, загорнуті в окремі модулі LoggerModule та CacheModule відповідно. Ось так виглядає структура проєкту:
src ├── app.config.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── main.ts └── shared ├── modules │ ├── cache │ └── logger └── utils
Базовий результат бенчмарку:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 28.69ms 45.46ms 872.64ms 98.49% Req/Sec 1.03k 87.23 1.26k 78.44% 164978 requests in 20.06s, 98.49MB read Socket errors: connect 0, read 137, write 0, timeout 0 Requests/sec: 8225.70 Transfer/sec: 4.91MB
Що ж, перейдемо безпосередньо до наших покращень.
1. Перехід на NestJS/Fastify
Перша і найбільш очевидна оптимізація — це зміна платформи NestJS з Express.js на Fastify, що особисто я рекомендую робити у 100% випадків, коли ви розпочинаєте новий проєкт або якщо поточний проєкт не завʼязаний на інфраструктуру Express.js.
...
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter() // Setting the Fastify adapter
);
await app.listen(config.app.port, '0.0.0.0');
...
}
bootstrap();
Fastify має схожий API на Express.js, володіє тим самим набором функцій і також має велику екосистему, але працює набагато швидше ⚡️
Тож переглянемо повний оновлений код у нашому репозиторії. І перевіримо швидкість:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 9.39ms 10.96ms 328.89ms 99.07% Req/Sec 2.89k 247.86 3.36k 88.50% 461192 requests in 20.05s, 246.74MB read Socket errors: connect 0, read 153, write 0, timeout 0 Requests/sec: 23005.51 Transfer/sec: 12.31MB
Як бачимо, наш застосунок став швидшим майже ВТРИЧІ. Звісно, у реальному проєкті приріст буде меншим, наприклад на одному з моїх продакшн-проєктів, де було зроблено такі зміни, перформанс підвищився на ~40%, але це вже є чудовим результатом і воно точно того вартувало.
Також Fastify дозволяє додавати дані до обʼєктів request/response більш правильно за допомогою Fastify Decorators. Річ у тому, що у JS існують Hidden Classes. Якщо коротко, це спеціальний механізм оптимізації, що дозволяє швидше отримувати доступ до полів обʼєктів, і ця оптимізація руйнується, коли ми додаємо/видаляємо поля обʼєкта після того, як він був вже створений, що власне є частою практикою в Express.js Middlewares. Fastify своєю чергою за допомогою декораторів дає змогу ініціалізувати нове поле в request/response на етапі власне створення цих обʼєктів.
2. Покращення інтеграції з логером Pino
Pino сам по собі є одним з найшвидших логерів доступних в Node.js екосистемі, наприклад на одному з моїх проєктів, перехід з Winston на Pino збільшив перформанс застосунку ~40%.
Але у нашому випадку ми можемо зробити його ще швидшим. Річ у тім, що nestjs-pino, яким в основному користуються для інтеграції з даним логером, підключає Pino через fastify-midie — це плагін, що дозволяє використовувати Express.js Middlewares у Fastify (за замовчуванням у Fastify нема middlewares).
Це зроблено для того, щоб ця бібліотека була сумісна як з nestjs-platform-express, так і з nestjs-platform-fastify, але проблема в тому, що у випадку другого, це сповільнює логування запитів, але вирішення — є! І як ви могли здогадатися, це підключення Pino напряму до Fastify.
Для цього, щоб цього досягнути нам потрібно створити наш власний модуль-обгортку для Pino.
Перевірка швидкості:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 8.24ms 10.89ms 345.35ms 99.23% Req/Sec 3.32k 254.48 3.73k 91.25% 530009 requests in 20.04s, 283.56MB read Socket errors: connect 0, read 144, write 0, timeout 0 Requests/sec: 26145.34 Transfer/sec: 14.15MB
Як бачимо, ми отримали додаткові 3000 запитів на секунду. Звертаю вашу увагу, що цей модуль логування сумісний лише з nestjs-platform-fastify.
3. Покращення CacheInterceptor
Під капотом нативний CacheInterceptor використовує cache-manager, який десереалізує результат, отриманий з Redis за допомогою JSON.parse() (оскільки в Redis закешована відповідь зберігається у форматі рядка), а потім при формуванні відповіді сервера, обʼєкт, який ми тільки зпарсили знову перетворюється на строку, тож потрібно створити свій власний CacheInterceptor, який не буде робити зайві операції, а одразу буде брати строку з Redis і віддавати її як відповідь:
@Injectable()
export class JsonCacheInterceptor implements NestInterceptor {
...
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const key = this.getCacheKey(context);
const ttlValueOrFactory =
this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) ?? null;
if (!key) {
return next.handle();
}
try {
// fetching raw string from Redis without deserializing
const value = await this.ioRedisInstance.get(key);
if (!isNil(value)) {
const reply: FastifyReply | null = context.switchToHttp().getResponse();
if (reply) {
// Adding header with propper content type
reply.header('content-type', 'application/json; charset=utf-8');
}
// Return raw string as response
return of(value);
...
}
...
Повний код. А ось бенчмарк:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 7.37ms 6.54ms 230.02ms 98.73% Req/Sec 3.58k 354.80 4.04k 91.62% 571328 requests in 20.04s, 305.67MB read Socket errors: connect 0, read 118, write 0, timeout 0 Requests/sec: 27510.04 Transfer/sec: 15.25MB
В результаті ми отримуємо приріст на 1500 запитів на секунду. У випадку, якби закешований JSON був більшим і складнішим, приріст був би ще відчутнішим.
УВАГА: даний інтерсептор оптимізовано саме для закешованих відповідей у форматі JSON, для інших форматів він не підійде.
4. Увімкнення батчингу запитів у Redis
Мало хто знає, але в ioredis є хитрий параметр налаштування, який дозволяє обʼєднувати всі команди, що були запущені в одному тіку Node.js Event Loop і обʼєднувати це в один запит до Redis, що дозволяє досить сильно покращити продуктивність.
import IoRedis from 'ioredis';
const redisClient = new IoRedis({
host: config.redis.host,
port: config.redis.port,
enableAutoPipelining: true, // The magic parameter
});
Повний код. Результати:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 6.57ms 3.31ms 132.97ms 98.54% Req/Sec 3.89k 332.87 4.49k 70.69% 620856 requests in 20.05s, 332.16MB read Socket errors: connect 0, read 119, write 0, timeout 0 Requests/sec: 30960.53 Transfer/sec: 16.56MB
Бачимо приріст майже на 3500 запитів на секунду. Перед увімкненням цього налаштування в продакшені, раджу провести лоад-тестування або A/B-тестування, щоб переконатися, що все працює як слід, оскільки є деякі недоліки.
5. Інтеграція uWebSockets.js
uWebSockets.js — це альтернативна імплементація HTTP/WS-сервера для Node.js, яка дозволяє значно підвищити перформанс і навіть зменшити використання ресурсів. Для того, щоб її інтегрувати, використаємо @geut/fastify-uws, також є fastify-uws, що є фактично тим самим — це адаптери, які підганяють API uWS під API http.createServer().
...
async function bootstrap() {
const { serverFactory } = await import('@geut/fastify-uws');
const app = await NestFactory.create(
AppModule,
// Using uWebSockets.js version of http.createServer()
new FastifyAdapter({ serverFactory }),
{ bufferLogs: true }
);
...
}
void bootstrap();
Повний код. А ось результати:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 5.86ms 1.33ms 31.12ms 85.11% Req/Sec 4.29k 361.45 4.96k 83.94% 684611 requests in 20.05s, 346.03MB read Socket errors: connect 0, read 68, write 0, timeout 0 Requests/sec: 34141.80 Transfer/sec: 17.26MB
Ще на 3000 запитів в секунду більше. На цьому етапі логи досить сильно сповільнюють наш застосунок, оскільки ми логуємо кожен запит. Такий підхід зазвичай використовується на проєктах, де важливо збирати телеметрію з користувачів, але якщо цього не потрібно, то можна логувати лише помилки, у такому випадку:
Running 20s test @ http://127.0.0.1:3000/cached 8 threads and 200 connections Thread Stats Avg Stdev Max +/- Stdev Latency 4.60ms 1.33ms 36.93ms 85.53% Req/Sec 5.49k 378.11 7.27k 90.56% 874099 requests in 20.03s, 441.81MB read Socket errors: connect 0, read 65, write 0, timeout 0 Requests/sec: 43641.98 Transfer/sec: 22.06MB
Отримуємо приріст ще на 9500 (!) запитів на секунду.
Важливе застереження. Вказані бібліотеки є не популярними, також в минулому з uWebSockets.js були деякі проблеми, коли автор намагався публікувати порожні релізи в NPM, а потім взагалі повністю переїхав на GitHub. Згодом він публікував пояснення своїх дій, але токсичний характер цих пояснень не особливо додає довіри автору, тому перед тим, як використовувати цю бібліотеку в продакшені — краще зробити форк, щоб уникнути можливих проблем у майбутньому. Також слід перевірити, чи ця інтеграція нічого не зламала, оскільки API HTTP-сервера uWS відрізняється від нативного http.createServer і запропоновані бібліотеки можуть бути не досконалими адаптерами.
Підсумок
Як ми можемо побачити за результатом нашого бенчмарку, після досить простих змін, швидкість зросла більше, ніж в чотири рази!

Варто зважати, що це лише бенчмарк, на реальному проєкті покращення може бути не таке грандіозне, але воно буде і потенційно може зекономити багато коштів на інфраструктуру. Якщо швидкість є критичною для вашого застосунку і ви все ж вирішили рухатися з Node.js, тоді перехід з NestJS Platform Fastify на чистий Fastify дасть ще більший приріст.
Мій Linkedin, нумо дружити 🙃
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
29 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів