Витискаємо максимум перформансу з NestJS

Привіт! Мене звуть Павло, я — Senior Software Engineer в компанії Yalantis. За моїми плечима понад чотири роки досвіду роботи з Node.js, а також близько року досвіду на Rust.

Протягом карʼєри мене завжди цікавило використання останніх найновіших і найкрутіших технологій для досягнення максимальної якості продукту. Також мене дуже цікавило, як отримати максимальну швидкодійність і ефективність Node.js-застосунків, які я розробляв.

Підвищення продуктивності не лише забезпечує економію коштів на утримання інфраструктури, але й робить систему більш готовою до викликів майбутнього. Ця стаття націлена на тих, хто вже працював з NestJS і добре знає його основи — у ній я розкрию прості, але досить потужні стратегії, які допоможуть вашому NestJS-бекенд-застосунку стати не лише швидшим, але й ефективнішим, забезпечуючи менші витрати на інфраструктуру.

Дисклеймер: поради, описані у цій статті, стосуються саме оптимізації Node.js-застосунку з погляду системного програмування, а не інфраструктури, бізнес-логіки, баз даних та інших даунстрім-сервісів. Найбільш влучними дані поради будуть у випадку, якщо ботлнеком швидкості вашої системи є саме бекенд-застосунок.

Спосіб оцінювання

Я створив репозиторій з простим NestJS-проєктом, який ми будемо крок за кроком оптимізувати. На кожен з кроків буде створено окремий бранч і після кожного кроку будемо вимірювати швидкодію за допомогою CLI-утиліти wrk.

Для того, щоб зробити наш бенчмарк більше наближеним до реального світу, ми не будемо «дудосити» порожній проєкт з ендпоінтом, який просто віддає нам 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, нумо дружити 🙃

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось20
До обраногоВ обраному12
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Queue це більше для випадку коли задачу можна вирішити асинхронним способом, тобто покласти її вирішення в бекграунд, або розбити задачу на менші, щоб велика задача не блокувала івент луп.
Пул воркерів це специфічна штука, яка спрацює лиш в залежності від ситуації, для того щоб взагалі розглядати цей варіант, треба щоб на машині на якій задеплоєний бекенд застосунок було достатньо ядер) з мого досвіду, пул воркерів може бути помічним якщо у нас є CPU intensive задача, у якої не великий input та output, бо коли ми передаємо дані між воркерами, там теж відбувається сереалізація/десереалізація, тому наприклад 0 сенсу використовувати воркери, щоб зробити в них JSON.parse() великого JSON-у

json.parse буде блокувати івент луп (як і всі інші операції), а якщо розкинути обробку ріквестів користувача на пул воркерів через раунд робін/інші алгоритми має підвищити обробку запитів якщо в тебе >1 одночасних користувачів на сервері. На фронті такого робити не потрібно бо там завжди йде обслуговування одного користувача

це не спрацює від слова взагалі, у вас перформанс зʼїсть серіалізація і десереалізація даних для передачі даних всередину воркер треду і назад, ви лиш понизите перформанс серверу таким рішенням, можете проексперементувати

Існує дві можливості передавати дані у воркер без серіалізації/десеріалізації: через типізовані масиви та через портирування об’єктів з одного хіпа v8 в інший, це також частина API тредів.
Реальна причина проблем може полягати в тому, що всі треди використовують одну на всіх машинерію libuv, а там 4 треди для I/O, тому на практиці потрібно розуміти, що якщо багато обчислень і мало I/O, то працює нормально, а якщо багато I/O, то потрібно ще додавати тредів libuv.
У випадку сервера кожен тред може незалежно відкрити порт і приймати запити, щоб навіть хендли сокетів не передавати між тредами, і це все одно буде ефективніше, ніж процеси, у більшості випадків, тому що вони швидше стартують, мають частину спільної машинерії, яка один раз завантажується при старті, і все це менше споживає пам’яті.

Мені було б цікаво подивитися, якби Ви створили репозиторій і показали на практиці як це реально допомагає в покращенні перформансу HTTP серверу.
На рахунок передачі ArrayBuffer, не забувайте, що перед передачею даних, Вам треба ці дані в ArrayBuffer спершу перетворити. Якщо про SharedArrayBuffer, то він власне shared, і треба буде з рейс кондішинами боротися. На рахунок портування обʼєктів з одного хіпу V8 у інший, раніше не чув про таке, можете, будь ласка, дати посилання?
Щодо однієї машинерії libuv на всі воркер треди, на скільки мені відомо, там по інстансу libuv на кожен тред, ну і власне по 4 цих потоки, чи я помиляюсь? Можете, будь ласка, дати посилання щодо цього.
На рахунок підіймання окремих серверів на воркер тредах, як Ви пропонуєте балансити навантаження між цими сереверами? Ще одним воркер тредом?

again, you said it won’t work xD read node.js api first

ну то нафіга вам було розпочинати цей тред, коли ви не можете довести свої слова?

У якості цікавого факту, uWebSockets і справді може робити те, про що це тред: розподіляти реквести по воркерам, а не по процессам. Ось приклад коду: github.com/...​examples/WorkerThreads.js

uWebSockets це вміє тільки на Linux, а стандартний node.js так не вміє взагалі.

Я бенчмаркав це локально, і приблизно на 10% мій хело ворлд став ефективніше з воркерами, ніж на кластері.

Скільки цій бібліотеці вже років, про неї ніхто не знає, але от у цієї статті згадується, сьогодні відос теж згаданого у статті Primeagen про відос Bun vs Go де Bun по суті на цієїж бібліотеці виїзджає, нещодавно один наш співвітчизник створив express-compatible фреймворк на uWS: github.com/...​dimdenGD/ultimate-express, так що популяризується потихеньку.

Так, знаю що uWebSockets.js вміє робити кластер на воркер тредах, до речі тепер він це вміє робити і на MacOS, не впевнений щодо Windows. Штука то прикольна з однієї сторони, а з іншої у нас та сама проблема що з node:cluster чи PM2, що у нас скейлинг всередині контейнеру, а не поза ним і те що у нас один тред який відповідає за лоад балансинг буде навантажений більше за інші треди.

Так, про цю бібліотеку мало хто знає, але знаючі люди можуть інтегрувати у свої проєкти, показати на скільки бекенд апка може краще працювати і часто отримати премію за це) на жаль я не серед тих щасливчиків, але чув такі історії) Так чув що в Bun вона використовується під капотом, але як на мене в Node.js від неї толку більше, бо в Ноди більша екосистема.

Так, бачив ultimate-express, крута штука дійсно беквард компатібл з Express, навідміну від hyper-express, можливо вона дійсно зробить Express Great Again

Дуже гарна стаття!

Здивувало наскільки pino прожерливий, а він точно налаштований запускатися у окремому worker’і? У рідмі про це є, що потрібно у worker чи у окремому процесі його запускати: github.com/...​ransports—log-processing

Ще було б дуже цікаво дізнатись наскільки велика різниця стане якщо Nest прибрати. Не як привід викидувати Nest, лише щоб розуміти скільки він забирає.

Про uWebSockets не зрозуміло чому цей пост стільки відрази викликає? Бо майже кожного разу коли uWebSockets, то хтось аттачить цей пост. Якщо вас та вашу роботу, у яку ви вклали багато років, публічно якісь Manuel буде у чомусь звинувачувати та оббрехувати, то напевно ви так само спробуєте детально розібрати події щоб прояснити це для своїх користувачів. Інше діло, як саме комунікувати, що писати, а що не писати. І я от не можу збагнути чому бібліотека, яку хтось підтримує близько десятиліття і нічого у вас за це не просить, та яка показує найкращі результати, виявляється прокаженою, бо автору не вистачило софт-скілз для написання статті колись там давно.

Дякую!

Логування насправді досить прожерлива штука, якщо багато логів, воно сповілюнює навіть бекенди написані на Rust, що вже говорити про Node.js) Я увімкнув

pino.destination({ sync: false })

це наче те що потрібно, якщо не помиляюся, по посиланню на яке Ви надіслали говориться про саме обробку логів (запис у файл, надсилання на агрегатори, pino-pretty і т.д.) а не саме логування stdout в worker-thread

На рахунок чистого Fastify, то тут вже треба сідати пробувати писати)

По uWebSockets, можливо Ви і праві, швидше ризиків нема, ніж є, але всеодно трошки стрьомно після того як він публікував порожні релізи в npm, але так, бібліотека дійсно класна)

дякую, чудова стаття. 2 роки тому розробляв мікросервіс на NestJS і з тих часів дуже люблю цей фреймворк

Дякую, радий що Вам сподобалася стаття) так, я теж дуже люблю NestJS)

nest & cache manager & redis только в качестве кеша и годятся. библиотеки, что полноценно раскрывает всю силу редиса для неста хрен найдешь

В пана є чудова можливість написати про це статтю!

Не розумію про що ви говорите, в запропонованій мною версії JsonCacheInterceptor використовується голий ioredis клієнт без cache-manager, до того ж ioredis дає можливість відправляти чисті команди в Redis, розкриваючи всю його силу, чи чого ви очікували?

Nest redis lib, де не тiлькi set & get, як у cache-manager, а повний список команд з TS пiдтримкою. ioredis без типiв, та не у системi nest libs

Напевно більша частина екосистеми Node.js не у ’системі nest libs’ і не біда) Вам нічого не заважає створити NestJS провайдер, можливо завернути його ще у модуль і додавати в NestJS DI все що завгодно) Не подобається cache-manager — інжектіть інстанс ioredis напряму і у вас буде доступ до всіх команд Redis.

Чому це ioredis без типів? Він на typescript написаний. До речі, якщо Ви зайдете в source code nest-redis, про який згадуєте, то якраз побачите, що це дуже тонка обгортка над ioredis, і типи всі беруться напряму з ioredis 🙃

Непогано. Але наскільки знаю, багато npm бібліотек не підтримує fastify. Чи це міф?

Звісно що бібліотеки призначені виключно для Express.js не сумісні з Fastify, але у 2-го теж велика екосистема і з мого досвіду завжди вдавалося знайти потрібний аналог

У підсумку постер із «The name is I_dont_trust_in_benchmarking...eagen», який багато разів казав, що не довіряє сферичним бенчмаркам у вакуумі)

Як Ви могли бачити зі статті, бенчмарк тут досить сильно приближений до реальності з даунстрім сервісом і логуванням, а не просто дудос health ендпоінту в голому застосунку) Також є і приклади з реального досвіду на скільки зростає перформанс від деяких з кроків, тому не розумію де Ви тут побачили сферичний вакуум)

Підписатись на коментарі