Node.js & NestJS
JavaScript սերվերում՝ ճարտարապետությամբ, որը մասշտաբվում է մինչև մեծ թիմեր։
30 վայրկյանում
Node.js-ը JavaScript-ն աշխատեցնում է սերվերում՝ օգտագործելով մեկ հիմնական թրեդ և event loop, ինչի շնորհիվ այն շատ լավ է կարողանում մշակել միաժամանակյա բազմաթիվ ցանցային հարցումներ։ NestJS-ը TypeScript ֆրեյմվորք է Node-ի վրա, որն ավելացնում է մոդուլներ, dependency injection, guard-եր և pipe-եր, որպեսզի մեծ բեքենդները մնան կազմակերպված։ Prisma-ն type-safe գործիքակազմ է տվյալների բազայի հետ աշխատելու համար, որը հաճախ օգտագործվում է երկուսի հետ էլ։
Ինչու եմ օգտագործում
- Node-ն օգտագործում եմ, երբ ամբողջ պրոդուկտը TypeScript է, որպեսզի տիպերն ու վալիդացիան կիսվեն API-ի, վեբի, desktop-ի և մոբայլի միջև։
- NestJS-ն օգտագործում եմ, որովհետև մոդուլներն ու dependency injection-ը մեծ multi-tenant բեքենդը կազմակերպված են պահում տասնյակ դոմեյններում։
- Node-ն օգտագործում եմ realtime և I/O-ով ծանրաբեռնված աշխատանքների համար (սոքեթներ, հերթեր, webhook-ներ), որտեղ նրա non-blocking մոդելը փայլում է։
- Նույն runtime-ն օգտագործում եմ Electron-ի ներսում, որպեսզի desktop դրամարկղը կարողանա կոդ կիսել սերվերի հետ։
Ինչպես է աշխատում
01Event loop-ը պարզ բառերով
Պատկերացրեք մի մատուցողի, որը մենակ սպասարկում է բազմաթիվ սեղաններ։ Նա ընդունում է պատվերը, փոխանցում խոհանոց և անմիջապես անցնում հաջորդ սեղանին՝ կանգնած սպասելու փոխարեն։ Երբ ուտեստը պատրաստ է, խոհանոցը կանչում է նրան։ Node-ն աշխատում է նույն կերպ։ Ձեր JavaScript-ն աշխատում է մեկ հիմնական թրեդի վրա։ Երբ ֆայլ եք կարդում, հարցում եք անում բազային կամ կանչում եք API, Node-ն աշխատանքը փոխանցում է օպերացիոն համակարգին կամ փոքր thread pool-ին (libuv) և շարունակում է կատարել այլ կոդ։ Երբ արդյունքը պատրաստ է, callback-ը կամ resolve եղած Promise-ը դրվում է հերթի մեջ, և event loop-ը կատարում է այն։ Սա non-blocking I/O է։ Թակարդը սա է․ եթե ձեր սեփական կոդը ծանր CPU աշխատանք է անում, օրինակ՝ նկարների չափափոխում կամ հսկայական JSON-ի parse, մատուցողը խրվում է խոհանոցում, և մնացած բոլոր հարցումները սպասում են։
- Փուլեր՝ timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks։
- Microtask-ները (Promise callback-ներ, queueMicrotask) կատարվում են յուրաքանչյուր քայլի միջև՝ հաջորդ macrotask-ից առաջ։
- Կանոն՝ երբեք մի՛ արգելափակեք loop-ը․ CPU աշխատանքը տեղափոխեք worker thread-եր կամ հերթ։
02Երբ է Node-ը տեղին, և տարբերակները 2026-ի վերջին
Node-ը հիանալի ընտրություն է API-ների համար, որոնք հիմնականում սպասում են բազաներին և այլ ծառայություններին, realtime ֆունկցիաների համար՝ բազմաթիվ բաց կապերով, BFF-երի (backend-for-frontend շերտեր), webhook-ների, CLI-ների և գործիքների համար։ Այն ավելի թույլ ընտրություն է CPU-ով ծանր հաշվարկների համար, որտեղ Go-ն, Rust-ը կամ առանձին worker ծառայությունն ավելի լավ են աշխատում։ Պրոդակշնի համար օգտագործեք LTS թողարկում․ 2026-ի նոյեմբերի դրությամբ Node 24-ը maintenance LTS-ում է մինչև 2028-ի ապրիլ, Node 22-ի աջակցությունն ավարտվում է 2027-ի ապրիլին, իսկ Node 26-ը նոր է մտել LTS։ Ժամանակակից Node-ն ունի ներկառուցված fetch, test runner (node:test), watch ռեժիմ և պարզ դեպքերի համար TypeScript տիպերի native հեռացում (type stripping), այնպես որ ավելի քիչ լրացուցիչ փաթեթներ են պետք, քան մի քանի տարի առաջ։
03Express vs NestJS
Express-ը մինիմալ է՝ router և middleware, ուրիշ ոչինչ։ Դա կատարյալ է փոքր ծառայության համար, բայց մեծ կոդային բազայում յուրաքանչյուր թիմ հորինում է իր սեփական կառուցվածքը։ NestJS-ը տալիս է այդ կառուցվածքը։ Մոդուլը խմբավորում է մեկ դոմեյն (պատվերներ, մենյու, լոյալություն) իր controller-ներով և provider-ներով։ Dependency injection-ը նշանակում է, որ կլասը իր կոնստրուկտորում հայտարարում է, թե ինչ է իրեն պետք, և Nest-ը կառուցում է այն, ինչի շնորհիվ թեստերում իրական ծառայությունը fake-ով փոխարինելը դառնում է չափազանց հեշտ։ Յուրաքանչյուր հարցման շուրջ Nest-ն աշխատեցնում է ֆիքսված pipeline․ middleware, հետո guard-եր (կարո՞ղ է այս օգտատերը սա կանչել), հետո interceptor-ներ (լոգավորում, ժամանակաչափում, պատասխանի ձևափոխում), հետո pipe-եր (մուտքի վալիդացիա և ձևափոխում), հետո ձեր handler-ը, հետո exception filter-ներ, որոնք սխալները վերածում են մաքուր HTTP պատասխանների։ NestJS-ը լռելյայն աշխատում է Express-ի վրա, իսկ ավելի մեծ թողունակության համար՝ Fastify-ի վրա։
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
@Roles('cashier', 'manager')
create(@CurrentUser() user: AuthUser, @Body() dto: CreateOrderDto) {
return this.orders.create(user.companyId, dto);
}
}
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
04Prisma-ն որպես տվյալների շերտ
Prisma-ն թույլ է տալիս աղյուսակները նկարագրել schema ֆայլում և գեներացնում է լիովին տիպավորված client․ եթե սյունը nullable է, TypeScript-ը դա գիտի։ Միգրացիաները գեներացվում են schema-ի փոփոխություններից prisma migrate dev-ով և պրոդակշնում կիրառվում են prisma migrate deploy-ով։ Օգտագործեք select կամ include՝ միայն անհրաժեշտը բերելու համար, $transaction՝ բազմաքայլ գրառումների համար, և client extension-ներ՝ ընդհանուր կանոններ ավելացնելու համար, օրինակ՝ ստիպել, որ յուրաքանչյուր հարցում պարունակի tenant id։ Prisma 7-ից սկսած կապի կարգավորումները գտնվում են prisma.config.ts-ում, իսկ client-ը ստեղծվում է driver adapter-ով (օրինակ՝ @prisma/adapter-pg), այնպես որ հին պրոեկտը տեղափոխելուց առաջ ստուգեք upgrade guide-ը։ Հիշեք, որ Prisma-ն կախարդանք չէ․ դանդաղ հարցումների համար ստուգեք գեներացված SQL-ը և schema-ում ինդեքսներ ավելացրեք։
const orders = await prisma.order.findMany({
where: { companyId, status: 'PAID', createdAt: { gte: since } },
select: {
id: true,
total: true,
customer: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
05Worker thread-եր, child process-ներ և Electron
Երբ Node-ում պետք է CPU աշխատանք անել, տեղափոխեք այն հիմնական թրեդից դուրս։ Worker thread-երը (node:worker_threads) JavaScript-ն աշխատեցնում են զուգահեռ թրեդերում՝ իրենց սեփական event loop-ով, և հաղորդակցվում են հաղորդագրություններով․ օգտագործեք pool գրադարան՝ յուրաքանչյուր հարցման համար նոր թրեդ ստեղծելու փոխարեն։ Երկար կամ կրկնվող (retry) աշխատանքի համար սովորաբար ավելի լավ է հերթը՝ առանձին worker process-ներով (BullMQ), որովհետև այն դիմանում է վերագործարկումներին։ Node API-ն հորիզոնական մասշտաբեք՝ աշխատեցնելով մի քանի process (pm2 cluster ռեժիմ, կոնտեյներներ) load balancer-ի հետևում։ Electron-ը Node գումարած Chromium է մեկ desktop հավելվածում․ main process-ն ունի Node հասանելիություն, renderer պատուհանները ցույց են տալիս ձեր React UI-ը, իսկ preload script-ը նրանց միջև բացում է փոքր, անվտանգ API։ Renderer-ներում contextIsolation-ը պահեք միացված, իսկ nodeIntegration-ը՝ անջատված։
06Խորհուրդներ պրոդակշնի համար
Ամբողջ մուտքային տվյալները վալիդացրեք եզրին՝ DTO-ներով (class-validator) կամ zod-ով։ Սխալները մշակեք exception filter-ներով և երբեք թույլ մի՛ տվեք, որ reject եղած Promise-ը մնա չմշակված։ Լոգավորեք կառուցվածքային JSON-ով՝ request id-ով։ Իրականացրեք graceful shutdown․ SIGTERM ստանալիս դադարեցրեք նոր տրաֆիկի ընդունումը, ավարտեք ընթացիկ հարցումները, փակեք բազայի և Redis-ի կապերը։ Ավելացրեք health check-եր load balancer-ի համար։ Յուրաքանչյուր դուրս գնացող HTTP կանչի վրա timeout դրեք։ Կախվածությունները պահեք քիչ և ստուգված, Node-ի տարբերակը ֆիքսեք .nvmrc-ում կամ engines-ում, և Docker image-ներում build արեք մաքուր JavaScript-ի՝ պրոդակշնում TypeScript-ը dev գործիքով աշխատեցնելու փոխարեն։
- app.enableShutdownHooks() NestJS-ում, որպեսզի onModuleDestroy hook-երը կատարվեն։
- Հետևեք event-loop lag-ին որպես մետրիկայի․ աճող lag-ը նշանակում է, որ ինչ-որ բան արգելափակում է loop-ը։
Օգտագործեք, երբ
- Full-stack TypeScript պրոդուկտներ, որտեղ վեբի, մոբայլի և սերվերի միջև տիպեր կիսելը ժամանակ է խնայում։
- Realtime ֆունկցիաներ և I/O-ով ծանրաբեռնված API-ներ՝ բազմաթիվ միաժամանակյա կապերով։
- Մեծ բեքենդներ՝ բազմաթիվ դոմեյններով, որտեղ NestJS-ի մոդուլներն ու DI-ը թիմերին միասնական են պահում։
- Desktop հավելվածներ (Electron) կամ գործիքներ, որոնք պետք է կոդ կիսեն սերվերի հետ։
Մի՛ օգտագործեք, երբ
- CPU-ով սահմանափակված ծանրաբեռնվածություններ, ինչպես վիդեոյի կոդավորումը կամ ծանր հաշվարկները՝ որպես ծառայության հիմնական աշխատանք։
- Թիմ, որն արդեն արդյունավետ է Laravel-ով կամ Django-ով CRUD-ով ծանր պրոդուկտի վրա, որտեղ անցումը քիչ բան է տալիս։
- Շատ փոքր ծառայություններ, որտեղ NestJS-ի կառուցվածքը չափազանց է․ բավական է մաքուր Fastify-ն կամ Hono-ն։
Այլընտրանքներ
- Laravel
«Ամեն ինչ ներառված» PHP․ ավելի արագ է դասական CRUD և ադմինով ծանր պրոդուկտների համար։
- Fastify / Hono
Թեթև Node ֆրեյմվորքներ, երբ ուզում եք արագություն՝ առանց Nest-ի կառուցվածքի։
- Go
Կոմպիլյացվող, հիանալի concurrency և CPU արտադրողականություն․ բիզնես լոգիկայի համար ավելի խոսուն է։
- Bun / Deno
Այլընտրանքային JavaScript runtime-ներ․ պրոդակշնում օգտագործելուց առաջ ստուգեք գրադարանների համատեղելիությունը։
Լավագույն պրակտիկաներ
- Երբեք մի՛ արգելափակեք event loop-ը․ CPU կամ դանդաղ աշխատանքը տարեք worker thread-եր կամ հերթեր։
- Օգտագործեք գլոբալ ValidationPipe՝ whitelist-ով, որպեսզի անհայտ դաշտերը հեռացվեն։
- Ամեն դոմեյնի համար պահեք մեկ Nest մոդուլ և export արեք միայն այն, ինչ պետք է այլ մոդուլներին։
- Ավտորիզացիան դրեք guard-երում, ոչ թե ցրված if-երում։
- Օգտագործեք Prisma select՝ ավելորդ տվյալներ չբերելու համար, և տրանզակցիաներ՝ բազմաքայլ գրառումների համար։
- Իրականացրեք graceful shutdown և health check-եր՝ նախքան live դուրս գալը։
- Աշխատեցրեք Node-ի LTS տարբերակ և պլանավորեք թարմացումները՝ նախքան աջակցության ավարտը։
Տարածված սխալներ
- Սինխրոն crypto, նկարների մշակում կամ մեծ ցիկլեր աշխատեցնել request handler-ների ներսում։
- Մոռանալ await-ը, որի հետևանքով սխալները դառնում են unhandled rejection-ներ, իսկ տվյալները՝ կիսատ գրված։
- Ցիկլիկ մոդուլային կախվածություններ NestJS-ում՝ ամենուր forwardRef-ով քողարկված։
- Յուրաքանչյուր հարցման համար նոր PrismaClient ստեղծել՝ սպառելով բազայի կապերը։
- Node API-ները բացել Electron-ի renderer պատուհանների համար՝ nodeIntegration-ը միացված։
Խորը վերլուծություն
07Արգելափակված event loop-ի և libuv thread pool-ի չափում
Չեք կարող ուղղել այն blocking-ը, որը չեք տեսնում։ Node-ը node:perf_hooks-ում ունի monitorEventLoopDelay․ այն չափում է, թե loop-ը որքան ուշ է գործարկում իր timer-ները, և աճող p99-ը նշանակում է, որ ինչ-որ synchronous կոդ զբաղեցնում է thread-ը։ Export արեք այն որպես metric՝ request-ի latency-ի կողքին։ Մեղավորին գտնելու համար load-ի տակ CPU profile գրանցեք node --cpu-prof-ով և .cpuprofile ֆայլը բացեք Chrome DevTools-ում․ ամենալայն գծերը ձեր տաք function-ներն են, հաճախ՝ JSON.parse հսկա payload-ների վրա, synchronous crypto կամ catastrophic backtracking ունեցող regex։ Երկրորդ, ավելի լուռ bottleneck-ը libuv-ի thread pool-ն է, որը default-ով ունի ընդամենը 4 thread։ File system կանչերը, dns.lookup-ը, zlib-ը, pbkdf2-ն ու scrypt-ը և շատ native hashing մոդուլներ կիսում են այն, ուստի 50 միաժամանակյա password hash-եր հերթ են կանգնում իրար հետևից։ Գործարկման ժամանակ UV_THREADPOOL_SIZE-ը բարձրացնելն օգնում է, բայց իրական լուծումը ծանր աշխատանքը worker pool տեղափոխելն է։
import { monitorEventLoopDelay } from 'node:perf_hooks';
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
setInterval(() => {
const p99ms = loop.percentile(99) / 1e6; // nanoseconds to milliseconds
metrics.gauge('event_loop_delay_p99_ms', p99ms);
if (p99ms > 200) logger.warn({ p99ms }, 'event loop is blocked');
loop.reset();
}, 10_000).unref();
08Request-ի context AsyncLocalStorage-ով
PHP-ում յուրաքանչյուր request իր սեփական process-ն է, ուստի «ընթացիկ user»-ը հեշտ է։ Node-ում հազարավոր request-ներ խառնվում են մեկ thread-ի վրա, ուստի global փոփոխականը դրանք կխառներ։ node:async_hooks-ի AsyncLocalStorage-ը լուծում է սա․ դուք context եք սկսում request-ի սկզբում, և դրանից կանչված յուրաքանչյուր function, await-ների, timer-ների և Promise chain-երի միջով, կարող է կարդալ նույն store-ը։ Այն իդեալական է յուրաքանչյուր log տողում request id-ի, Prisma extension-ի համար tenant id-ի կամ դուրս գնացող կանչերի trace id-ի համար։ NestJS-ում nestjs-cls package-ը կոկիկ փաթաթում է այն։ Երկու կանոն․ store-ը փոքր և հնարավորինս immutable պահեք և այն միշտ սկսեք middleware-ում, ոչ թե guard-ում, որպեսզի ներքևում ամեն ինչ ժառանգի այն։ Request-ից դուրս սկսված աշխատանքին, օրինակ՝ queue worker-ին, պետք է յուրաքանչյուր job-ի համար իր run() կանչը։
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
type Ctx = { requestId: string; companyId?: string };
export const requestContext = new AsyncLocalStorage<Ctx>();
// main.ts (Express adapter)
app.use((req, _res, next) => {
const requestId = req.headers['x-request-id']?.toString() ?? randomUUID();
requestContext.run({ requestId }, next);
});
// Anywhere deeper, even after several awaits
logger.info({ requestId: requestContext.getStore()?.requestId }, 'order created');
09NestJS-ի injection scope-երը և դրանց թաքնված արժեքը
Default-ով NestJS-ի յուրաքանչյուր provider singleton է՝ ստեղծվում է մեկ անգամ startup-ի ժամանակ և կիսվում, ինչը արագ է և սովորաբար ճիշտ։ Scope.REQUEST-ը յուրաքանչյուր request-ի համար նոր instance է ստեղծում, որպեսզի կարողանա inject անել request օբյեկտը։ Խնդիրն այն է, որ request scope-ը բարձրանում է վերև․ ցանկացած controller կամ provider, որը կախված է request-scoped provider-ից, նույնպես դառնում է request-scoped, ուստի մեկ անմեղ injection կարող է ստիպել ամբողջ dependency tree-ն վերակառուցվել յուրաքանչյուր կանչի ժամանակ՝ ավելացնելով latency և garbage collection-ի ճնշում։ Նախընտրեք singleton-ներ, որոնք context-ը ստանում են որպես method-ի argument կամ կարդում են այն AsyncLocalStorage-ից։ Եթե իսկապես պետք են tenant-ի համար առանձին instance-ներ, օրինակ՝ առանձին connection յուրաքանչյուր tenant-ի համար, NestJS-ն առաջարկում է durable provider-ներ ContextIdStrategy-ով, որը մեկ sub-tree է պահում յուրաքանչյուր tenant-ի, ոչ թե յուրաքանչյուր request-ի համար։ forRootAsync-ով և ConfigService-ով dynamic module-ները startup-ի ժամանակ կոնֆիգուրացվող singleton-ներ կառուցելու մաքուր ճանապարհն են։
10Հիշողություն, stream-ներ և database connection-ներ
Node-ի heap-ը սահմանափակ է, ուստի 500 MB export-ը array-ի մեջ բեռնելը process-ը կործանում է «JavaScript heap out of memory»-ով։ Stream-ները տվյալները մշակում են փոքր կտորներով, և stream.pipeline-ը դրանք միացնում է backpressure-ով․ եթե նպատակակետը դանդաղ է, աղբյուրը դադար է տալիս՝ ամեն ինչ buffer անելու փոխարեն։ Async generator-ները readable աղբյուրներ են, ուստի կարող եք database-ով էջ առ էջ անցնել և CSV տողերն ուղղակի գրել։ Արտահոսքերի համար process-ը սկսեք --heapsnapshot-signal=SIGUSR2-ով, մի քանի րոպե ընդմիջումով երկու snapshot վերցրեք և DevTools-ում համեմատեք, թե ինչն է աճել․ տիպիկ մեղավորներն են անսահմանափակ in-memory cache-երը, երբեք չհեռացված listener-ները և մեծ օբյեկտներ պահող closure-ները։ Connection-ները մյուս սահմանափակ ռեսուրսն են։ Յուրաքանչյուր process պետք է ստեղծի մեկ Prisma client․ Prisma 7-ի driver adapter-ների դեպքում pool-ի չափը սահմանվում է driver-ի վրա, օրինակ՝ pg Pool-ի max-ով։ Pool-ի չափը բազմապատկեք process-ների քանակով և ընդհանուրը պահեք database-ի սահմանից ցածր կամ առջևում դրեք PgBouncer։
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { createWriteStream } from 'node:fs';
async function* ordersAsCsv(companyId: string) {
yield 'id,total,created_at\n';
let cursor: string | undefined;
do {
const page = await prisma.order.findMany({
where: { companyId }, take: 1000, orderBy: { id: 'asc' },
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
for (const o of page) yield o.id + ',' + o.total + ',' + o.createdAt.toISOString() + '\n';
cursor = page.at(-1)?.id;
} while (cursor);
}
await pipeline(ordersAsCsv(companyId), createGzip(), createWriteStream('/tmp/orders.csv.gz'));
Իրական իրավիճակներ
Latency-ի թռիչքներ մի քանի րոպեն մեկ առանց ակնհայտ պատճառի
Խնդիր
API-ն սովորաբար պատասխանում է 40 ms-ում, բայց մի քանի րոպեն մեկ բոլոր request-ները միանգամից տևում են երկու-երեք վայրկյան։ CPU-ն միջինում նորմալ է թվում, և database-ը պարապ է։
Ինչպես լուծել
Երբ ամեն ինչ միասին է դանդաղում, դա ցույց է տալիս արգելափակված event loop, ոչ թե դանդաղ dependency։ Ավելացրեք event-loop delay metric-ը և թռիչքները համադրեք log-երի հետ․ հաճախ այդ պահին synchronous կերպով աշխատում է report endpoint, մեծ JSON export կամ bulk import։ Վերարտադրեք դա load-ի տակ և գրանցեք node --cpu-prof՝ տաք function-ը գտնելու համար։ Ապա այդ աշխատանքը հանեք request-ի thread-ից․ stream արեք export-ը, էջավորեք import-ը կամ CPU-ի մասը գործարկեք worker thread pool-ում, օրինակ՝ piscina-ում։ Եթե աշխատանքը երկար է կամ պետք է restart-ներից փրկվի, դրեք այն BullMQ queue-ի մեջ և վերադարձրեք job id, որը client-ը կարող է poll անել։
Scale out-ից հետո Postgres-ը հայտնում է too many connections
Խնդիր
Traffic-ը աճեց, ուստի թիմը 2 API container-ից անցավ 10-ի։ Հիմա պիկերի ժամանակ Postgres-ը մերժում է նոր connection-ները, և որոշ request-ներ ձախողվում են connection error-ներով։
Ինչպես լուծել
Հաշվեք․ container-ներ անգամ process-ներ յուրաքանչյուր container-ում անգամ pool-ի չափ։ Տասը container 20-անոց pool-ով արդեն պահանջում են 200 connection, գումարած worker-ներն ու migration-ները։ Նախ ստուգեք, որ յուրաքանչյուր process ստեղծում է ճիշտ մեկ Prisma client, ոչ թե մեկը յուրաքանչյուր request-ի կամ մոդուլի համար։ Ապա pool-ների չափը գիտակցաբար ընտրեք, հաճախ 5-ից 10 յուրաքանչյուր process-ի համար, և metric-ներով հաստատեք, որ request-ները pool-ին չեն սպասում։ Եթե դեռ ավելի շատ process-ներ են պետք, app-երի և Postgres-ի միջև դրեք PgBouncer transaction mode-ով, որպեսզի հազարավոր client connection-ներ կիսեն փոքր քանակով server connection-ներ, և ստուգեք ձեր ORM-ի ուղեցույցը pooler-ի հետևում աշխատելու վերաբերյալ։
Deploy-ները ամեն անգամ կորցնում են մի քանի request
Խնդիր
Յուրաքանչյուր rolling deploy load balancer-ում առաջացնում է 502 error-ների պոռթկում։ User-ները երբեմն տեսնում են ձախողված վճարման էկրան, թեև կոդում ոչինչ չի փոխվել։
Ինչպես լուծել
Հին process-ը սպանվում է, մինչ դեռ connection-ներ է պահում։ Ճիշտ մշակեք SIGTERM-ը․ NestJS-ում կանչեք app.enableShutdownHooks(), նախ readiness endpoint-ը ձախողեք, որպեսզի balancer-ը դադարի նոր traffic ուղարկել, սպասեք մի քանի վայրկյան, ապա թողեք HTTP server-ը փակվի և ավարտի ընթացիկ request-ները՝ նախքան Prisma-ն և Redis-ը փակելը։ Orchestrator-ին տվեք grace period, որն ավելի երկար է ձեր ամենադանդաղ request-ից։ Երկրորդ պատճառը keep-alive-ն է․ Node-ի default keepAliveTimeout-ը 5 վայրկյան է, ավելի կարճ, քան շատ load balancer-ների idle timeout-ը, ուստի balancer-ը վերօգտագործում է socket, որը Node-ը հենց նոր փակել է։ keepAliveTimeout-ը սահմանեք balancer-ի idle timeout-ից բարձր, իսկ headersTimeout-ը՝ դրանից մի փոքր բարձր։
Արագ ուղեցույց
- Գեներացնել ամբողջական CRUD մոդուլ NestJS-ում
npx nest g resource orders- Գրանցել CPU profile
node --cpu-prof dist/main.js- Heap snapshot-ներ վերցնել ըստ պահանջի
node --heapsnapshot-signal=SIGUSR2 dist/main.js # then: kill -USR2 <pid>- Բեռնել .env ֆայլ առանց package-ի
node --env-file=.env dist/main.js- Գործարկել ներկառուցված test runner-ը watch mode-ով
node --test --watch- libuv-ին ավելի շատ thread տալ
UV_THREADPOOL_SIZE=16 node dist/main.js- Կիրառել Prisma migration-ները production-ում
npx prisma migrate deploy- NestJS-ը փոխել Fastify-ի
NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())- Keep-alive՝ 60 վ load balancer-ից ավելի երկար
server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000;
Իմ իրական նախագծերում
- Համատեղ ղեկավարել եմ multi-tenant ռեստորանային POS SaaS՝ NestJS 11-ով, Prisma 6-ով և PostgreSQL-ով․ 83 դոմեյն մոդուլ, մոտ 936 route handler և 181 Prisma մոդել, կառուցված մոտ չորս ամսում՝ երկու հոգանոց հիմնական թիմով։
- Այնտեղ tenant-ների մեկուսացումը ապահովվում է երկու անգամ․ բացահայտ companyId յուրաքանչյուր հարցման մեջ, գումարած Prisma extension, որը սխալ է նետում, եթե scope-ը բացակայում է։
- Աշխատակազմի դրամարկղն աշխատում է որպես Electron desktop հավելված՝ auto-update-ով, իսկ առանձին worker process-ը մշակում է BullMQ հերթերը։
- Կոդային բազան ունի մոտ 270 թեստային ֆայլ՝ ներկառուցված node:test runner-ի վրա։
Հարցեր և պատասխաններ
Q1Node-ը single-threaded է։ Ինչպե՞ս է այն մշակում հազարավոր հարցումներ։
Մեկ թրեդի վրա աշխատում է միայն JavaScript-ը։ Ցանցային I/O-ն փոխանցվում է օպերացիոն համակարգի non-blocking մեխանիզմներին, իսկ որոշ աշխատանքներ, ինչպես ֆայլային հասանելիությունը, DNS-ը և crypto-ն, գնում են libuv-ի thread pool։ Քանի դեռ այդ գործողությունները ընթացքի մեջ են, event loop-ը շարունակում է ընդունել և մշակել այլ հարցումներ։ Երբ արդյունքները պատրաստ են, դրանց callback-ները կամ Promise-ի շարունակությունները դրվում են հերթի մեջ և կատարվում։ Այնպես որ բազային սպասող հազարավոր հարցումները շատ քիչ արժեն։ Սահմանափակումը CPU-ն է․ ցանկացած սինխրոն ծանր աշխատանք արգելափակում է բոլոր հարցումները։ Դրա համար օգտագործում ես worker thread-եր, հերթ կամ ավելի շատ process-ներ load balancer-ի հետևում։
Q2Ո՞րն է տարբերությունը process.nextTick-ի, Promise-ների և setImmediate-ի միջև։
Երեքն էլ աշխատանքը պլանավորում են ավելի ուշ, բայց տարբեր պահերի։ process.nextTick-ի callback-ները կատարվում են անմիջապես ընթացիկ գործողությունից հետո՝ մյուս microtask-ներից առաջ։ Promise-ի callback-ները microtask-ներ են․ դրանք կատարվում են, երբ ընթացիկ call stack-ը դատարկվում է, նախքան event loop-ը կանցնի առաջ։ setImmediate-ը macrotask է, որը կատարվում է check փուլում՝ ընթացիկ loop iteration-ի I/O polling-ից հետո։ setTimeout-ը 0-ով կատարվում է հաջորդ iteration-ներից մեկի timers փուլում։ Գործնականում՝ նախընտրեք Promise-ները, setImmediate-ն օգտագործեք երկար աշխատանքի ընթացքում I/O-ին տեղ տալու համար, և խուսափեք ռեկուրսիվ nextTick-ից, որովհետև այն կարող է «սովամահ» անել loop-ը։
Q3Ինչո՞ւ NestJS, և ոչ թե մաքուր Express։
Express-ը տալիս է routing և middleware, իսկ ճարտարապետությունը թողնում է քեզ։ Դա նորմալ է փոքր ծառայության համար, բայց մեծ կոդային բազայում յուրաքանչյուր ծրագրավորող կառուցվածքը տարբեր կերպ է կառուցում։ NestJS-ը դա ստանդարտացնում է․ մոդուլներ ըստ դոմեյնի, dependency injection՝ թեստավորելիության համար, guard-եր՝ ավտորիզացիայի համար, pipe-եր՝ վալիդացիայի համար, interceptor-ներ՝ ընդհանուր (cross-cutting) խնդիրների համար և exception filter-ներ՝ միասնական սխալների համար։ Նոր ծրագրավորողները գիտեն, թե ինչը որտեղ է դրվում։ Այն նաև մաքուր ինտեգրվում է OpenAPI-ի, հերթերի, websocket-ների և microservice տրանսպորտների հետ։ Գինը ավելի շատ boilerplate-ն է և decorator-ների ու DI-ի ուսուցման կորը, այնպես որ շատ փոքր ծառայության համար ես դեռ կընտրեի ավելի թեթև բան։
Q4Բացատրեք guard-երը, pipe-երը և interceptor-ները NestJS-ում։
Դրանք աշխատում են հարցման տարբեր փուլերում։ Guard-երը կատարվում են առաջինը՝ middleware-ից հետո, և պատասխանում են մեկ հարցի․ կարո՞ղ է այս հարցումը շարունակվել։ Դրանք օգտագործվում են authentication-ի ստուգումների և դերերի կամ թույլտվությունների ստուգումների համար, և վերադարձնում են true կամ սխալ են նետում։ Interceptor-ները փաթաթում են handler-ը, այնպես որ կարող են գործել նրանից առաջ և հետո․ լոգավորում, ժամանակաչափում, cache, պատասխանի ձևի փոփոխում կամ timeout ավելացնել։ Pipe-երը ձևափոխում և վալիդացնում են արգումենտները հենց նախքան handler-ը կստանա դրանք, օրինակ՝ ValidationPipe-ը JSON body-ն վերածում է վալիդացված DTO-ի, կամ ParseIntPipe-ը փոխարկում է route պարամետրը։ Այնուհետև exception filter-ները բռնում են սխալները և ձևավորում HTTP պատասխանը։
Q5Ե՞րբ կօգտագործեիք worker thread-եր։
Երբ Node ծառայությունը պետք է կատարի CPU-ով ծանր աշխատանք, որը կարգելափակեր event loop-ը․ նկարների կամ PDF-ների մշակում, սեղմում, մեծ ֆայլերի parse, ծանր հաշվարկներ։ Worker thread-երը JavaScript-ն աշխատեցնում են զուգահեռ՝ իրենց սեփական event loop-ով, և հաղորդակցվում են հաղորդագրություններով կամ shared memory-ով։ Ես օգտագործում եմ pool՝ ամեն հարցման համար նորը ստեղծելու փոխարեն, որովհետև թրեդեր ստեղծելն իր գինն ունի։ Եթե աշխատանքը երկարատև է, պետք է դիմանա վերագործարկումներին կամ retry-ի կարիք ունի, նախընտրում եմ BullMQ-ի նման հերթ՝ առանձին worker process-ներով։ Worker thread-երը չեն օգնում I/O-ի հարցում, որը Node-ն արդեն մշակում է առանց արգելափակման։
Q6Ինչպե՞ս եք կանխում tenant-ների միջև տվյալների արտահոսքը Node-ի և Prisma-ի բեքենդում։
Դարձրեք այնպես, որ tenant ֆիլտրը մոռանալը դժվար լինի։ Նախ՝ ծառայության յուրաքանչյուր մեթոդ tenant id-ն ստանում է բացահայտ՝ authenticated կոնտեքստից, երբեք request body-ից, և յուրաքանչյուր հարցում ներառում է այն։ Երկրորդ՝ ավելացրեք ապահովության ցանց․ Prisma client extension կամ middleware, որը ստուգում է tenant-ին պատկանող մոդելների հարցումները և սխալ է նետում, եթե tenant պայմանը բացակայում է, հիմնավորված բացառությունների կարճ allow-list-ով։ Այլ tenant-ի գրառումների համար վերադարձրեք 404, ոչ թե 403, որպեսզի id-ները չհաստատվեն։ Վերջապես, յուրաքանչյուր մոդուլի համար գրեք «սխալ tenant»-ի թեստ։ PostgreSQL-ում row-level security-ն լրացուցիչ շերտ է, որը արժե դիտարկել։
Սենիոր մակարդակի հարցեր
S1Ինչպե՞ս Node API-ն 1,000-ից կհասցնեիք 20,000 request վայրկյանում։
Նախ bottleneck-ը գտնում եմ load test-երով և profile-ներով՝ գուշակելու փոխարեն։ Node-ն ինքը լավ է կառավարում բարձր I/O concurrency-ն, ուստի սահմանները սովորաբար CPU-ն են յուրաքանչյուր process-ում, database-ը և արտաքին կանչերը։ Գործարկում եմ մեկ process յուրաքանչյուր core-ի համար՝ container-ներով կամ cluster mode-ով, load balancer-ի հետևում, process-ները պահելով stateless՝ session-ներով և cache-երով Redis-ում։ CPU-ի համար ծանր աշխատանքը տեղափոխում եմ worker pool-եր կամ queue-ներ և հետևում եմ event-loop delay-ին։ Տվյալների կողմից՝ index-ներ, ավելի քիչ round trip-եր, տաք կարդալների cache՝ հստակ invalidation-ով, read replica-ներ և pooled connection-ներ՝ չափված database-ի սահմանի համեմատ։ NestJS-ը Fastify adapter-ի փոխելը և JSON payload-ները կրճատելը չափելի շահ են տալիս։ Trade-off-ը արժեքն ու բարդությունն է, ուստի կանգ եմ առնում, երբ թիրախային p99 latency-ն հասնում է պաշարով։
S2Ինչպե՞ս եք tenant-ի և request-ի context-ը տանում NestJS app-ի միջով։
Երեք տարբերակ՝ հստակ trade-off-ներով։ Context-ը բացահայտ որպես argument փոխանցելը ամենաթափանցիկն ու test անելու համար ամենահարմարն է, և tenant id-ի համար service method-ներում ես միշտ այդպես եմ անում։ Request-scoped provider-ները կարող են inject անել request-ը, բայց scope-ը բարձրանում է dependency tree-ով և յուրաքանչյուր request-ի համար վերստեղծում շատ օբյեկտներ, ինչն արժե latency։ AsyncLocalStorage-ը, հաճախ nestjs-cls-ի միջոցով, յուրաքանչյուր request-ին տալիս է անուղղակի store, որը փրկվում է await-ների միջով, և դա հարմար է cross-cutting տվյալների համար՝ request id log-երի համար, trace id-ներ և ապահովության ցանցի Prisma extension, որը ստուգում է tenant filter-ը։ Ռիսկը թաքնված կապվածությունն է, ուստի սահմանափակում եմ, թե ինչ է մտնում store-ի մեջ, սահմանում եմ այն միայն middleware-ում և բիզնեսի համար կարևոր արժեքները դեռ բացահայտ եմ փոխանցում։ Queue job-երն իրենց context-ը սկսում են job-ի payload-ից։
S3Ինչպե՞ս եք գտնում և ուղղում memory leak-ը production Node service-ում։
Նախ հաստատում եմ այն․ հիշողություն, որը ժամերի ընթացքում կայուն աճում է և garbage collection-ից հետո չի իջնում, հաճախ ավարտվելով out-of-memory restart-ներով։ Յուրաքանչյուր process-ի օգտագործված heap-ը գրաֆիկ եմ անում traffic-ի կողքին՝ նորմալ load-ը բացառելու համար։ Ապա վերարտադրում եմ այն staging-ում նման traffic-ի տակ, process-ը սկսում եմ --heapsnapshot-signal-ով, մի քանի րոպե ընդմիջումով snapshot-ներ եմ վերցնում և DevTools-ի համեմատման տեսքով նայում, թե օբյեկտների որ տեսակներն են աճում և ինչն է դրանք պահում։ Տարածված պատճառներն են որպես cache օգտագործվող անսահմանափակ Map-երը, յուրաքանչյուր request-ի համար ավելացվող event listener-ները, երբեք չմաքրված timer-ները և երկար ապրող օբյեկտների պահած մեծ closure-ները։ Ուղղում եմ արմատային պատճառը, օրինակ՝ չափի սահմանով LRU։ Worker-ները պարբերաբար restart անելը ապահովության ցանց է, ոչ թե լուծում, քանի որ այն թաքցնում է նաև հաջորդ արտահոսքը։
S4Ե՞րբ կբաժանեիք NestJS monolith-ը microservice-ների։
Երբ մոդուլն իսկապես այլ կարիք ունի․ այն պետք է անկախ մասշտաբավորվի, deploy արվի իր սեփական գրաֆիկով, պատկանի առանձին թիմի կամ մեկուսացնի ռիսկը, օրինակ՝ վճարային ինտեգրացիան։ Այս պատճառներից մեկի բացակայության դեպքում microservice-ները ավելացնում են ցանցային ձախողումներ, distributed transaction-ներ, տարբերակավորված contract-ներ և ավելի շատ ենթակառուցվածք՝ առանց որևէ շահի։ NestJS-ը ճանապարհը հարթեցնում է, քանի որ մոդուլներն արդեն հստակ սահմաններ ունեն, և framework-ն ունի microservice transport-ներ Redis-ի, NATS-ի, RabbitMQ-ի, Kafka-ի և gRPC-ի համար։ Նախ մոդուլին ստիպում եմ մնացածի հետ շփվել միայն իր public service-ի և event-ների միջոցով, ապա այն տեղափոխում եմ transport-ի հետևում՝ դեռ մեկ repository-ում, և միայն դրանից հետո deploy եմ անում առանձին։ Եթե մոդուլը հնարավոր չէ մեկուսացնել նույն process-ի ներսում, այն որպես service նույնպես չի աշխատի։
Բառարան
- libuv
- Node-ի տակ գտնվող C գրադարանը, որն ապահովում է event loop-ը, non-blocking ցանցային I/O-ն և փոքր thread pool այլ աշխատանքի համար։
- Event-loop delay
- Թե loop-ը որքան ուշ է գործարկում պլանավորված callback-ները։ Բարձր delay-ը նշանակում է, որ synchronous կոդն արգելափակում է յուրաքանչյուր request։
- Microtask
- Callback, օրինակ՝ Promise-ի շարունակությունը, որը գործարկվում է ընթացիկ կոդի ավարտից անմիջապես հետո՝ timer-ներից կամ I/O-ից առաջ։
- Provider
- Ցանկացած class, որը NestJS-ը կարող է inject անել՝ service-ներ, repository-ներ, factory-ներ կամ արժեքներ, գրանցված մոդուլում։
- Injection scope
- Որքան է ապրում NestJS provider-ի instance-ը՝ default-ով singleton, կամ մեկ request-ի համար, կամ transient՝ յուրաքանչյուր injection-ի համար։
- Dynamic module
- NestJS մոդուլ, որը կոնֆիգուրացվում է import-ի պահին, օրինակ՝ forRoot() կամ forRootAsync(), և վերադարձնում option-ներից կառուցված provider-ներ։
- AsyncLocalStorage
- Node API, որը request-ի store-ը տանում է async կանչերի միջով՝ առանց այն որպես argument փոխանցելու։
- Backpressure
- Դանդաղ consumer-ը producer-ին ազդանշան է տալիս դադար տալու, որպեսզի տվյալներն անսահմանափակ buffer չարվեն հիշողության մեջ։
- Driver adapter
- Prisma 7-ում package, որը գեներացված client-ը միացնում է database driver-ին, օրինակ՝ pg-ին, և որին պատկանում է նաև pool-ը։
Շարունակեք սովորել
Պրոդուկտի գաղափար ունե՞ք։Եկեք ստեղծենք այն։
Պատմեք ձեր գաղափարի, պրոդուկտի կամ թիմի մասին։ Սովորաբար պատասխանում եմ մեկ աշխատանքային օրվա ընթացքում։