Пошук уроків, статей та іншого контенту
Запустіть кілька екземплярів Node.js і забезпечте коректну роботу сесій, черг та спільного стану між ними.
Горизонтальне масштабування означає запуск кількох екземплярів застосунку на різних процесах або серверах:
Клієнти
|
Load Balancer
|-------- Node.js instance 1
|-------- Node.js instance 2
|-------- Node.js instance 3
|
RedisБалансувальник розподіляє HTTP-запити між екземплярами. Наступний запит того самого користувача може потрапити вже до іншого процесу.
Стан, який має бути доступним для всіх екземплярів, не можна зберігати лише в пам’яті одного процесу Node.js.
До такого стану належать:
HTTP-сесії;
лічильники та інші спільні дані;
черги завдань;
блокування;
результати тимчасових операцій.
Розглянемо простий приклад:
const sessions = new Map();
const jobs = [];
sessions.set("session-id", { userId: 42 });
jobs.push({ type: "send-email" });Якщо запущено два процеси Node.js, кожен має власні Map і масив jobs:
Process 1:
sessions = { "session-id" => ... }
jobs = [ ... ]
Process 2:
sessions = {}
jobs = []Якщо користувач спочатку потрапив до першого процесу, а наступний запит — до другого, другий процес не знайде його сесію.
Черга має ще серйознішу проблему: один процес може додати завдання у власний масив, а обробник в іншому процесі ніколи його не побачить.
Крім того, пам’ять процесу втрачається під час:
перезапуску застосунку;
аварії;
розгортання нової версії;
переміщення запиту на інший сервер.
Для спільного стану використовують зовнішній сервіс, доступний усім екземплярам. Для сесій, лічильників і простих черг часто використовують Redis.
У production-системі Redis має бути окремим сервісом із:
постійним збереженням або відповідною політикою втрати даних;
резервним копіюванням;
контролем доступу;
моніторингом;
планом відновлення після відмови.
Сам факт запуску Redis не робить систему надійною. Важливо визначити, які дані можна втратити, а які повинні зберігатися в основній базі даних.
const session = require("express-session");
app.use(session({
secret: "secret",
resave: false,
saveUninitialized: false
}));Типово express-session зберігає сесії в пам’яті процесу. Це неприйнятно для production і не працює коректно між кількома екземплярами.
Сесія повинна містити лише ідентифікатор у cookie браузера, а самі дані сесії — зберігатися у спільному Redis:
Cookie:
connect.sid = session-id
Redis:
sess:session-id -> { userId: 42, ... }Cookie не повинна містити всі дані сесії. Це дає змогу:
не передавати стан у кожному запиті;
відкликати сесію на сервері;
змінювати дані сесії;
використовувати будь-який екземпляр Node.js для наступного запиту.
Нижче наведено мінімальний застосунок із:
двома або більше екземплярами Node.js;
сесіями в Redis;
спільним лічильником;
Redis-чергою;
кількома конкурентними обробниками черги.
Потрібні Node.js, Docker і Redis.
Запустіть Redis:
docker run --rm --name lesson-redis -p 6379:6379 redis:7Створіть проєкт і встановіть залежності:
npm init -y
npm install express express-session connect-redis redisСтворіть файл app.js:
const express = require("express");
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");
const port = Number(process.env.PORT || 3000);
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
async function main() {
const redis = createClient({ url: redisUrl });
redis.on("error", (error) => {
console.error("Redis error:", error);
});
await redis.connect();
// Окреме з'єднання потрібне для блокувального читання з черги.
const workerRedis = redis.duplicate();
workerRedis.on("error", (error) => {
console.error("Worker Redis error:", error);
});
await workerRedis.connect();
const app = express();
app.use(express.json());
app.use(
session({
store: new RedisStore({
client: redis,
prefix: "lesson:sess:"
}),
secret: process.env.SESSION_SECRET || "development-secret",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 1000
}
})
);
app.get("/", (req, res) => {
res.json({
pid: process.pid,
message: "Node.js instance is working"
});
});
app.post("/login", (req, res) => {
const userId = String(req.body.userId || "anonymous");
req.session.userId = userId;
res.json({
pid: process.pid,
loggedIn: true,
userId
});
});
app.get("/me", (req, res) => {
res.json({
pid: process.pid,
userId: req.session.userId || null,
authenticated: Boolean(req.session.userId)
});
});
app.post("/counter/increment", async (req, res, next) => {
try {
const value = await redis.incr("lesson:counter");
res.json({
pid: process.pid,
value
});
} catch (error) {
next(error);
}
});
app.post("/jobs", async (req, res, next) => {
try {
const job = {
id: crypto.randomUUID(),
type: req.body.type || "default",
createdBy: req.session.userId || null,
createdAt: new Date().toISOString()
};
// LPUSH додає завдання в Redis-список.
await redis.lPush("lesson:jobs", JSON.stringify(job));
res.status(202).json({
accepted: true,
job
});
} catch (error) {
next(error);
}
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({
error: "Internal server error"
});
});
const server = app.listen(port, () => {
console.log(`HTTP server ${port}, PID ${process.pid}`);
});
consumeJobs(workerRedis);
async function shutdown(signal) {
console.log(`${signal}: shutting down`);
server.close(async () => {
await workerRedis.quit();
await redis.quit();
process.exit(0);
});
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
async function consumeJobs(workerRedis) {
while (true) {
try {
// BRPOP чекає на елемент у черзі.
// Тайм-аут дає змогу періодично перевіряти стан процесу.
const result = await workerRedis.brPop("lesson:jobs", 1);
if (!result) {
continue;
}
const job = JSON.parse(result.element);
console.log(
`[PID ${process.pid}] processing job ${job.id}, type=${job.type}`
);
// Тут могла б бути реальна тривала операція.
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(`[PID ${process.pid}] completed job ${job.id}`);
} catch (error) {
console.error("Job processing error:", error);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});У цьому прикладі використовується crypto.randomUUID(), але модуль crypto ще потрібно підключити на початку файлу:
const crypto = require("node:crypto");Повний початок файлу має виглядати так:
const crypto = require("node:crypto");
const express = require("express");
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");Запустіть два екземпляри:
SESSION_SECRET=change-this-secret PORT=3001 node app.jsВ іншому терміналі:
SESSION_SECRET=change-this-secret PORT=3002 node app.jsДля Windows змінні середовища можна передати через відповідні засоби оболонки або використати пакет cross-env у скрипті npm.
Спочатку виконайте вхід і збережіть cookie:
curl -i -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"userId":"user-42"}' \
http://localhost:3001/loginПотім перевірте сесію через інший порт:
curl -b cookies.txt http://localhost:3002/meВідповідь повинна містити:
{
"pid": 12345,
"userId": "user-42",
"authenticated": true
}Значення pid може відрізнятися, але дані сесії залишаються доступними. Вони були прочитані з Redis, а не з пам’яті процесу.
У прикладі лічильник збільшується так:
const value = await redis.incr("lesson:counter");INCR виконується Redis атомарно. Якщо кілька екземплярів одночасно збільшують один ключ, значення не буде втрачено через race condition.
Небезпечний варіант виглядав би так:
const value = Number(await redis.get("lesson:counter")) || 0;
await redis.set("lesson:counter", value + 1);Між операціями GET і SET інший процес може змінити значення:
Process A: GET -> 10
Process B: GET -> 10
Process A: SET -> 11
Process B: SET -> 11Два збільшення перетворилися на одне. Якщо операція вже підтримується Redis як атомарна команда, використовуйте саме її.
Для складніших змін потрібні транзакції Redis, Lua-скрипти або операції з умовами. Головне — не розділяти читання і запис без аналізу конкурентного доступу.
У прикладі завдання додається до Redis-списку:
await redis.lPush("lesson:jobs", JSON.stringify(job));Обробник забирає завдання:
const result = await workerRedis.brPop("lesson:jobs", 1);Якщо запущено кілька екземплярів, усі вони читають одну чергу. Конкретне завдання забере лише один обробник.
Це відрізняється від локального масиву:
const jobs = [];
jobs.push(job);Локальний масив не є спільною чергою. Його бачить тільки один процес.
BRPOPОперація BRPOP видаляє елемент зі списку, коли його забирає обробник. Якщо процес аварійно завершиться після видалення, але до завершення роботи, завдання може бути втрачено.
Для критичних завдань потрібен патерн надійної черги:
переміщення завдання до списку processing;
підтвердження завершення;
повернення незавершених завдань після тайм-ауту;
або спеціалізована бібліотека черг із підтримкою повторних спроб.
Для простих прикладів і некритичних операцій BRPOP може бути достатнім, але його не слід автоматично вважати системою гарантованої доставки.
У розподіленій системі завдання може бути виконано повторно через:
повторну доставку після тайм-ауту;
повторну спробу;
мережеву помилку;
збій після фактичної обробки, але до запису результату.
Тому обробник має бути ідемпотентним: повторне виконання не повинно створювати некоректний результат.
Наприклад, перед відправленням електронного листа можна зберегти у базі даних унікальний jobId і перевірити, чи це завдання вже виконувалося.
if (await alreadyProcessed(job.id)) {
return;
}
await processJob(job);
await markAsProcessed(job.id);Перевірка і запис мають бути захищені від конкурентного виконання. Зазвичай для цього використовують унікальний індекс у базі даних або атомарну операцію в Redis.
Балансувальник може розподіляти запити:
Запит 1 -> instance 1
Запит 2 -> instance 2
Запит 3 -> instance 1
Запит 4 -> instance 3Не слід покладатися на те, що всі запити користувача потраплять до одного процесу. Такий режим називають sticky sessions або session affinity.
Sticky sessions іноді використовують як тимчасове рішення, але вони не замінюють спільне сховище:
процес може завершитися;
сервер може стати недоступним;
балансування стає менш рівномірним;
під час масштабування поведінка ускладнюється.
Надійніша модель — зробити HTTP-екземпляри максимально stateless, винісши сесії та інший спільний стан у зовнішні системи.
У production HTTPS часто завершується на балансувальнику, а між балансувальником і Node.js використовується HTTP. У такій конфігурації застосунок повинен коректно довіряти проксі, інакше secure-cookie може працювати неправильно.
Для Express конфігурація може містити:
app.set("trust proxy", 1);Використовуйте це лише відповідно до реальної топології мережі. Довіряти довільним проксі без потреби небезпечно.
Для production-сесії також потрібно:
використовувати випадковий секрет у змінній середовища або менеджері секретів;
увімкнути secure: true під HTTPS;
залишити httpOnly: true;
налаштувати sameSite відповідно до сценарію;
встановити термін дії cookie;
не зберігати паролі або великі об’єкти в сесії.
Не кожен стан слід переносити в Redis. Розділяйте його за призначенням:
основні бізнес-дані — реляційна або документна база даних;
короткоживучі сесії — Redis;
кеш — Redis або інше кеш-сховище;
критичні завдання — надійна черга;
тимчасові дані конкретного запиту — локальні змінні процесу.
Локальні змінні безпечні, якщо вони існують лише протягом одного запиту і не потрібні іншому запиту.
Сесія працює на одному екземплярі, але зникає після перезапуску або недоступна на іншому.
Виправлення: використовувати Redis або інше спільне сховище сесій.
Інші процеси не бачать завдань, а всі завдання втрачаються після завершення процесу.
Виправлення: використовувати спільну чергу та продумати гарантії доставки.
Це приховує проблему, але не усуває залежність від конкретного процесу.
Виправлення: зберігати сесії поза процесом Node.js.
Послідовність GET і SET може втрачати оновлення при конкурентних запитах.
Виправлення: використовувати INCR, транзакцію або інший атомарний механізм.
Блокувальна команда на кшталт BRPOP може утримувати з’єднання, тому звичайні команди через цей самий клієнт працюватимуть некоректно або чекатимуть завершення блокування.
Виправлення: створити окреме Redis-з’єднання для споживача черги.
Повторна обробка може двічі списати кошти, відправити лист або створити запис.
Виправлення: додати унікальний ідентифікатор завдання та захист від повторного виконання.
Зміна секрету або витік репозиторію створює проблему для всіх екземплярів.
Виправлення: передавати однаковий секрет усім екземплярам через змінні середовища або менеджер секретів.
Процес може припинити прийом сигналів, не завершивши HTTP-запити або Redis-з’єднання.
Виправлення: обробляти SIGTERM, припиняти прийом нових запитів і коректно закривати з’єднання.
Горизонтальне масштабування запускає кілька незалежних екземплярів Node.js.
Не можна покладатися на пам’ять одного процесу для сесій, черг і спільних лічильників.
Сесії потрібно зберігати у спільному сховищі, наприклад Redis.
Черга повинна бути доступною всім обробникам, а її гарантії доставки мають бути явно визначені.
Для конкурентних змін потрібно використовувати атомарні операції.
Обробники черг мають бути ідемпотентними.
Sticky sessions не замінюють спільне сховище.
Важливі дані потрібно розміщувати у відповідному зовнішньому сховищі, а не просто переносити весь стан у Redis.