Замечание о выборе технологии. 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 servePOST /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 архивирует все сессии на ноде, без выборочной фильтрации.
Это означает, что при очистке истекающих сессий через sessions:clean-expired
вызов /clean затронет и живые сессии на той же ноде.
В текущей реализации после /clean из Redis удаляются только истекающие сессии.
Redis TTL-ключи сессий истекают автоматически, но счётчик в balancer:nodes не уменьшается при этом.
Команда sessions:clean-expired частично компенсирует это, обрабатывая сессии с TTL < 60 секунд.
Между запусками команды (раз в минуту) возможна временная рассинхронизация счётчиков.
В продакшене можно добавить периодическую пересинхронизацию через SCAN.
Полный текст задания
Пусть есть некоторый 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 скрипт, который принимает запросы от пользователей и перенаправляет их на ноды.
Цель: Создать простой балансировщик, который распределяет пользовательские сессии между нодами.
Требования:
-
Основная логика:
- Пользователь в рамках сессии (session_id не меняется) всегда попадает на одну ноду;
- Новые сессии создаются на ноде с минимальным количеством активных сессий;
- Привязка сессий к нодам хранится в памяти балансировщика;
- Данные сессии (количество кликов) хранятся на ноде;
- Сессия пользователя не превышает 10 минут. Далее сессия удаляется, данные переносятся в общий архив (PostgreSQL). Далее может быть создана новая сессия на другой ноде на основе архивных данных;
- Список нод изначально пуст. Администратор может добавить / удалить ноду в процессе работы;
- При недоступности ноды балансировщик должен исключить её из кластера и создать новые сессии на других нодах с восстановлением данных из архива.
-
API балансировщика:
POST /press {"session_id": "user123"} → "You clicked 5 times" POST /node {"action": "add|remove", "address": "http://node1:8080"} -
API ноды (предполагается, что нода уже реализована):
POST /press {"session_id": "user123"} → "You clicked 5 times" POST /clean {"archive": true|false} → "OK" -
Структура данных архива (PostgreSQL):
CREATE TABLE session_archive ( session_id VARCHAR(255) PRIMARY KEY, clicks INT DEFAULT 0, archived_at TIMESTAMP );
Что не нужно делать:
- Реализовывать ноду. Достаточно приведенного шаблона;
- Перемещение активных сессий между нодами;
- Сложную проверку доступности нод. Достаточно try-catch при запросе;
- Веб-интерфейс управления;
- Балансировку по CPU/RAM и другим метрикам.
Критерии оценки:
- Работает sticky sessions;
- Новые сессии распределяются равномерно;
- Архив корректно используется;
- Чистый, понятный код.
Примечания:
- Считать архивацию на стороне ноды корректной;
- При удалении ноды балансировщик должен вызвать /clean с archive=true для архивации данных;
- При недоступности ноды данные считаются утерянными Создаются новые сессии на других нодах с данными из архива (если есть архивные данные).
Рекомендуемые технологии:
- PHP 7.4+
- PostgreSQL для архива
- Любой микрофреймворк для реализации API (Slim, Lumen) или чистый PHP