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

Монолит на Laravel 10.x, 40 контроллеров, одна база на 90 таблиц. Команда решает резать его на модули — сперва внутри одного репозитория (nWidart/laravel-modules), потом часть вынести в отдельные сервисы. На стейдже всё зелёное. В проде на 250–300 одновременных запросах начинают сыпаться SQLSTATE[40001]: Serialization failure: deadlock detected и рассинхрон кеша: пользователь видит старый баланс через 200 мс после оплаты. Разберём, что именно ломается первым при разбивке и почему модульная граница, красивая на схеме, под нагрузкой протекает.
Это разбор, а не инструкция по установке пакета. Сам процесс дробления кода на namespace здесь не рассматриваем — фокус на том, что перестаёт работать, когда модули начинают конкурировать за одни ресурсы под реальным трафиком.
Первое, что ломается: транзакции через границу модулей
В монолите оформление заказа — одна транзакция: списать со склада, создать заказ, начислить бонусы. Всё в DB::transaction(), откат бесплатный. Когда склад, заказы и бонусы становятся тремя модулями, каждый со своим сервисом, соблазн — вызывать их последовательно из оркестратора.
// Модульный оркестратор — так делать нельзя под нагрузкой
public function checkout(int $orderId): void
{
$this->warehouseService->reserve($orderId); // транзакция 1, закоммичена
$this->orderService->confirm($orderId); // транзакция 2
$this->bonusService->accrue($orderId); // транзакция 3 — упала
}
Пока модули живут в одной базе, соблазн — обернуть всё во внешнюю транзакцию. И тут выясняется, что модули брали блокировки в разном порядке: склад лочит stock, потом orders; бонусы — orders, потом bonuses. Два параллельных чекаута берут блокировки крест-накрест — PostgreSQL детектит взаимоблокировку и убивает одну из транзакций. На стейдже с одним потоком этого не видно никогда.
Модульность разбивает не только код — она разбивает транзакционную границу. Пока вы не решили, где эта граница проходит, «модуль» — просто новое имя для старой связности.
Как это ловить и чем чинить
Сначала — увидеть. Включите логирование взаимоблокировок в PostgreSQL 16 (log_lock_waits = on, deadlock_timeout = 1s) и посмотрите в логе, какие две транзакции сцепились и на каких таблицах. Дальше два пути:
- Единый порядок блокировок. Если модули пока в одной базе, договоритесь, что таблицы всегда лочатся в фиксированном порядке (по алфавиту имён, например). Дёшево, но это временный костыль: он держит модули связанными.
- Разорвать транзакцию и принять eventual consistency. Заказ подтверждается в своей транзакции, начисление бонусов уезжает в очередь. Тогда «атомарности» между модулями нет, зато нет и общих блокировок. Компенсацию при сбое (не начислили — вернуть заказ в статус «ожидает») пишете руками через
failed_jobs.
Второе: общий кеш и гонка инвалидации
В монолите кеш профиля пользователя чистил тот же код, что его менял. После разбивки профиль читают три модуля, а меняет один. Классическая гонка: модуль A прочитал устаревшее значение до инвалидации, модуль B записал новое и сбросил кеш, A положил своё старое обратно.
// Гонка: между get и set другой процесс успел обновить данные
$balance = Cache::get("user:{$userId}:balance");
if ($balance === null) {
$balance = $this->repository->loadBalance($userId); // тут другой модуль уже списал
Cache::put("user:{$userId}:balance", $balance, 300); // записали протухшее
}
Под нагрузкой это стреляет на любых горячих ключах. В Laravel 10.x/11.x есть Cache::lock() — атомарная блокировка поверх Redis, ей и лечится dog-pile при прогреве. Но lock решает только гонку двух процессов на одном ключе; он не решает, чей модуль отвечает за инвалидацию.
use Illuminate\Support\Facades\Cache;
$key = "user:{$userId}:balance";
$balance = Cache::get($key);
if ($balance === null) {
// блокировка на 10 сек, ждём до 5 сек
$balance = Cache::lock("lock:{$key}", 10)->block(5, function () use ($userId, $key) {
// повторная проверка внутри блокировки — данные могли прогреться
$cached = Cache::get($key);
if ($cached !== null) {
return $cached;
}
$fresh = $this->repository->loadBalance($userId);
Cache::put($key, $fresh, 300);
return $fresh;
});
}
Уточните доступность block() и семантику лока для вашего кеш-драйвера в документации Laravel — атомарные блокировки работают на Redis, Memcached, DynamoDB и database-драйвере, но не на file и array.
Правило одного владельца ключа
Гонку инвалидации не победить локами до тех пор, пока за один ключ отвечает несколько модулей. Границу проводят так: у каждого кеш-ключа один модуль-владелец, который его пишет и чистит. Остальные читают через его публичный интерфейс, а не лезут в Cache::get напрямую. Инвалидация кеша — большая отдельная тема, здесь важно только это: без единого владельца модули будут затирать друг друга.
Третье: пул соединений и очереди умножаются на число модулей
Незаметная мина. Монолит держал одно соединение с базой на воркер. После разбивки каждый модуль в цепочке чекаута открывает свою транзакцию, и если оркестратор дергает их синхронно в рамках одного запроса — соединение держится дольше. При том же числе воркеров и той же max_connections в PostgreSQL пул исчерпывается раньше, и вы получаете SQLSTATE[08006] could not connect to server: too many connections на трафике, который монолит держал спокойно.
То же с очередями. Разбили обработку на модульные джобы — стало три джобы вместо одной. Тот же поток заказов теперь генерирует втрое больше сообщений в Redis, и воркеры queue:work, настроенные на старый объём, начинают отставать. Отставание очереди — это и есть тот рассинхрон, который пользователь видит как «оплатил, а бонусы не пришли».
Что мерить до и после разбивки
Придумывать конкретные цифры для чужого проекта бессмысленно — снимите свои. Минимальный набор метрик под нагрузочным тестом (k6 или Locust, профиль трафика близкий к боевому):
| Метрика | Где смотреть | Красный флаг после разбивки |
|---|---|---|
| Deadlock-и в БД | лог PostgreSQL, pg_stat_database.deadlocks | появились там, где их не было |
| Активные соединения | pg_stat_activity | приблизились к max_connections |
| Длина очереди | Redis LLEN по очередям, Horizon | растёт быстрее, чем разгребается |
| p95 времени чекаута | APM / логи запросов | выросло из-за синхронных вызовов модулей |
| Расхождение данных | сверка агрегатов модулей раз в N минут | появилось после ухода в eventual consistency |
Где подход ломается окончательно
Модульный монолит в одной базе — компромисс, и у него есть граница применимости:
- Модули пишут в одни и те же таблицы. Тогда это не модули, а иллюзия границы: транзакции всё равно сцепляются. Пока таблицы общие, вы платите цену микросервисов, не получая их изоляции.
- Синхронная цепочка вызовов длиннее трёх модулей. Каждое звено добавляет задержку и держит соединение; p95 растёт нелинейно. На длинных цепочках переходите на асинхронные события.
- Требуется строгая согласованность между модулями. Если бизнес не терпит даже секундного расхождения (например, финансовый баланс должен быть точным в момент чтения), eventual consistency не подходит — оставляйте эти операции в одной транзакции и одном модуле.
Что делать дальше
Прежде чем резать код, нарисуйте карту транзакционных границ: какие операции обязаны быть атомарными, а какие переживут задержку. Это решение важнее выбора пакета для модулей. Дальше — прогоните нагрузочный тест на текущем монолите и снимите базовые цифры из таблицы выше, чтобы было с чем сравнивать после разбивки. Настройте логирование взаимоблокировок и мониторинг pg_stat_activity заранее: deadlock и исчерпание пула вы хотите видеть в графиках, а не в тикетах от поддержки. И заведите джобу-сверку агрегатов между модулями — она первой поймает расхождение, если вы ушли в eventual consistency.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Laravel Cache — Atomic Locks (официальная документация), PostgreSQL 16 — Concurrency Control and Deadlocks
Частые вопросы
У меня Laravel 11, а не 10 — приёмы применимы?
А если deadlock-и появились, но нагрузка невысокая?
Обязательно ли уходить в eventual consistency при разбивке?
Cache::lock() не сработал — блокировка не берётся, что не так?
Как понять, что пул соединений исчерпывается именно из-за разбивки, а не сам по себе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.