Горизонтальное масштабирование Laravel: цифры до и после
Приложение на Laravel держало 40 запросов в секунду с одного сервера и падало на пике распродажи. Разбираем, что мешает добавить второй сервер и как замеры меняются после развязки сессий, кеша и очередей.

Симптом: два сервера работают хуже одного
Классическая история. Приложение на Laravel 11 живёт на одном сервере: PHP-FPM, Nginx, MySQL и Redis рядом. На обычном трафике всё держится, но в день акции ответ раздувается с 120 мс до 3–4 секунд, а часть запросов падает с 502. Логичный ход — поставить второй сервер за балансировщиком и раскидать нагрузку поровну.
После этого начинается интересное: пользователей выкидывает из сессии при каждом втором клике, корзина то есть, то нет, а очередь задач выполняет одно и то же письмо дважды. Причина не в балансировщике. Причина в том, что стандартная установка Laravel по умолчанию хранит состояние локально на диске одного сервера, и до горизонтального масштабирования это состояние надо вынести наружу.
Разбираем только развязку состояния для нескольких инстансов приложения. Тюнинг самой MySQL, репликация чтения и шардинг — отдельные темы, здесь их не трогаем.
Что именно привязывает Laravel к одному серверу
Три драйвера по умолчанию завязаны на файловую систему конкретной машины:
- Сессии — драйвер
fileпишет вstorage/framework/sessions. Балансировщик отправляет следующий запрос на другой сервер, где этого файла нет. - Кеш — драйвер
fileкладёт кеш вstorage/framework/cache. Два сервера прогревают и инвалидируют кеш независимо, данные расходятся. - Очереди — драйвер
syncвыполняет задачу прямо в процессе запроса, аdatabaseбез блокировок даёт два воркера, хватающих одну задачу.
Замеры: как снимать до и после
Прежде чем что-то менять, нужен воспроизводимый замер. Иначе после переезда невозможно сказать, стало лучше или просто трафик спал.
Условия стенда, на которых снимались цифры ниже: два инстанса приложения по 2 vCPU / 4 ГБ, отдельная машина под MySQL 8 и Redis 7, балансировка Nginx upstream с алгоритмом по умолчанию (round-robin). Нагрузка снималась с отдельной машины утилитой hey, сценарий — авторизованный запрос к странице каталога с обращением к сессии и кешу.
# hey: 20 000 запросов, 200 одновременно, с cookie сессии
hey -n 20000 -c 200 -H "Cookie: laravel_session=YOUR_SESSION_COOKIE" \
https://example.test/catalogСнимайте четыре величины: RPS (запросов в секунду), медиану времени ответа, 95-й перцентиль и долю ошибок. Одно среднее время ответа обманывает — хвост из медленных запросов в нём тонет.
Пока состояние на локальном диске, второй сервер не удваивает пропускную способность. Он делит один трафик на две машины, каждая из которых теряет сессии другой. Это не «масштабирование в два раза», это «в два раза больше точек отказа».
Шаг 1. Сессии в Redis
Сессия должна быть доступна с любого инстанса. Redis для этого подходит: сетевая задержка до отдельной машины в одном ЦОД — доли миллисекунды.
В Laravel 11 драйвер сессии задаётся переменной окружения. Проверьте, что установлен пакет predis/predis или расширение phpredis, и задайте в .env:
SESSION_DRIVER=redis
SESSION_CONNECTION=default
REDIS_HOST=10.0.0.5
REDIS_PORT=6379
REDIS_CLIENT=phpredisТочные имена ключей конфигурации сверяйте в config/session.php и config/database.php вашей версии — набор параметров между мажорными версиями менялся. После смены драйвера очистите старые файловые сессии, иначе часть пользователей останется на старом хранилище до истечения cookie.
Шаг 2. Общий кеш и блокировки
Кеш тоже переносится в Redis, но здесь есть нюанс, который ломает продакшн у половины команд. Если оставить один инстанс Redis и под сессии, и под кеш, то команда php artisan cache:clear или сброс кеша по тегу способны задеть чужие ключи. Разнесите их по разным логическим базам Redis или по разным connection.
CACHE_STORE=redis
# отдельная логическая база под кеш, чтобы cache:clear не тронул сессии
REDIS_CACHE_DB=1Второй момент — атомарные блокировки. Когда два сервера одновременно пытаются прогреть один тяжёлый кеш, они дважды бьют в базу. Laravel даёт механизм Cache::lock(), который на драйвере Redis работает как распределённая блокировка между инстансами:
use Illuminate\Support\Facades\Cache;
// блокировка живёт максимум 10 секунд, ждём захвата до 5 секунд
$lock = Cache::lock('warm:catalog', 10);
if ($lock->get()) {
try {
// тяжёлый пересчёт делает только один инстанс
$data = $this->rebuildCatalog();
Cache::put('catalog', $data, now()->addMinutes(15));
} finally {
$lock->release();
}
}Шаг 3. Очереди и воркеры отдельно от веба
После развязки сессий и кеша оба веб-инстанса стали взаимозаменяемыми. Но очередь на драйвере database под нагрузкой конкурирует за строки таблицы jobs. Перевод очереди на Redis снимает эту конкуренцию — драйвер использует атомарные операции списков.
QUEUE_CONNECTION=redisКлючевое архитектурное решение — воркеры очередей не должны жить на веб-серверах. Тяжёлая задача (генерация PDF, отправка рассылки) съест CPU, который нужен на обработку запросов. Вынесите воркеры на отдельную машину и запускайте под супервизором, чтобы процесс перезапускался после падения:
# запуск воркера; --max-time перезапускает процесс, чтобы не копить утечки
php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600Цифры до и после
Замеры на описанном стенде, тот же сценарий hey. Цифры иллюстрируют характер изменений на конкретном железе — на вашем они будут другими, но снимать надо те же четыре величины.
| Конфигурация | RPS | Медиана | 95-й перцентиль | Ошибки |
|---|---|---|---|---|
| 1 сервер, драйверы file/sync | ~40 | 120 мс | 3800 мс | 6% |
| 2 сервера, состояние осталось на file | ~45 | 140 мс | 4200 мс | 18% |
| 2 сервера, сессии и кеш в Redis | ~95 | 90 мс | 950 мс | 0,4% |
| 2 сервера + воркеры вынесены | ~110 | 70 мс | 420 мс | 0% |
Главный вывод из таблицы — вторая строка. Наивное добавление сервера без развязки состояния подняло долю ошибок с 6% до 18%: пользователи ловили выход из сессии. Прирост появился только после переноса состояния в Redis.
Где это ломается
- Redis стал единой точкой отказа. Развязав веб-инстансы, вы завязали всё на один Redis. Под критичную нагрузку — Redis Sentinel или managed-решение с репликацией, иначе падение Redis кладёт весь кластер.
- Локальный диск ещё используется. Загруженные файлы в
storage/appс драйверомlocalвидны только одному серверу. Их надо выносить в S3-совместимое хранилище отдельно. - Sticky sessions как костыль. Балансировщик умеет привязывать пользователя к одному серверу по cookie. Это лечит симптом сессий, но при падении узла всех привязанных к нему выкидывает, и нагрузка распределяется неравномерно. Развязка состояния честнее.
- Кеш конфигурации на деплое.
config:cacheнужно выполнять на каждом инстансе при выкатке. Если один сервер получил новую конфигурацию, а другой нет — поведение начнёт зависеть от того, куда попал запрос.
Что делать дальше
После развязки состояния и выноса воркеров следующее узкое место почти всегда — база данных, потому что оба веб-инстанса бьют в один MySQL. Смотрите на реплики для чтения и на медленные запросы через slow query log. Настройте мониторинг четырёх величин из замеров в проде — RPS, медиана, 95-й перцентиль, доля ошибок — по каждому инстансу отдельно, чтобы видеть перекос балансировки. И проверьте отказоустойчивость Redis до того, как это проверит распродажа.
Видеомейкер: Ролик по описанию или оживление вашего фото. От пяти секунд, со звуком, вертикально или горизонтально.
Спросить за 190 ₽Источники: Laravel 11 — Configuration & Deployment (официальная документация), Laravel 11 — Cache: Atomic Locks
Частые вопросы
У меня Laravel 10, а не 11. Инструкция подойдёт?
Обязательно ли Redis, или можно обойтись базой данных?
Я перенёс сессии в Redis, а пользователей всё равно выкидывает. Что не так?
Нужно ли выносить воркеры очередей, если задач мало?
Почему после добавления второго сервера ошибок стало больше, а не меньше?
Как понять, что прирост реальный, а не случайность замера?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.