Каждые 10x роста ломают в вашем приложении что-то новое. Вот в каком порядке это происходит и какое именно исправление важно на каждом этапе.

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

Вот чего большинство людей не замечают, пытаясь масштабировать приложение на Laravel: каждые 10x пользователей ломают что-то новое, и это происходит в чётком порядке. Исправляйте проблемы в этом порядке, и каждый шаг обойдется недорого. Забегайте вперед — и вы будете платить за сложность, которая вам пока не нужна.

Итак, вот этот порядок: от 1 тыс. до 10 млн пользователей.

Сначала измеряйте, потому что гадать — это дорого

Прежде чем что-то менять, выясните, что работает медленно. Вся работа сводится к трем инструментам:

  • Лог медленных запросов MySQL показывает, какие запросы выполняются слишком долго.
  • Laravel Pulse показывает самые медленные маршруты, запросы и задачи на продакшене.
  • Laravel Telescope (локально или на стейджинге) показывает каждый запрос, выполняемый в рамках роута.

Запишите пять самых медленных маршрутов. Всё, о чем говорится ниже, сводится к тому, чтобы сделать этот список короче.

От 1K до 10K пользователей: исправляйте запросы, а не сервер

При таком объеме причиной почти всегда является один медленный запрос. Самый частый пример — проблема N+1: вы загружаете список, а затем ваш код выполняет еще по одному лишнему запросу для каждой строки.

// Slow: 1 query for orders + 1 more query for each order's customer
$orders = Order::latest()->take(50)->get();

foreach ($orders as $order) {
    echo $order->customer->name;
}

// Fast: 2 queries in total, no matter how many orders
$orders = Order::with('customer')->latest()->take(50)->get();

Сделайте так, чтобы Laravel ругался при совершении этой ошибки. Добавьте это в ваш AppServiceProvider :

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

Затем проверьте индексы. Я рассказывал об этом в статье Stop Adding MySQL Indexes — Your Slow Laravel Queries Need a Different Fix, поэтому повторяться не буду. Как только запросы приведены в порядок, один небольшой сервер часто справляется со всем этим этапом.

От 10K до 100K пользователей: кешируйте то, что многие читают и что мало кто меняет

Теперь ваша база данных выполняет одну и ту же работу снова и снова. Главной странице, показывающей топ-10 продуктов, не нужно обращаться к MySQL при каждом посещении. Используйте Redis (установите CACHE_STORE=redis  в ваш .env ; в старых версиях Laravel используется CACHE_DRIVER ) и кешируйте результат:

$topProducts = Cache::remember('home:top-products', now()->addMinutes(10), function () {
    return Product::query()
        ->withCount('orders')
        ->orderByDesc('orders_count')
        ->limit(10)
        ->get();
});

Один вызов теперь заменяет тысячи идентичных запросов. Когда данные меняются, сбрасывайте кеш с помощью Cache::forget('home:top-products'); .

Начинайте со страниц с наибольшим трафиком. Не кешируйте всё подряд. Кеш, который не удается вовремя очистить, отображает устаревшие данные, а этот баг найти сложнее, чем медленную страницу.

От 100K до 1M пользователей: выносите медленные задачи за пределы запроса

При таком объеме проблемой становится работа, которую ваше приложение выполняет в ожидании ответа пользователя. Отправка письма, изменение размера изображения или вызов платежного API могут занимать секунды. Под нагрузкой эти секунды суммируются, и запросы выстраиваются в очередь.

Решение — очереди. Сначала ответьте пользователю, а тяжелую работу выполняйте в фоновом режиме:

// Slow: the user waits for the mail provider
Mail::to($user)->send(new WelcomeMail($user));

// Fast: the user gets a reply now, and a worker sends the email
Mail::to($user)->queue(new WelcomeMail($user));

Укажите QUEUE_CONNECTION=redis , а затем запустите воркеров с помощью php artisan queue:work redis --tries=3 . Laravel Horizon предоставит дашборд для наблюдения за ними.

Два предостережения. Во-первых, джобы иногда падают, поэтому за ними нужно следить. Я писал о том, почему таблица неудачных джоб может вас запутать, в статье Your Laravel Failed Jobs Table Is Lying to You — Here’s the Real Fix. Во-вторых, джобы могут выполниться дважды, поэтому сделайте каждую задачу безопасной для повторного запуска. Урок о дублировании платежей из статьи A Redis Lock Alone Won’t Stop Duplicate Payments in Laravelприменим и здесь.

От 1M до 10M пользователей: разделяйте чтение и запись, а затем добавляйте серверы

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

'mysql' => [
    'read' => [
        'host' => [env('DB_READ_HOST_1'), env('DB_READ_HOST_2')],
    ],
    'write' => [
        'host' => [env('DB_WRITE_HOST')],
    ],
    'sticky' => true,
    'driver' => 'mysql',
    'database' => env('DB_DATABASE'),
    // username, password, charset and the rest stay the same
],

Параметр sticky  имеет решающее значение. После того как запрос записывает данные, Laravel читает их из основной базы данных до конца этого запроса, чтобы пользователь не сохранил что-то и тут же не обнаружил пропажу.

Затем сделайте ваши серверы приложений stateless, чтобы разместить большее их число за балансировщиком нагрузки. Для этого нужны три изменения: сессии в Redis (SESSION_DRIVER=redis ), загруженные файлы в S3 (FILESYSTEM_DISK=s3 ) и кеш в Redis. Когда ни один сервер не хранит ничего нужного другим, добавление нового сервера сводится к его простому включению.

Наконец, защититесь от скачков трафика и недобросовестных пользователей с помощью rate limiting:

// In AppServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

Исправления, которые пока можно смело пропустить

Обратите внимание на то, чего нет в этом списке: шардинг, микросервисы, Kubernetes. Это реальные инструменты, но большинство приложений достигают миллиона пользователей и без них. Я доказывал это в статье Your App Doesn’t Need Sharding Yet — Here’s What It Needs First, а о том, что сломалось при слишком раннем разделении приложения, писал здесь: I Split My Laravel App Into Microservices. Here’s What Broke in Production. Каждое из этих решений добавляет движущиеся части, и каждая часть может сломаться сама по себе.

Внедряйте их тогда, когда ваши метрики показывают, что потенциал простых шагов исчерпан. Не делайте этого просто потому, что какая-то статья в блоге (включая эту) заставила вас нервничать.

Где эти цифры меняются

Указанное выше количество пользователей — это примерные ориентиры, а не жесткие правила. Важны запросы в секунду и то, что именно делает каждый запрос, а не количество зарегистрированных людей.

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

Проверяйте, прежде чем доверять. Инструмент нагрузочного тестирования вроде k6 может показать ваш реальный предел всего за полдня.

Что делать перед следующими 10x

Определите этап, на котором вы находитесь сегодня, и сделайте только этот шаг. Снова снимите метрики. Затем возвращайтесь к выкатке фич, пока цифры не подскажут, что пора переходить к следующему этапу.

Вам не нужен сервер побольше. Вам нужно знать, в какую стену вы собираетесь врезаться.

Если вам нужна чистая отправная точка для работы с API в проекте на Laravel, я выкладываю свой стартовый набор Laravel API на Gumroad.