Витискаємо максимум перформансу з 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, нумо дружити 🙃
23 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів