Сессии и контексты в Node.js

В долгоживущем Node-процессе сессия пользователя живёт в памяти десятками минут/часов. Контекст запроса нужно изолировать чтобы данные одного юзера не утекали другому через общий scope модуля.

Сессия в долгоживущем процессе

В PHP/Apache процесс пересоздаётся на каждый запрос — сессия в файле/БД, читается каждый раз. В Node всё иначе:

// Сессии прямо в Map в памяти процесса
const sessions = new Map();

function login(userId) {
  const token = crypto.randomUUID;
  sessions.set(token, { userId, lastSeen: Date.now() });
  return token;
}

function authenticate(token) {
  const s = sessions.get(token);
  if (!s) return null;
  s.lastSeen = Date.now(); // touch
  return s;
}

// + Periodic cleanup
setInterval(() => {
  const now = Date.now();
  for (const [t, s] of sessions) {
    if (now - s.lastSeen > 30 * 60_000) sessions.delete(t);
  }
}, 60_000);

Плюсы: 0 latency на чтение, можно хранить богатый state (открытые WebSocket, кэш расчётов). Минусы: при рестарте теряется, при кластере нужна sticky session или общий Redis.

Контекст запроса (request context)

Главная угроза — утечка данных между запросами:

// ❌ ПЛОХО: общий scope модуля
let currentUser; // утекает между запросами

app.use((req, res, next) => {
  currentUser = req.user; // ← race condition!
  next;
});

app.get('/api/profile', () => sendProfile(currentUser));
// При параллельных запросах currentUser перетирается → user A видит данные B
// ✅ ПРАВИЛЬНО: AsyncLocalStorage
const { AsyncLocalStorage } = require('async_hooks');
const ctx = new AsyncLocalStorage();

app.use((req, res, next) => {
  ctx.run({ user: req.user, traceId: req.id }, () => next);
});

function getCurrentUser() {
  return ctx.getStore?.user;
}
// Каждая async chain имеет свой store, никаких пересечений

Альтернативы AsyncLocalStorage

  • Пробрасывать context явно: первый аргумент каждой функции (ctx, ...). Многословно, но прозрачно
  • vm.createContext на каждый запрос — изоляция полная, но дорого
  • Worker thread на каждый запрос — для критичных по безопасности кейсов

Миграция сессий между процессами

При graceful shutdown воркера в cluster нужно перенести активные сессии:

  1. Воркер получает SIGTERM
  2. Перестаёт принимать новые соединения
  3. Сериализует сессии и отправляет в master через IPC
  4. Master распределяет сессии между живыми воркерами
  5. Воркер завершается

Подводные камни

  • Общий scope модуля = глобальные переменные — никогда не клади в них request-scoped данные
  • AsyncLocalStorage overhead — есть, но в большинстве приложений незаметен
  • req.user mixin — Express-стиль примеси удобен, но не выносить за пределы request
  • Redis-сессии возвращают latency — компромисс между производительностью и масштабированием
  • JWT не сессия — JWT stateless, нет invalidation; настоящие сессии нужны для logout/ban

🎓 Источники

  • 🎓 [Sessions and contexts on Node.js and Metarhia] · 2021-02-05 · YouTube · [Marp](../../../Documents/TimurShemsedinov/2021-02-05 — 💻 Sessions and contexts on Node.js and the Metarhia tech stack (5u8imY9SJiQ).md)
    • Тезисы: сессия в памяти долгоживущего процесса, изоляция per-request, миграция при шатдауне
  • 🎓 [Безопасность приложений Node.js] · 2019-11-28 · YouTube
    • Тезисы: общая память запросов — главная уязвимость Node по сравнению с PHP, mixin к req/res провоцирует утечки, коннект перешёл к другому юзеру
    • Цитата: «Глобальное состояние и data race — главная причина утечек данных в Node»
  • 🎓 [Введение в Node js и серверный JavaScript] · 2019-11-16 · YouTube
    • Тезисы: сессия пользователя живёт долго; миграция сессий между процессами при scale; в Node не нужен REST т.к. сессия в памяти

См. также