Kodėl Node.js serveriai reikalauja ypatingo dėmesio
Kai pirmą kartą susiduri su Node.js serverio konfigūravimu, gali atrodyti, kad viskas paprasta – npm install, node app.js ir viskas veikia. Bet realybė tokia, kad tarp „veikia mano kompiuteryje” ir „veikia produkcinėje aplinkoje su 10,000 vartotojų per minutę” yra milžiniškas skirtumas.
Node.js architektūra, paremta vienu gijų (single-threaded) įvykių ciklu, suteikia neįtikėtinų galimybių, bet kartu ir unikalių iššūkių. Skirtingai nei tradicinės serverių technologijos, kur kiekvienas užklausimas gauna atskirą giją ar procesą, Node.js dirba visiškai kitaip. Viena blogai parašyta funkcija gali užblokuoti visą serverį. Vienas neoptimalus užklausimas į duomenų bazę gali sulėtinti visus kitus vartotojus.
Praktikoje tai reiškia, kad negalima tiesiog „įmesti” Node.js aplikacijos į serverį ir tikėtis, kad viskas veiks sklandžiai. Reikia suprasti, kaip veikia V8 variklis, kaip valdoma atmintis, kaip efektyviai naudoti procesorių ir kaip užtikrinti, kad vieno vartotojo problema netaptų visų vartotojų problema.
Procesorių valdymas ir klasterizacija
Viena didžiausių Node.js klaidų, kurią mato pradedantieji – paleisti vieną Node.js procesą serveryje, kuris turi 16 branduolių. Rezultatas? Naudojamas tik vienas branduolys, o likusieji 15 tiesiog tingiai sėdi.
Node.js turi puikų įmontuotą cluster modulį, kuris leidžia paleisti kelis darbinius procesus. Štai kaip tai atrodo praktiškai:
const cluster = require('cluster');
const os = require('os');
const numCPUs = os.cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} paleistas`);
for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on(‘exit’, (worker, code, signal) => {
console.log(`Darbininkas ${worker.process.pid} mirė`);
cluster.fork(); // Paleidžiam naują vietoj mirusio
});
} else {
require(‘./app.js’);
console.log(`Darbininkas ${process.pid} paleistas`);
}
Bet čia yra vienas svarbus niuansas – ne visada reikia naudoti visus branduolius. Jei tavo serveris atlieka ir kitus darbus (duomenų bazė, cache sistema), palikti kelis branduolius jiems taip pat svarbu. Praktiškai dažnai naudoju formulę: branduolių skaičius – 1, arba branduolių skaičius – 2, jei serveris turi daug kitų procesų.
Alternatyva cluster moduliui yra PM2 – process manager’is, kuris ne tik valdo procesus, bet ir suteikia daug papildomų funkcijų: automatinį perkrovimą, logų valdymą, monitoringą. PM2 konfigūracija atrodo taip:
module.exports = {
apps: [{
name: 'mano-app',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
}
}]
};
Atminties optimizavimas ir V8 nustatymai
V8 variklis, kuris varo Node.js, turi savo atminties valdymo mechanizmus, bet jie ne visada optimalūs konkrečiai tavo aplikacijai. Pagal nutylėjimą Node.js procesas gali naudoti apie 1.4GB atminties 64-bitinėse sistemose. Jei tavo aplikacija reikalauja daugiau – ji tiesiog kris.
Atminties limitą galima padidinti naudojant V8 flags:
node --max-old-space-size=4096 app.js
Bet čia svarbu suprasti – didinti limitą nėra sprendimas, jei tavo aplikacija turi atminties nutekėjimą (memory leak). Tai tik laiko atidėjimas iki kito krachso. Reikia reguliariai stebėti atminties naudojimą ir ieškoti problemų.
Vienas iš būdų stebėti atmintį – naudoti process.memoryUsage():
setInterval(() => {
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)}MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)}MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`,
external: `${Math.round(used.external / 1024 / 1024)}MB`
});
}, 30000);
Jei matai, kad heapUsed nuolat auga ir niekada nesumažėja – turim problemą. Garbage collector’ius neveikia taip, kaip turėtų, arba kažkas laiko nuorodas į objektus, kurie jau nebenaudojami.
Dar vienas svarbus V8 nustatymas – garbage collection optimizavimas. Jei tavo aplikacija turi didelius atminties šuolius, gali būti naudinga įjungti incremental marking:
node --optimize-for-size --max-old-space-size=4096 --gc-interval=100 app.js
Event loop’o valdymas ir blokuojančio kodo vengimas
Event loop – tai Node.js širdis. Jei jį užblokuosi, viskas sustoja. Ir tai nėra teorija – tai kasdienė praktika, su kuria susiduria kiekvienas, kuris rimtai dirba su Node.js.
Klasikinis pavyzdys – sinchroninis failų skaitymas:
// BLOGAI - blokuoja event loop
const data = fs.readFileSync('/kelias/i/dideli/faila.json');
// GERAI – neblokuoja
fs.readFile(‘/kelias/i/dideli/faila.json’, (err, data) => {
// apdoroti duomenis
});
Bet ne visada taip akivaizdu. Kartais blokuojantis kodas pasislėpęs giliau. Pavyzdžiui, sunkūs skaičiavimai:
// Blokuoja event loop
function sunkusSkaiciavimas(n) {
let result = 0;
for (let i = 0; i < n * 1000000; i++) {
result += Math.sqrt(i);
}
return result;
}
Tokiems atvejams yra worker threads arba galima iškelti skaičiavimus į atskirą procesą. Worker threads pavyzdys:
const { Worker } = require('worker_threads');
function runService(workerData) {
return new Promise((resolve, reject) => {
const worker = new Worker(‘./skaiciavimas-worker.js’, { workerData });
worker.on(‘message’, resolve);
worker.on(‘error’, reject);
worker.on(‘exit’, (code) => {
if (code !== 0)
reject(new Error(`Worker sustojo su kodu ${code}`));
});
});
}
Event loop’o būseną galima stebėti naudojant bibliotekas kaip blocked-at arba event-loop-lag. Jos parodo, kiek laiko event loop buvo užblokuotas:
const blocked = require('blocked-at');
blocked((time, stack) => {
console.log(`Event loop buvo blokuotas ${time}ms`);
console.log(stack);
});
Duomenų bazių ryšių optimizavimas
Viena dažniausių Node.js aplikacijų problemų – neoptimalus darbas su duomenų bazėmis. Node.js yra greitas, bet jei kiekvienas užklausimas laukia 200ms atsakymo iš duomenų bazės, visa aplikacija tampa lėta.
Pirmiausia – connection pooling. Niekada nekurti naujo ryšio kiekvienam užklausimui:
// BLOGAI
app.get('/users', async (req, res) => {
const client = await MongoClient.connect(url);
const users = await client.db().collection('users').find().toArray();
await client.close();
res.json(users);
});
// GERAI
const pool = new Pool({
host: ‘localhost’,
database: ‘mydb’,
max: 20, // maksimalus ryšių skaičius
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
app.get(‘/users’, async (req, res) => {
const client = await pool.connect();
try {
const result = await client.query(‘SELECT * FROM users’);
res.json(result.rows);
} finally {
client.release();
}
});
Connection pool’o dydis priklauso nuo kelių faktorių: serverio resursų, duomenų bazės galimybių, tikėtino apkrovimo. Praktiškai, pradėti galima nuo formulės: procesorių branduolių skaičius * 2 + 1. Paskui stebėti ir koreguoti pagal realų naudojimą.
Antra svarbi dalis – query’ų optimizavimas. N+1 problema yra klasika:
// BLOGAI - N+1 problema
const users = await User.find();
for (let user of users) {
user.posts = await Post.find({ userId: user.id });
}
// GERAI – vienas užklausimas
const users = await User.find().populate(‘posts’);
Trečia – caching. Ne visi duomenys keičiasi kas sekundę. Naudoti Redis ar panašų sprendimą dažnai užklausiamiems duomenims:
async function getUser(id) {
const cacheKey = `user:${id}`;
// Patikrinam cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Jei nėra cache, kreipiamės į DB
const user = await User.findById(id);
// Išsaugom cache 5 minutėms
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
Middleware ir request handling optimizavimas
Express.js ir kiti framework’ai naudoja middleware grandinę. Kiekvienas middleware prideda overhead’ą, todėl svarbu optimizuoti jų veikimą ir tvarką.
Middleware tvarka turi reikšmės. Greitesni ir dažniau naudojami middleware turėtų būti pirmiau:
// BLOGAI - lėtas middleware pirmoje vietoje
app.use(heavyLoggingMiddleware);
app.use(express.json());
app.use(authMiddleware);
// GERAI – greiti middleware pirmiau
app.use(express.json({ limit: ‘1mb’ })); // ribojam payload dydį
app.use(helmet()); // security headers
app.use(compression()); // gzip
app.use(authMiddleware);
app.use(conditionalLoggingMiddleware); // logai tik kai reikia
Body parser’io konfigūracija taip pat svarbi. Jei neribosite įeinančių duomenų dydžio, kas nors gali atsiųsti gigabaitų dydžio JSON ir užkrauti serverį:
app.use(express.json({
limit: '1mb',
strict: true
}));
app.use(express.urlencoded({
extended: true,
limit: ‘1mb’,
parameterLimit: 1000
}));
Rate limiting – būtinas dalykas produkcinėje aplinkoje. Apsaugo nuo DDoS ir piktnaudžiavimo:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minučių
max: 100, // maksimalus užklausimų skaičius
message: ‘Per daug užklausimų iš šio IP’,
standardHeaders: true,
legacyHeaders: false,
});
app.use(‘/api/’, limiter);
Streaming – kai reikia perduoti didelius duomenis, naudoti stream’us vietoj to, kad viską įkeltumėte į atmintį:
// BLOGAI - visas failas į atmintį
app.get('/download', async (req, res) => {
const data = await fs.promises.readFile('didelis-failas.zip');
res.send(data);
});
// GERAI – streaming
app.get(‘/download’, (req, res) => {
const stream = fs.createReadStream(‘didelis-failas.zip’);
stream.pipe(res);
});
Monitoring ir logging strategijos
Negalima optimizuoti to, ko nematai. Monitoring ir logging – ne tik debugging įrankiai, bet ir optimizavimo pagrindas.
Structured logging – naudoti JSON formatą vietoj paprastų string’ų. Tai leidžia lengvai analizuoti logus:
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: ‘error.log’, level: ‘error’ }),
new winston.transports.File({ filename: ‘combined.log’ })
]
});
// Naudojimas
logger.info(‘User login’, {
userId: user.id,
ip: req.ip,
duration: Date.now() – startTime
});
Request tracking – kiekvienam užklausimui priskirti unikalų ID, kad galėtumėte sekti jo kelią per sistemą:
const { v4: uuidv4 } = require('uuid');
app.use((req, res, next) => {
req.id = uuidv4();
res.setHeader(‘X-Request-ID’, req.id);
next();
});
app.use((req, res, next) => {
const start = Date.now();
res.on(‘finish’, () => {
const duration = Date.now() – start;
logger.info(‘Request completed’, {
requestId: req.id,
method: req.method,
url: req.url,
status: res.statusCode,
duration
});
});
next();
});
Performance metrics – stebėti svarbiausius metrikų:
const promClient = require('prom-client');
const httpRequestDuration = new promClient.Histogram({
name: ‘http_request_duration_seconds’,
help: ‘Duration of HTTP requests in seconds’,
labelNames: [‘method’, ‘route’, ‘status_code’]
});
const activeConnections = new promClient.Gauge({
name: ‘active_connections’,
help: ‘Number of active connections’
});
app.use((req, res, next) => {
const start = Date.now();
activeConnections.inc();
res.on(‘finish’, () => {
const duration = (Date.now() – start) / 1000;
httpRequestDuration
.labels(req.method, req.route?.path || req.url, res.statusCode)
.observe(duration);
activeConnections.dec();
});
next();
});
Health check endpoint’ai – būtini load balancer’iams ir monitoring sistemoms:
app.get('/health', async (req, res) => {
const health = {
uptime: process.uptime(),
timestamp: Date.now(),
status: 'OK'
};
try {
// Patikrinam DB ryšį
await db.ping();
health.database = ‘connected’;
} catch (error) {
health.status = ‘ERROR’;
health.database = ‘disconnected’;
return res.status(503).json(health);
}
res.json(health);
});
Kai viskas sueina į vieną vietą
Node.js serverio optimizavimas – tai ne vienkartinis veiksmas, o nuolatinis procesas. Pradedi nuo bazinės konfigūracijos: klasterizacijos, atminties limitų, connection pooling. Paskui pridedi monitoring’ą ir matai, kur yra butelio kakliukai. Optimizuoji tuos taškus. Ir ciklas kartojasi.
Svarbiausia – neskubėti optimizuoti visko iš karto. Premature optimization yra blogis, kaip sakė Donald Knuth. Pradėti reikia nuo matavimo, ne nuo spėliojimų. Įdiegti monitoring’ą, surinkti duomenis, analizuoti, optimizuoti. Ir tik tuos dalykus, kurie tikrai yra problematiški.
Praktiškai, dauguma aplikacijų pirmiausia susiduria su duomenų bazių užklausimų problemomis, ne su pačiu Node.js. Todėl connection pooling, query optimizavimas ir caching dažniausiai duoda didžiausią efektą. Paskui ateina middleware optimizavimas ir rate limiting. Ir tik tada – gilesnė V8 ir event loop optimizacija.
Dar vienas dalykas, kurį verta prisiminti – horizontal scaling dažnai lengvesnis ir efektyvesnis už vertical scaling. Geriau turėti kelis vidutinės galios serverius su load balancer’iu, nei vieną super galingą. Node.js puikiai tinka tokiai architektūrai, nes procesai neturi bendros būsenos (jei teisingai suprojektuota aplikacija).
Ir galiausiai – dokumentuokite savo konfigūracijas ir optimizavimus. Po pusės metų, kai reikės suprasti, kodėl max-old-space-size nustatytas būtent 4096, būsite dėkingi sau už komentarą ar commit message, kuris tai paaiškina. Optimizavimas be dokumentacijos – tai žinių praradimas, kai komandoje keičiasi žmonės arba tiesiog praeina laikas.
