MAATRIX / Блог / Липкие сессии изнутри: чем система платит за то, что пользователь возвращается на тот же сервер

Липкие сессии изнутри: чем система платит за то, что пользователь возвращается на тот же сервер

MAATRIX

Как только за приложением встаёт больше одного backend-сервера, всплывает вопрос: а что делать с сессией пользователя, если она хранится в памяти конкретного процесса? Самый быстрый ответ — прилепить пользователя к тому серверу, где эта сессия появилась. Работает сразу, не требует переписывать код. Но у этого решения есть цена, и платить её приходится не в момент настройки, а позже — когда нагрузка перестаёт делиться поровну и когда один из серверов падает.

Что решает липкая сессия

Классическое веб-приложение хранит сессию пользователя где-то рядом с процессом, который её обрабатывает: в памяти самого приложения, во временных файлах на диске сервера, иногда в локальном Redis, который никто не собирался делать общим. Пока запросы одного пользователя всегда попадают на один и тот же сервер, всё работает: сервер помнит, что пользователь залогинен, что лежит в его корзине, на каком шаге многоэтапной формы он остановился.

Проблема начинается, когда перед этим приложением ставят балансировщик и несколько одинаковых backend-серверов. Балансировщик по умолчанию не обязан отправлять запросы одного и того же браузера на один и тот же сервер — при round-robin или least connections следующий запрос вполне может улететь на соседнюю ноду, у которой в памяти нет ни малейшего представления о сессии этого пользователя. Итог — случайные разлогинивания, пустая корзина через один клик, сброшенный прогресс формы.

Липкая сессия (sticky session) — это правило балансировщика, которое говорит: если запрос пришёл от клиента, у которого уже была сессия на сервере B, отправляй туда же, а не туда, куда обычно попал бы следующий запрос по алгоритму балансировки. Проблема с локальным состоянием при этом не решается — она просто маскируется тем, что клиент почти всегда общается с одним и тем же сервером.

Как это реализовано технически

Балансировщик должен как-то узнавать, к какому серверу «прилип» конкретный клиент. Есть два основных механизма, и они принципиально разные.

Cookie, которую ставит сам балансировщик. Например, в HAProxy это делается через cookie в описании backend:

backend app_servers
    balance roundrobin
    cookie SRV_ID insert indirect nocache
    server app1 10.0.0.11:8080 check cookie app1
    server app2 10.0.0.12:8080 check cookie app2
    server app3 10.0.0.13:8080 check cookie app3

При первом запросе HAProxy сам добавляет в ответ cookie SRV_ID=app2, и на все последующие запросы с этой cookie отправляет трафик именно на app2 — независимо от алгоритма балансировки. Ключевая деталь: cookie ставит и читает сам балансировщик, приложению вообще не нужно знать об этом механизме.

В nginx (в открытой версии) встроенной поддержки sticky-cookie нет — она есть только в nginx Plus. На обычном nginx для этого либо используют модуль nginx-sticky-module, либо идут другим путём — привязкой по IP-адресу клиента:

upstream app_servers {
    ip_hash;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
    server 10.0.0.13:8080;
}

ip_hash — это второй механизм привязки: не по cookie, а по хешу IP-адреса клиента. У него свои минусы — все пользователи за одним NAT (офис, мобильный оператор с CGNAT) хешируются в один и тот же backend, то есть привязка получается грубее, чем хотелось бы, а при изменении числа серверов в апстриме хеш почти для всех клиентов пересчитывается заново.

Третий вариант — привязка на уровне приложения через собственную cookie (например, JSESSIONID с суффиксом имени ноды в Tomcat), которую балансировщик умеет разбирать и парсить, чтобы направить запрос на нужный сервер. Логика та же, разница только в том, кто именно генерирует идентификатор — балансировщик или приложение. О том, чем в целом отличаются подходы HAProxy и nginx к балансировке нагрузки, отдельно написано в сравнении HAProxy и nginx для балансировки.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Первая цена: нагрузка перестаёт делиться поровну

Пока сессий мало и они короткие, привязка почти не заметна — пользователи быстро перераспределяются между серверами естественным образом. Проблема нарастает вместе с тем, как растёт время жизни сессии и как активность пользователей становится неравномерной.

Представим, что на сервер A в какой-то момент «прилипло» непропорционально много активных пользователей — просто потому, что именно в этот час они зашли, а балансировщик на тот момент честно распределял новые сессии по кругу. Дальше эти сессии продолжают жить: пользователи листают каталог, добавляют товары в корзину, обновляют страницы. Все эти запросы идут на сервер A, потому что там их сессия. Балансировщику формально всё равно, что A сейчас нагружен сильнее остальных, — новую сессию он направит туда, куда положено по алгоритму, но уже существующие сессии он трогать не будет, у них есть cookie с явным указанием сервера.

В результате метрики CPU и памяти по нодам расходятся не потому, что где-то хуже железо или хуже код, а потому что распределение активных сессий само по себе неравномерно, и стандартные алгоритмы балансировки (round-robin, least connections) на это никак не реагируют — они управляют распределением новых подключений, а не уже прилипших. Долгоживущие сессии (админка, где сотрудники сидят часами; SaaS с длинными рабочими сессиями) усиливают эффект — перекос накапливается за день и почти никогда не выравнивается сам по себе, пока сессии не истекут или пользователи не переоткроют вкладку с новой cookie.

Частичное решение — weighted least connections с учётом текущей нагрузки при выборе сервера для *новых* подключений, чтобы новые сессии шли туда, где сейчас легче. Но это лечит симптом на будущее, а не перекос, который уже накопился среди активных сессий.

Вторая цена: что происходит при падении сервера

Это цена, которую замечают быстрее, потому что она бьёт не по метрикам, а напрямую по пользователям. Health check балансировщика замечает, что сервер A не отвечает, и выводит его из ротации. С точки зрения инфраструктуры — правильное, ожидаемое поведение: трафик уходит на живые серверы, downtime сервиса в целом не случается.

Но что происходит с пользователем, чья сессия жила именно на A? Балансировщик переподключает его к любому другому доступному серверу — B или C. А там про эту сессию ничего не знают: в памяти этого процесса её никогда не было. Итог для пользователя — мгновенный logout, пустая корзина, обнулённый прогресс в форме, которую он заполнял пять минут. Внешне это выглядит как «сайт работает», потому что HTTP 200 отдаётся исправно — просто пользователю приходится начинать сначала.

Хуже, если сессия хранилась не только в памяти, а частично на диске сервера — временные файлы загрузки, промежуточные данные многошаговой операции. Такие данные при падении ноды теряются безвозвратно: их просто негде взять на других серверах.

Отдельная ловушка — путать sticky session с отказоустойчивостью. Липкая сессия не реплицируется между серверами, у неё нет резервной копии. Это чистая привязка, без какого-либо механизма восстановления. Если требуется, чтобы падение одного backend-сервера не било по пользовательским сессиям, sticky session эту задачу не решает в принципе — она решает только задачу маршрутизации при исправно работающих серверах.

Альтернатива: вынести состояние наружу

Раз корень проблемы — состояние сессии, привязанное к конкретному процессу, логичный путь решения — перестать хранить его локально. Сессия переезжает во внешнее общее хранилище, доступное всем backend-серверам одинаково, а сами серверы становятся полностью stateless: любой из них может обработать любой запрос любого пользователя.

Чаще всего для этого берут Redis — он быстрый, поддерживает TTL из коробки (сессия истекает сама, без отдельной уборки) и есть готовые адаптеры почти под любой стек. Базовая установка описана в статье как установить и настроить Redis на VPS; если стоит выбор между Redis и Memcached именно под сессии, там же есть разбор в статье Redis или Memcached — что выбрать для сервера.

Node.js / Express:

const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

const redisClient = createClient({ url: 'redis://10.0.0.20:6379' });
redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 30 * 60 * 1000 }
}));

PHP (session handler на Redis, если стоит расширение redis):

; php.ini или пул php-fpm
session.save_handler = redis
session.save_path = "tcp://10.0.0.20:6379?timeout=2.5"

Django:

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://10.0.0.20:6379/1",
    }
}

После этого backend-серверы можно снова балансировать любым обычным алгоритмом — round-robin, least connections — без cookie-привязки, потому что любому серверу для обработки запроса достаточно прочитать сессию из Redis по её идентификатору. Падение одного backend-сервера теперь не роняет сессии пользователей, которые на нём оказались, — балансировщик просто перенаправляет их запросы на другой сервер, а тот читает то же самое состояние из общего хранилища.

Это не бесплатно. Появляется сетевой round-trip до Redis на каждое обращение к сессии (обычно доли миллисекунды в той же локальной сети, но это уже не чтение из памяти процесса). Redis сам становится точкой отказа — если он недоступен, недоступны все сессии сразу, а не только на одном сервере, поэтому для сессий в проде обычно нужна минимум реплика. И придётся аккуратно продумать сериализацию — какие данные вообще имеет смысл держать в сессии, а какие лучше сразу класть в БД, потому что Redis для сессий обычно настраивают с ограничением по памяти и политикой вытеснения, и раздувать сессию до мегабайтов — плохая идея.

Когда липкие сессии всё-таки оправданы

Несмотря на всё сказанное, полностью списывать sticky sessions со счетов неверно — у них есть законные сценарии.

WebSocket и другие долгоживущие соединения по своей природе привязаны к конкретному TCP-соединению с конкретным сервером — тут привязка не костыль, а следствие протокола: балансировщик просто обязан держать одно физическое соединение на одном backend, потому что переключить его на лету по определению нельзя.

Легаси-приложения, где сессия хранит действительно тяжёлые объекты (закешированный результат дорогого вычисления, состояние сложного визарда с промежуточными файлами), а переписывать логику под внешнее хранилище дорого прямо сейчас, — здесь sticky session как временная мера снижает риск немедленно, а вынос состояния можно сделать отдельным проектом.

Ситуации с очень низкой чувствительностью к перебалансировке — например, внутренние админки с десятком одновременных пользователей, где перекос нагрузки физически не может стать проблемой из-за масштаба. В таком случае цена, о которой шла речь выше, просто не набирает вес — она есть теоретически, но практически ничтожна.

Важно только не путать «пока можем себе позволить sticky sessions» с «sticky sessions — правильная архитектура». Это разные утверждения, и первое обычно временное.

Как проверить, что у вас именно локальные сессии

Перед тем как что-то менять, стоит убедиться, что проблема вообще актуальна для конкретного приложения.

Проверить конфиг балансировщика на предмет sticky-механизма:

# HAProxy
grep -A5 "cookie" /etc/haproxy/haproxy.cfg

# nginx
grep -A5 "ip_hash\|sticky" /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf

Проверить в коде приложения, куда реально пишется сессия — часто это видно прямо в конфиге фреймворка (session.save_handler, SESSION_ENGINE, store: в опциях express-session) или, если конфиг неочевиден, по факту — где физически лежат файлы сессий на диске:

ls -la /var/lib/php/sessions/ 2>/dev/null
ls -la /tmp/sess_* 2>/dev/null

Если файлы есть и сессии хранятся именно так — это стопроцентно локальное хранилище, разнесённое по каждому серверу отдельно.

Практический тест: временно отключить sticky-привязку на одном из серверов в тестовом окружении (или локально прогнать два экземпляра приложения без sticky) и понаблюдать, разлогинивает ли пользователя при чередовании запросов между серверами. Если да — состояние действительно локальное, и без sticky sessions или без внешнего хранилища приложение просто не работает корректно при нескольких backend-серверах.

Ещё один частый источник сюрпризов — то, что балансировщик знает про привязку, а приложение — нет: конфиг sticky выставлен правильно, но кто-то параллельно добавил ещё один слой балансировки (например, перед основным балансировщиком встал CDN или ещё один прокси), и на этом слое привязка не настроена — тогда проблема возвращается, хотя вроде бы всё было учтено.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли одновременно использовать sticky session и Redis для сессий?

Технически да, но смысла в этом обычно нет: если сессия уже лежит в общем хранилище, доступном любому серверу, привязка к конкретной ноде больше не нужна для корректности — она может остаться разве что как оптимизация локального кеша перед Redis, но это отдельная, более тонкая настройка, а не стандартный сценарий.

Сколько серверов нужно, чтобы проблема неравномерной нагрузки стала заметной?

Однозначного порога нет — это зависит от длительности сессий и от того, насколько активность пользователей неравномерна по времени. На двух серверах с короткими сессиями перекос может быть незаметен месяцами, а на трёх-четырёх с сессиями, живущими часами, — проявиться уже в первую неделю. Проверяется это метриками CPU и памяти по нодам за сутки, а не теорией.

Что произойдёт с пользователем в середине запроса, если сервер, к которому он привязан, упадёт прямо во время обработки?

Текущий конкретный запрос почти наверняка завершится ошибкой (таймаут или сброс соединения) — балансировщик не может «на лету» перенести уже выполняющийся запрос на другой сервер. Дальше, при следующем запросе, клиента перенаправят на живой сервер, но, как и описано выше, без внешнего хранилища его сессия там уже не найдётся.

Даёт ли Redis Cluster или Redis с репликой полную гарантию, что сессии не потеряются при сбое?

Нет, полной гарантии не даёт ни один механизм — при переключении на реплику возможна потеря последних не успевших реплицироваться записей, а без правильно настроенной persistence (AOF/RDB) перезапуск самого Redis тоже способен обнулить данные. Внешнее хранилище радикально снижает вероятность потери сессий по сравнению с локальным хранением на одном сервере, но не делает эту вероятность нулевой — это стоит учитывать при выборе конфигурации.

Нужно ли выносить сессии в Redis, если backend-сервер всего один?

Нет практического смысла — привязка и перекос нагрузки существуют только там, где есть выбор между несколькими серверами. Но если в планах горизонтальное масштабирование, разумнее сразу проектировать stateless-серверы, чем позже мигрировать логику под нагрузкой.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →