Пошук уроків, статей та іншого контенту
Визначте вузькі місця та оберіть стратегію масштабування з урахуванням стану, кешування й обмежень застосунку.
Масштабування — це збільшення здатності системи обробляти навантаження зі збереженням прийнятного часу відповіді, надійності та керованої вартості інфраструктури.
Є дві базові стратегії:
Вертикальне масштабування — збільшення ресурсів одного сервера: CPU, RAM, швидшого диска або мережі.
Горизонтальне масштабування — запуск кількох екземплярів застосунку та розподіл запитів між ними.
На практиці часто використовують обидві стратегії. Наприклад, спочатку збільшують ресурси сервера, а потім додають кілька екземплярів застосунку за балансувальником.
Масштабування не зводиться до запуску більшої кількості процесів Node.js. Якщо всі процеси очікують одну базу даних, блокуються на одному зовнішньому сервісі або конкурують за обмежений пул з’єднань, додаткові екземпляри не усунуть вузьке місце.
Перед вибором стратегії потрібно визначити, який ресурс вичерпується першим.
Node.js добре підходить для великої кількості операцій введення-виведення, але JavaScript виконується в одному основному потоці event loop.
Проблемами можуть бути:
складні регулярні вирази;
обробка великих JSON-документів;
сортування великих масивів;
синхронні операції з файловою системою;
стиснення, хешування або шифрування великих обсягів даних;
обчислювально складні цикли.
Ознаки блокування event loop:
збільшення часу відповіді навіть для простих endpoint’ів;
високий CPU одного процесу;
зростання черги запитів;
високий event loop lag.
У такій ситуації горизонтальне масштабування процесами може тимчасово збільшити пропускну здатність, але важку CPU-операцію все одно потрібно оптимізувати або винести в окремий worker чи сервіс.
База даних часто стає головним обмеженням після додавання кількох екземплярів застосунку.
Наприклад, один процес використовує пул із 20 з’єднань. Після запуску 10 процесів база потенційно отримує 200 з’єднань. Якщо база дозволяє лише 100, масштабування застосунку призведе до помилок підключення або деградації продуктивності.
Потрібно контролювати:
кількість з’єднань у пулі;
час очікування з’єднання;
тривалість SQL-запитів;
індекси;
кількість запитів на один HTTP-запит;
блокування та конкуренцію за рядки;
максимальну пропускну здатність самої бази.
Застосунок може бути обмежений не власними ресурсами, а API платіжної системи, сервісу повідомлень або сховища файлів.
Додаткові екземпляри Node.js у такому випадку можуть лише збільшити кількість запитів до зовнішнього сервісу та швидше досягнути його rate limit.
Для таких залежностей потрібні:
тайм-аути;
повторні спроби з обмеженням;
exponential backoff;
circuit breaker;
черга для некритичних операцій;
локальне або розподілене кешування.
Зростання використання пам’яті може бути наслідком:
кешу без обмеження розміру;
накопичення об’єктів у глобальних структурах;
незакритих таймерів або потоків;
витоків через обробники подій;
завантаження великих файлів цілком у пам’ять;
занадто великих черг внутрішніх задач.
Якщо процес перевищує доступну пам’ять, операційна система або контейнер може завершити його. Масштабування кількістю процесів не виправляє витік пам’яті, а лише збільшує сумарне споживання RAM.
Для прийняття рішення потрібні метрики, а не лише середній час відповіді.
Корисно вимірювати:
пропускну здатність — запити за секунду;
latency на перцентилях p50, p95, p99;
частку помилок;
використання CPU та пам’яті;
час очікування event loop;
кількість активних з’єднань;
завантаження пулу з’єднань до бази;
кількість cache hit і cache miss;
час відповіді зовнішніх сервісів;
довжину черг.
Середнє значення може приховувати проблему. Наприклад, середній час відповіді 100 мс виглядає добре, але p99 у 5 секунд означає, що кожен сотий користувач отримує дуже повільну відповідь.
Профілювання потрібно виконувати на навантаженні, близькому до реального. Локальний запуск одного процесу не показує поведінку системи з балансувальником, базою даних і кількома екземплярами.
Вертикальне масштабування є простим способом отримати додаткову продуктивність:
збільшити кількість ядер;
додати RAM;
перейти на швидший диск;
збільшити мережеві ліміти.
Переваги:
мінімальні зміни в коді;
простіше адміністрування;
не потрібно одразу вирішувати проблеми розподіленого стану.
Недоліки:
існує верхня межа ресурсів одного сервера;
одна машина залишається потенційною точкою відмови;
збільшення ресурсів не допоможе, якщо вузьке місце знаходиться в базі або зовнішньому сервісі.
Вертикальне масштабування доречне, коли застосунок ще не вичерпав ресурси сервера або коли горизонтальне масштабування поки що невиправдано складне.
Горизонтальне масштабування передбачає запуск кількох екземплярів застосунку. Балансувальник розподіляє запити між ними.
Це дає змогу:
використовувати кілька ядер;
збільшити доступну пропускну здатність;
обмежити вплив падіння одного процесу;
виконувати оновлення поступово;
масштабувати кількість екземплярів незалежно від розміру одного сервера.
Щоб горизонтальне масштабування працювало, екземпляри мають бути переважно stateless — без важливого стану лише в пам’яті конкретного процесу.
Один процес Node.js зазвичай використовує одне основне JavaScript-виконання. Для використання кількох ядер можна запустити кілька процесів.
Вбудований модуль node:cluster дозволяє створити кілька worker-процесів, які слухають той самий порт:
const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');
const port = Number(process.env.PORT) || 3000;
const workerCount = Math.max(1, Math.min(4, os.availableParallelism()));
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} запускає ${workerCount} worker-процесів`);
for (let i = 0; i < workerCount; i += 1) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.error(
`Worker ${worker.process.pid} завершився: code=${code}, signal=${signal}`
);
// Автоматично замінюємо worker, який завершився не під час зупинки.
if (!shuttingDown) {
cluster.fork();
}
});
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log('Primary зупиняє worker-процеси');
for (const worker of Object.values(cluster.workers)) {
worker.disconnect();
}
setTimeout(() => {
for (const worker of Object.values(cluster.workers)) {
worker.kill();
}
process.exit(0);
}, 10_000).unref();
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} else {
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
return;
}
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
res.end(`Запит обробив worker ${process.pid}\n`);
});
server.listen(port, () => {
console.log(`Worker ${process.pid} слухає порт ${port}`);
});
process.on('SIGTERM', () => {
// Припиняємо приймати нові з'єднання та чекаємо на поточні.
server.close(() => process.exit(0));
});
}Запуск:
node server.jsПід час кількох HTTP-запитів можна побачити різні ідентифікатори worker-процесів.
Важливі обмеження такого підходу:
кожен worker має власну пам’ять;
глобальна змінна одного worker не видима в інших;
локальний кеш не є спільним;
помилка конфігурації може створити надто багато процесів;
кілька процесів збільшують сумарне навантаження на базу даних.
У середовищах із контейнерами часто запускають один Node.js-процес у контейнері та масштабують кількість контейнерів оркестратором. Головний принцип залишається тим самим: екземпляри повинні коректно працювати незалежно один від одного.
Стан — це дані, які потрібні для обробки наступного запиту.
Прикладом локального стану є:
const sessions = new Map();Це працює в одному процесі, але створює проблеми при горизонтальному масштабуванні:
Перший запит потрапляє до процесу A, який записує сесію.
Наступний запит потрапляє до процесу B.
Процес B не бачить сесію процесу A.
Також локальний стан зникає після перезапуску процесу.
У пам’яті процесу можна зберігати лише дані, втрата яких допустима, наприклад короткоживучий локальний кеш. Критичний стан потрібно винести в зовнішнє сховище.
Залежно від типу даних стан можна зберігати в:
основній базі даних;
спеціалізованому сховищі сесій;
розподіленому кеші;
черзі повідомлень.
Важливо, щоб усі екземпляри застосунку використовували одне узгоджене джерело даних.
Sticky sessions змушують балансувальник спрямовувати користувача до того самого екземпляра.
Це може приховати проблему локальних сесій, але не усуває її повністю:
екземпляр може завершитися;
балансувальник може змінити маршрут;
розподіл навантаження стає менш рівномірним;
масштабування стає складнішим.
Sticky sessions варто розглядати як спеціальну сумісну вимогу, а не як основний спосіб зберігання стану.
Кеш зменшує кількість дорогих операцій, але додає проблему актуальності даних.
Локальний кеш у пам’яті швидкий і не потребує мережевого запиту. Водночас кожен процес має власну копію:
дані можуть відрізнятися між worker-процесами;
після перезапуску кеш порожній;
сумарне споживання пам’яті зростає разом із кількістю процесів;
очищення одного кешу не очищає інші.
Локальний кеш підходить для даних, які можна повторно отримати та для яких короткочасна неузгодженість прийнятна.
Розподілений кеш дає спільний простір для кількох екземплярів. Він підходить для:
результатів дорогих запитів;
сесій;
rate limiting;
короткоживучих блокувань;
ідемпотентних ключів.
Такий кеш сам стає залежністю, тому потрібно передбачити:
тайм-аут підключення;
поведінку при недоступності;
обмеження розміру;
TTL;
політику видалення;
метрики hit/miss;
захист від перевантаження кешу.
Поширений підхід — cache-aside:
Перевірити кеш.
Якщо значення знайдено, повернути його.
Якщо значення відсутнє, прочитати дані з основного сховища.
Записати результат у кеш із TTL.
Повернути результат.
Потрібно визначити, що відбувається під час зміни даних:
видаляти кешований запис;
оновлювати його;
чекати завершення TTL;
використовувати версію або тег даних.
Кеш не повинен бути єдиним джерелом критично важливих даних, якщо втрата його вмісту неприпустима.
Якщо популярний запис одночасно стає простроченим, багато запитів можуть одночасно звернутися до бази даних. Це називають cache stampede.
Зменшити ризик можна за допомогою:
блокування оновлення одного ключа;
короткого випадкового розподілу TTL;
попереднього оновлення популярних записів;
stale-while-revalidate;
обмеження кількості одночасних запитів до джерела.
Балансувальник розподіляє запити між екземплярами, але не повинен бути єдиним способом контролю стану системи.
Для HTTP-застосунку важливі:
перевірка готовності екземпляра;
перевірка живості процесу;
виключення нездорового екземпляра з маршрутизації;
коректне завершення роботи;
тайм-аути;
обмеження розміру запиту;
контроль кількості одночасних з’єднань.
Під час розгортання або масштабування старий процес має:
припинити приймати нові запити;
завершити поточні запити;
закрити з’єднання з базою та іншими сервісами;
завершитися до встановленого тайм-ауту.
Без цього процес може бути завершений посеред запису даних або відповіді клієнту.
Водночас graceful shutdown не гарантує завершення нескінченної операції. Для всіх зовнішніх викликів потрібні обмежені тайм-аути.
Додаткові екземпляри потрібно додавати лише тоді, коли залежності можуть прийняти відповідне навантаження.
Перед масштабуванням перевірте:
максимальну кількість з’єднань до бази;
розмір пулу на один екземпляр;
ліміти файлових дескрипторів;
rate limit зовнішніх API;
пропускну здатність мережі;
доступну пам’ять;
кількість CPU;
максимальний розмір черг;
час очікування запитів.
Наприклад, якщо база підтримує 100 з’єднань, а системі потрібні 10 екземплярів, не можна бездумно налаштувати пул на 20 з’єднань у кожному. Потрібно залишити резерв для адміністративних і фонових операцій та врахувати інші клієнти бази.
Практичний порядок дій:
Відтворити навантаження в контрольованому середовищі.
Виміряти latency, помилки, CPU, пам’ять і зовнішні залежності.
Визначити ресурс, який вичерпується першим.
Перевірити, чи не є проблемою неефективний запит або алгоритм.
Усунути очевидне вузьке місце.
Перевірити, чи потрібен локальний або розподілений кеш.
Прибрати критичний стан із пам’яті процесу.
Налаштувати коректне завершення та перевірки стану.
Збільшити кількість екземплярів.
Повторно виміряти систему та залежності.
Орієнтовні рішення:
CPU одного процесу високий — кілька процесів, оптимізація алгоритму або worker для CPU-операцій.
Event loop блокується — усунення синхронних і важких операцій з основного потоку.
База перевантажена — оптимізація запитів, індекси, кешування, контроль пулу, поділ навантаження.
Багато повторних читань — cache-aside із відповідним TTL.
Сесії в локальній пам’яті — зовнішнє сховище сесій або інша stateless-модель.
Зовнішній API має жорсткий rate limit — кеш, черга, throttling та повторні спроби з backoff.
Процес споживає дедалі більше пам’яті — пошук витоку, обмеження кешу та контроль життєвого циклу ресурсів.
Один екземпляр є точкою відмови — кілька екземплярів і балансувальник.
Припустімо, HTTP-застосунок обробляє запити до каталогу товарів:
Профілювання показало, що CPU не перевантажений, але багато часу займають запити до бази.
Аналіз SQL виявив відсутній індекс.
Після додавання індексу p95 зменшився, але база все ще отримує багато однакових запитів.
Для часто запитуваних товарів додано кеш із TTL.
Сесії та ліміти запитів винесено зі стану процесу.
Застосунок запускається в кількох екземплярах.
Пул з’єднань налаштовано з урахуванням загального ліміту бази.
Балансувальник виключає екземпляри, які не пройшли health check.
Під час оновлення старі екземпляри завершують поточні запити перед зупинкою.
У цьому сценарії кілька процесів Node.js є лише частиною рішення. Основний результат дають вимірювання, оптимізація бази, правильне кешування та контроль стану.
Запуск додаткових екземплярів без метрик може збільшити витрати й навантаження на базу, не покращивши час відповіді.
Глобальна змінна існує лише в одному процесі. Вона не підходить для критичного стану при кількох екземплярах.
Sticky sessions маскують проблему розподіленого стану та можуть створити нерівномірне навантаження.
Кеш без TTL і ліміту розміру може спричинити витік пам’яті або завершення процесу через перевищення доступної пам’яті.
Загальна кількість з’єднань дорівнює сумі пулів усіх екземплярів. Це потрібно враховувати під час масштабування.
Запит до недоступного сервісу може займати з’єднання необмежено довго. З часом це призводить до вичерпання пулу та каскадного збою.
Неконтрольовані retries під час збою залежності збільшують навантаження саме в момент, коли система вже перевантажена.
Різке завершення процесу може перервати активні запити, транзакції або операції запису.
Кілька процесів збільшують паралелізм, але не роблять неефективний алгоритм швидшим. Спочатку потрібно перевірити профіль CPU та блокування event loop.
Спочатку знаходьте вузьке місце за метриками, а не масштабуйте навмання.
Вертикальне масштабування простіше, але має межі.
Горизонтальне масштабування потребує незалежних екземплярів і контролю спільного стану.
Стан у пам’яті процесу не підходить для критичних даних у багатопроцесній системі.
Кеш зменшує навантаження, але вимагає TTL, обмеження розміру та стратегії інвалідації.
Додаткові екземпляри збільшують навантаження на базу та зовнішні сервіси.
Пули з’єднань, тайм-аути, rate limit і черги потрібно розглядати як частину стратегії масштабування.
Graceful shutdown і health checks необхідні для безпечного горизонтального масштабування.
Найкраща стратегія — та, що відповідає конкретному вузькому місцю та обмеженням системи.