Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

NodesControl — Балансировщик запросов со Sticky Sessions

Замечание о выборе технологии. PHP — ужасный выбор для реализации балансировщика запросов. PHP работает по модели «запрос-ответ»: каждый запрос поднимает процесс, выполняет синхронную работу и умирает. Балансировщик же по своей природе — долгоживущий сетевой сервис, который должен держать множество соединений одновременно.

php artisan serve усугубляет ситуацию — это однопоточный dev-сервер. Пока балансировщик ждёт ответ от ноды (до 5 сек), все остальные запросы стоят в очереди.

В реальном проекте балансировка решается на другом уровне:

  • Nginx/HAProxy — конфигурация upstream + ip_hash для sticky sessions из коробки
  • Go/Rust сервис — горутины/async для тысяч одновременных соединений
  • Envoy / Traefik — готовые решения с service discovery

Данная реализация на PHP/Laravel выполнена в рамках условий тестового задания.

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

Стек

  • PHP 8.2+ / Laravel 12
  • PostgreSQL — архив сессий
  • Redis — хранение маппинга сессий и списка нод ("в памяти балансировщика")

Установка

git clone <repo-url>
cd NodesControl
composer install
cp .env.example .env
php artisan key:generate

Настроить подключения в .env:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=nodes_control
DB_USERNAME=app
DB_PASSWORD=secret

REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Применить миграции:

php artisan migrate

Запустить:

php artisan serve

API

Обработка нажатия

POST /api/press
Content-Type: application/json

{
    "session_id": "user123"
}

→ {"message": "You clicked 5 times"}

Пользователь в рамках сессии всегда попадает на одну ноду (sticky session). Новые сессии создаются на ноде с минимальным количеством активных сессий.

Управление нодами

POST /api/node
Content-Type: application/json

{
    "action": "add",
    "address": "http://node1:8080"
}

→ {"message": "OK"}
action Поведение
add Добавляет ноду в кластер с 0 активных сессий
remove Вызывает /clean с archive=true на ноде, удаляет из кластера

Архитектура

Запрос → FormRequest (валидация) → DTO → Service (бизнес-логика) → Repository (БД)

Структура

app/
├── Console/Commands/       Artisan-команды (очистка сессий)
├── DTO/                    Data Transfer Objects
├── Http/
│   ├── Controllers/        Тонкие контроллеры
│   └── Requests/           FormRequest валидация
├── Models/                 Eloquent модели
├── Repositories/           Работа с PostgreSQL
└── Services/               Бизнес-логика балансировки

Хранение данных

Хранилище Данные Формат
Redis (hash) Список нод + счётчики сессий balancer:nodes → {url: count}
Redis (str) Привязка сессии к ноде (TTL 10 мин) balancer:session:{id} → url
PostgreSQL Архив истёкших сессий Таблица session_archive

Обработка сбоев

  • При недоступности ноды — исключение из кластера, данные считаются утерянными
  • Запрос перенаправляется на другую ноду
  • При удалении ноды — сначала архивация через /clean, затем удаление

Очистка сессий

Команда php artisan sessions:clean-expired запускается по расписанию каждую минуту. Находит сессии с TTL < 60 секунд, вызывает /clean на нодах для архивации, удаляет маппинги из Redis.


Известные допущения и ограничения

Ноды — условные

Ноды не реализованы (по условию ТЗ). NodeClientService отправляет HTTP-запросы на указанные адреса, предполагая, что ноды отвечают по API из ТЗ (POST /press, POST /clean). Формат ответа ноды на /press ожидается как строка, содержащая число кликов (например, "You clicked 5 times").

Восстановление из архива — подмена ответа

API ноды не поддерживает создание сессии с заданным количеством кликов. Поэтому при восстановлении из архива балансировщик сохраняет base_clicks в Redis и прибавляет их к ответу ноды. Нода думает, что пользователь кликнул 1 раз, а балансировщик возвращает, например, 51. Это вынужденный хак в рамках ограниченного API ноды.

/clean на ноде — атомарная операция

По коду ноды из ТЗ, /clean архивирует все сессии на ноде, без выборочной фильтрации. Это означает, что при очистке истекающих сессий через sessions:clean-expired вызов /clean затронет и живые сессии на той же ноде. В текущей реализации после /clean из Redis удаляются только истекающие сессии.

Счётчик сессий на нодах может рассинхронизироваться

Redis TTL-ключи сессий истекают автоматически, но счётчик в balancer:nodes не уменьшается при этом. Команда sessions:clean-expired частично компенсирует это, обрабатывая сессии с TTL < 60 секунд. Между запусками команды (раз в минуту) возможна временная рассинхронизация счётчиков. В продакшене можно добавить периодическую пересинхронизацию через SCAN.


Тестовое задание

Полный текст задания

Балансировщик запросов с sticky sessions

Пусть есть некоторый API некого сервиса. Обращение пользователя к данному сервису подразумевает отправку HTTP-запроса, его обработку, и ответ на него. Для простоты понимания сервис будет отслеживать количество нажатий пользователя на кнопку. Приведем реализацию echo server'а, принимающего пользовательский "клик" и возвращающего количество нажатий на кнопку:

class EchoServer
{
    private $sessionHandler;
    private $databaseHandler;
    ...

    function pressBtn($session_id)
    {
        $session = $this->sessionHandler.read($session_id);
        if (empty($session) || empty($session['clicks'])) {
            $session = ['clicks' => 0];
        }

        $clicks = $session['clicks'] + 1;
        $session.set('clicks', $clicks);
        $this->sessionHandler.write($session_id, $session);

        sendAnswer("You clicked the button $clicks times");
    }

    function clean($archive)
    {
        foreach ($this->sessionHandler as $session_id => $session) {
            $this->databaseHandler.setClicks($session_id, $session['clicks']);
            $this->sessionHandler.delete($session_id);
        }
    }
}

Наша кнопка стала настолько востребована, что понадобилось распределить нагрузку на несколько машин (нод). Для этого было решено организовать балансировщик запросов. Так нода - это просто HTTP-сервер с API, который обрабатывает нажатия. Кластер - совокупность всех активных нод. Балансировщик - это PHP скрипт, который принимает запросы от пользователей и перенаправляет их на ноды.

Цель: Создать простой балансировщик, который распределяет пользовательские сессии между нодами.

Требования:

  1. Основная логика:

    • Пользователь в рамках сессии (session_id не меняется) всегда попадает на одну ноду;
    • Новые сессии создаются на ноде с минимальным количеством активных сессий;
    • Привязка сессий к нодам хранится в памяти балансировщика;
    • Данные сессии (количество кликов) хранятся на ноде;
    • Сессия пользователя не превышает 10 минут. Далее сессия удаляется, данные переносятся в общий архив (PostgreSQL). Далее может быть создана новая сессия на другой ноде на основе архивных данных;
    • Список нод изначально пуст. Администратор может добавить / удалить ноду в процессе работы;
    • При недоступности ноды балансировщик должен исключить её из кластера и создать новые сессии на других нодах с восстановлением данных из архива.
  2. API балансировщика:

    POST /press        {"session_id": "user123"}         → "You clicked 5 times"
    POST /node         {"action": "add|remove", "address": "http://node1:8080"}
    
  3. API ноды (предполагается, что нода уже реализована):

    POST /press        {"session_id": "user123"}         → "You clicked 5 times"
    POST /clean        {"archive": true|false}           → "OK"
    
  4. Структура данных архива (PostgreSQL):

    CREATE TABLE session_archive (
        session_id VARCHAR(255) PRIMARY KEY,
        clicks INT DEFAULT 0,
        archived_at TIMESTAMP
    );

Что не нужно делать:

  • Реализовывать ноду. Достаточно приведенного шаблона;
  • Перемещение активных сессий между нодами;
  • Сложную проверку доступности нод. Достаточно try-catch при запросе;
  • Веб-интерфейс управления;
  • Балансировку по CPU/RAM и другим метрикам.

Критерии оценки:

  1. Работает sticky sessions;
  2. Новые сессии распределяются равномерно;
  3. Архив корректно используется;
  4. Чистый, понятный код.

Примечания:

  1. Считать архивацию на стороне ноды корректной;
  2. При удалении ноды балансировщик должен вызвать /clean с archive=true для архивации данных;
  3. При недоступности ноды данные считаются утерянными Создаются новые сессии на других нодах с данными из архива (если есть архивные данные).

Рекомендуемые технологии:

  1. PHP 7.4+
  2. PostgreSQL для архива
  3. Любой микрофреймворк для реализации API (Slim, Lumen) или чистый PHP

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages