Пошук уроків, статей та іншого контенту
Порівняєте сервіси без стану та зі станом і дізнаєтеся, як зберігати сесії під час масштабування.
Стан — це дані, які потрібно зберігати між HTTP-запитами, щоб сервіс пам’ятав контекст попередніх операцій.
Наприклад:
ідентифікований користувач;
вміст кошика;
незавершена операція;
вибрані налаштування;
прогрес довготривалого процесу.
HTTP сам по собі є протоколом без стану: кожен запит містить лише ті дані, які були передані в ньому. Сервер не зобов’язаний пам’ятати попередні запити.
Тому стан потрібно або:
не зберігати на сервері між запитами;
зберігати поза конкретним екземпляром сервісу;
передавати клієнтом у кожному запиті.
Stateless-сервіс не покладається на локальну пам’ять конкретного екземпляра для обробки запиту.
Кожен запит містить усю інформацію, необхідну для його обробки, або сервіс отримує її з доступного спільного сховища.
Наприклад, сервіс може отримати такі дані:
GET /profile HTTP/1.1
Authorization: Bearer <token>Сервіс перевіряє токен і визначає користувача. Йому не потрібно пам’ятати, на якому екземплярі користувач виконував попередній запит.
Важливо: stateless не означає, що сервіс взагалі не використовує базу даних.
Наприклад, сервіс замовлень може:
отримати userId із запиту;
знайти замовлення в базі даних;
повернути результат.
Сервіс залишається stateless, якщо його робота не залежить від локальної пам’яті конкретного процесу.
просте горизонтальне масштабування;
будь-який запит можна направити на будь-який екземпляр;
простіше перезапускати та замінювати екземпляри;
менша залежність від конкретного сервера;
простіше працювати з автоматичним масштабуванням.
кожен запит має містити контекст або спосіб його отримати;
токени можуть збільшувати розмір запитів;
відкликання вже виданих токенів може бути складнішим;
частину даних доводиться читати зі спільного сховища.
Stateful-сервіс зберігає стан у локальній пам’яті або локальному сховищі конкретного екземпляра й використовує його в наступних запитах.
Простий приклад — сесії в пам’яті процесу:
const sessions = new Map();
sessions.set("abc123", {
userId: 42,
createdAt: Date.now()
});Якщо наступний запит потрапить до того самого процесу, він знайде сесію за ідентифікатором. Але якщо запит потрапить до іншого екземпляра, той може не знати про цю сесію.
зручно зберігати складний тимчасовий стан;
не потрібно передавати весь контекст у кожному запиті;
можна зберігати великі або внутрішні дані, які не варто віддавати клієнту;
деякі довготривалі процеси природно потребують стану.
складніше горизонтально масштабувати;
перезапуск процесу може знищити стан;
запити можуть залежати від конкретного екземпляра;
балансування навантаження стає складнішим;
потрібна синхронізація між екземплярами або спільне сховище.
Сесія пов’язує послідовність запитів із конкретним користувачем.
Поширена схема:
користувач надсилає облікові дані;
сервер створює сесію;
сервер повертає клієнту ідентифікатор сесії;
клієнт надсилає цей ідентифікатор у наступних запитах;
сервер знаходить сесію та визначає користувача.
Зазвичай ідентифікатор передають у cookie:
Set-Cookie: sid=generated-session-id; HttpOnly; Secure; SameSite=LaxCookie містить не обов’язково самі дані сесії. Безпечніша схема — зберігати в cookie лише випадковий ідентифікатор, а дані — на сервері.
Розглянемо два екземпляри сервісу:
Клієнт → Балансувальник → Сервіс A
→ Сервіс BКористувач входить у систему через сервіс A. Сесія зберігається в пам’яті A.
Наступний запит балансувальник направляє до сервісу B. Сервіс B не має цієї сесії, тому вважає користувача неавторизованим.
Це одна з типових проблем масштабування stateful-сервісу.
Усі екземпляри сервісу використовують одне сховище:
Сервіс A ─┐
├── Сховище сесій
Сервіс B ─┘Тоді будь-який екземпляр може знайти сесію за її ідентифікатором.
Для такого сховища часто використовують:
Redis;
базу даних;
інше сховище з низькою затримкою та підтримкою терміну життя записів.
У production-архітектурі це сховище має бути доступним усім екземплярам сервісу та мати політику очищення прострочених сесій.
Нижче наведено runnable-приклад на Node.js. Він запускає два HTTP-сервіси, які використовують спільний Map як демонстрацію сховища сесій.
У реальній системі два процеси не повинні ділити Map у пам’яті. Замість нього використовується зовнішнє спільне сховище.
const http = require("node:http");
const crypto = require("node:crypto");
const sessionStore = new Map();
function parseCookies(request) {
const header = request.headers.cookie || "";
return Object.fromEntries(
header
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) {
return [part, ""];
}
const name = part.slice(0, separatorIndex);
const value = part.slice(separatorIndex + 1);
return [name, decodeURIComponent(value)];
})
);
}
function sendJson(response, statusCode, body, headers = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
...headers
});
response.end(JSON.stringify(body));
}
function createApp(instanceName) {
return http.createServer((request, response) => {
const cookies = parseCookies(request);
const sessionId = cookies.sid;
if (request.method === "POST" && request.url === "/login") {
const newSessionId = crypto.randomBytes(24).toString("hex");
sessionStore.set(newSessionId, {
userId: 42,
createdAt: Date.now()
});
sendJson(
response,
200,
{
instance: instanceName,
message: "Вхід виконано"
},
{
"Set-Cookie": [
`sid=${newSessionId}; HttpOnly; Path=/; SameSite=Lax`
]
}
);
return;
}
if (request.method === "GET" && request.url === "/me") {
const session = sessionStore.get(sessionId);
if (!session) {
sendJson(response, 401, {
instance: instanceName,
error: "Сесію не знайдено"
});
return;
}
sendJson(response, 200, {
instance: instanceName,
userId: session.userId,
sessionCreatedAt: new Date(session.createdAt).toISOString()
});
return;
}
if (request.method === "POST" && request.url === "/logout") {
if (sessionId) {
sessionStore.delete(sessionId);
}
sendJson(
response,
200,
{
instance: instanceName,
message: "Вихід виконано"
},
{
"Set-Cookie": "sid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax"
}
);
return;
}
sendJson(response, 404, {
error: "Маршрут не знайдено"
});
});
}
createApp("A").listen(3001, () => {
console.log("Сервіс A: http://localhost:3001");
});
createApp("B").listen(3002, () => {
console.log("Сервіс B: http://localhost:3002");
});Запуск:
node app.jsСтворимо сесію через сервіс A:
curl -i -c cookies.txt -X POST http://localhost:3001/loginПеревіримо ту саму сесію через сервіс B:
curl -i -b cookies.txt http://localhost:3002/meСервіс B успішно знайде сесію, тому що обидва екземпляри використовують спільне сховище.
У production замість Map потрібно використовувати зовнішнє сховище. Також для сесій зазвичай задають:
термін життя;
автоматичне видалення прострочених записів;
обмеження кількості даних;
захист від паралельних конфліктів оновлення.
Sticky sessions — це налаштування балансувальника, за якого запити одного клієнта намагаються направляти на той самий екземпляр сервісу.
Тоді локальна сесія може працювати:
Клієнт 1 → Сервіс A
Клієнт 2 → Сервіс BОднак це лише часткове вирішення.
Проблеми sticky sessions:
якщо екземпляр A завершиться, його сесії можуть бути втрачені;
навантаження може розподілятися нерівномірно;
масштабування стає менш гнучким;
під час оновлення сервісу користувачі можуть втрачати сесії;
балансувальник має підтримувати таку поведінку.
Тому для важливих сесій зазвичай надають перевагу спільному сховищу, а не прив’язаності користувача до конкретного екземпляра.
Інший підхід — не зберігати сесію на сервері, а передавати контекст клієнту в підписаному токені.
Сервіс у такому випадку:
отримує токен;
перевіряє його підпис;
перевіряє термін дії;
використовує дані з токена.
Це наближає сервіс до stateless-моделі: будь-який екземпляр може перевірити токен, маючи потрібний секрет або ключ.
Водночас токен не слід сприймати як довільне сховище даних:
не потрібно зберігати в ньому секрети;
розмір токена має залишатися невеликим;
після видачі токен може бути складно відкликати;
ключі підпису потрібно безпечно зберігати та оновлювати;
підпис гарантує цілісність, але сам по собі не шифрує вміст.
Для короткоживучої авторизації токени можуть бути зручними. Для сесій, які потрібно негайно відкликати або централізовано змінювати, спільне сховище часто дає кращий контроль.
Стан не обов’язково має зберігатися в самому сервісі.
Можливий розподіл:
постійні бізнес-дані — у базі даних;
короткоживучі сесії — у спеціальному сховищі сесій;
кешовані значення — у кеші;
локальні обчислювальні дані — у пам’яті процесу, якщо їх можна безпечно втратити;
контекст запиту — у параметрах, заголовках або тілі запиту.
Ключове питання:
Чи може інший екземпляр сервісу обробити цей запит, не маючи локальної пам’яті попереднього екземпляра?
Якщо так — сервіс поводиться як stateless. Якщо ні — він залежить від локального стану.
Підходить, коли:
запити легко містять потрібний контекст;
токен має короткий термін життя;
не потрібне складне централізоване відкликання;
важливо направляти запити на будь-який екземпляр.
Підходить, коли:
сесію потрібно централізовано завершувати;
стан змінюється під час роботи;
у сесії є дані, які не варто передавати клієнту;
потрібно зберігати складніший контекст;
важливі контроль і негайні зміни стану.
Підходить лише для обмежених випадків:
локальний кеш, втрату якого можна пережити;
тимчасові дані під час одного процесу;
розробка або тестування;
спеціальні системи, де масштабування не потрібне.
Для користувацьких сесій у масштабованому production-сервісі такий підхід зазвичай ненадійний.
Для cookie з ідентифікатором сесії важливі атрибути:
HttpOnly — забороняє доступ до cookie з JavaScript у браузері;
Secure — передає cookie лише через HTTPS;
SameSite — зменшує ризик небажаних міжсайтових запитів;
Path — обмежує маршрути, для яких cookie надсилається;
Max-Age або Expires — визначає термін життя cookie.
У прикладі Secure не встановлено, тому що він запускається через звичайний локальний HTTP. У production із HTTPS його потрібно додати.
Також сервер має:
генерувати непередбачувані ідентифікатори сесій;
не використовувати послідовні ідентифікатори;
видаляти або замінювати сесію після входу;
завершувати сесію під час logout;
обмежувати термін її дії.
Map після горизонтального масштабуванняОдин екземпляр не бачить дані іншого. Для спільних сесій потрібне зовнішнє сховище або stateless-підхід.
Сервіс може читати й записувати базу даних, але бути stateless, якщо не залежить від локальної пам’яті конкретного процесу.
Це приховує проблему, але не усуває її. Втрата екземпляра все одно може призвести до втрати локальних сесій.
Cookie надсилається з кожним запитом, може бути викрадена або змінена. Краще зберігати там лише мінімальний ідентифікатор або правильно підписаний токен.
Без expiration старі сесії можуть накопичуватися у сховищі та залишатися активними довше, ніж потрібно.
Stateless-токен, виданий на тривалий час, може залишатися дійсним навіть після logout. Для сценаріїв із негайним відкликанням потрібен додатковий механізм або серверне сховище стану.
Під час проєктування поставте такі запитання:
Чи може кожен запит містити весь потрібний контекст?
Чи можна безпечно передати цей контекст клієнту?
Чи потрібне негайне відкликання сесії?
Чи має стан переживати перезапуск процесу?
Чи повинен будь-який екземпляр обробити запит?
Який термін життя цього стану?
Чи можна втратити цей стан без помилки для користувача?
Зазвичай хороший напрямок такий:
бізнес-дані зберігати в надійному постійному сховищі;
сесії не тримати лише в локальній пам’яті;
stateless-обробку використовувати там, де вона природна;
stateful-стан винести у спільне сховище, якщо його потрібно зберігати між екземплярами;
локальну пам’ять залишати для даних, втрату яких можна безпечно пережити.
Stateless-сервіс не залежить від локального стану конкретного екземпляра.
Stateful-сервіс використовує стан, збережений між запитами.
Наявність бази даних не робить сервіс автоматично stateful.
Локальні сесії в пам’яті погано працюють при горизонтальному масштабуванні.
Для спільних сесій можна використати зовнішнє сховище.
Sticky sessions зменшують проблему, але створюють залежність від конкретного екземпляра.
Підписані токени дають stateless-підхід, але ускладнюють негайне відкликання.
Вибір залежить від вимог до масштабування, безпеки, терміну життя та керування станом.