Меню
Как ускорить Laravel приложение: кэширование через Redis или Memcached

Как ускорить Laravel приложение: кэширование через Redis или Memcached

Недавно я столкнулся с проблемой, знакомой многим разработчикам: приложение на Laravel, которое отлично работало на этапе разработки, начало заметно тормозить под реальной нагрузкой. Time to First Byte вырос до 800-900 мс, база данных захлёбывалась от повторяющихся запросов, а клиент начал задавать неприятные вопросы про «жуткие тормоза». В этой статье расскажу, как я решил эту проблему с помощью кэширования через Redis, и поделюсь конкретными кейсами с кодом.

Почему я выбрал Redis, а не Memcached

Прежде чем идти дальше, объясню свой выбор. Я тестировал оба решения на одном проекте и вот к каким выводам пришёл:

Redis выиграл для меня по нескольким причинам:

  • Поддержка сложных структур данных (списки, множества, хэши) — очень важно для тегированного кэша.
  • Persistence на диск — при перезапуске сервера кэш не теряется полностью.
  • Встроенные механизмы pub/sub, которые я использовал для инвалидации кэша между несколькими серверами.
  • Нативная интеграция с Laravel через пакет predis/predis или phpredis.

Memcached проще и для некоторых задач чуть быстрее, например для простых key-value операций, но для проекта с большим количеством связанных данных Redis показал себя практичнее.

Шаг 1: Настройка окружения

Первым делом устанавливаем PHP-расширение phpredis — оно быстрее чем predis за счёт того что написано на C:

sudo apt-get install php-redis

Или через composer, если все же предпочитаете predis:

composer require predis/predis

В .env файле настраиваем подключение:

CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_CLIENT=phpredis

В config/database.php проверяем секцию redis:

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'default' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],
],

Шаг 2: Первая победа — кэширование тяжёлых запросов

В моём проекте была страница каталога товаров с фильтрами, которая генерировала запрос на 200+ строк SQL с несколькими джойнами. Каждый запрос занимал 150-200 мс. Вот как я это исправил:

public function getProductsByCategory($categoryId, $filters = [])
{
    $cacheKey = 'products.category.' . $categoryId . '.' . md5(serialize($filters));
    
    return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($categoryId, $filters) {
        return Product::with(['category', 'attributes'])
            ->where('category_id', $categoryId)
            ->when($filters['price_min'] ?? null, function ($query, $priceMin) {
                $query->where('price', '>=', $priceMin);
            })
            ->when($filters['price_max'] ?? null, function ($query, $priceMax) {
                $query->where('price', '<=', $priceMax);
            })
            ->get();
    });
}

Результат: время выполнения упало с 180 мс до 3-5 мс при попадании в кэш. Но здесь возникла первая проблема — инвалидация.

Шаг 3: Умная инвалидация через теги

Простой TTL не подходит, когда данные меняются часто. Я перешёл на тегированный кэш — это одна из killer features Redis в связке с Laravel, та самая возможность из-за которой нужно использовать Redis:

public function getProductsByCategory($categoryId, $filters = [])
{
    $cacheKey = 'products.filtered.' . md5(serialize($filters));
    
    return Cache::tags(['products', 'category-' . $categoryId])
        ->remember($cacheKey, now()->addHours(2), function () use ($categoryId, $filters) {
            return Product::with(['category'])
                ->where('category_id', $categoryId)
                ->get();
        });
}

// При обновлении товара
public function updateProduct(Product $product, array $data)
{
    $product->update($data);
    
    Cache::tags(['products', 'category-' . $product->category_id])->flush();
}

Важный момент: тегирование работает только с Redis и Memcached, файловый кэш эту фичу не поддерживает. Это ещё один аргумент в пользу Redis.

Шаг 4: Кэширование на уровне Eloquent через события моделей

Ручное управление кэшем в каждом методе быстро превращается в кошмар. Я вынес логику в трейт, который автоматически инвалидирует кэш при изменении модели:

php artisan make:trait Traits/Cacheable

Добавляем логику в трейт:

namespace App\Traits;

use Illuminate\Support\Facades\Cache;

trait Cacheable
{
    protected static function bootCacheable()
    {
        static::saved(function ($model) {
            static::flushModelCache($model);
        });

        static::deleted(function ($model) {
            static::flushModelCache($model);
        });
    }

    protected static function flushModelCache($model)
    {
        $tags = $model->getCacheTags();
        Cache::tags($tags)->flush();
    }

    public function getCacheTags()
    {
        return [strtolower(class_basename($this)) . '-' . $this->id];
    }
}

Подключаем к модели:

class Product extends Model
{
    use Cacheable;

    public function getCacheTags()
    {
        return ['products', 'category-' . $this->category_id];
    }
}

Теперь любое изменение автоматически чистит связанный кэш, без ручного вызова flush в контроллерах.

Шаг 5: Кэширование ответов API

Отдельная боль была с REST API — мобильное приложение дёргало одни и те же эндпоинты десятки раз в минуту. Решил через middleware:

php artisan make:middleware CacheResponse

Вставляем этот код:

namespace App\Http\Middleware;

use Illuminate\Support\Facades\Cache;
use Closure;

class CacheResponse
{
    public function handle($request, Closure $next, $ttl = 60)
    {
        if (!$request->isMethod('GET')) {
            return $next($request);
        }

        $cacheKey = 'response.' . md5($request->fullUrl());

        if (Cache::has($cacheKey)) {
            return response()->json(Cache::get($cacheKey));
        }

        $response = $next($request);
        
        if ($response->status() === 200) {
            Cache::put($cacheKey, $response->getData(true), now()->addSeconds($ttl));
        }

        return $response;
    }
}

Регистрируем в маршрутах:

Route::middleware('cache.response:120')->get('/api/products', [ProductController::class, 'index']);

Нагрузка на базу данных для этого эндпоинта снизилась на 70%.

Шаг 6: Кэширование сессий и очередей через Redis

Не остановился на кэше данных — перевёл сессии и очереди тоже на Redis, это дало прирост в целом по приложению:

SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

Для очередей это особенно важно, потому что Redis обрабатывает джобы быстрее чем database driver — я замерял разницу в throughput примерно на 40%.

Подводные камни, на которые я наткнулся

Проблема сериализации

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

define('CACHE_VERSION', 'v2');

$cacheKey = CACHE_VERSION . '.products.' . $categoryId;

Memory leak при неправильном TTL

В самом начале я забыл проставить TTL для части ключей, и Redis начал есть память без остановки. Мониторил через:

redis-cli info memory

И настроил eviction policy в redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

Race condition при высокой нагрузке

Когда кэш истекает и множество запросов одновременно пытаются его пересчитать — это называется cache stampede. Решил через блокировки.

Лучше всего использовать отдельный сервис класс app/Services/ProductAnalyticsService.php:

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use App\Models\Product;

class ProductAnalyticsService
{
    public function getExpensiveData($id)
    {
        $lock = Cache::lock('lock.expensive.' . $id, 10);

        if ($lock->get()) {
            try {
                return Cache::remember('expensive.' . $id, 3600, function () use ($id) {
                    return $this->calculateExpensiveData($id);
                });
            } finally {
                $lock->release();
            }
        }

        // Ждём, пока другой процесс завершит вычисление
        $waited = 0;
        $maxWait = 5000000; // 5 секунд в микросекундах
        
        while ($waited < $maxWait) {
            usleep(100000);
            $waited += 100000;
            
            if (Cache::has('expensive.' . $id)) {
                return Cache::get('expensive.' . $id);
            }
        }

        // Если за 5 секунд не дождались — считаем сами
        return $this->calculateExpensiveData($id);
    }

    protected function calculateExpensiveData($id)
    {
        // Ваша тяжёлая логика: агрегация данных, сложные вычисления,
        // обращения к внешним API и т.д.
        
        $product = Product::with(['orders', 'reviews'])->findOrFail($id);
        
        return [
            'total_sales' => $product->orders->sum('quantity'),
            'average_rating' => $product->reviews->avg('rating'),
            'revenue' => $product->orders->sum(function ($order) {
                return $order->quantity * $order->price;
            }),
        ];
    }
}

Использование в контроллере:

namespace App\Http\Controllers;

use App\Services\ProductAnalyticsService;

class ProductController extends Controller
{
    protected $analyticsService;

    public function __construct(ProductAnalyticsService $analyticsService)
    {
        $this->analyticsService = $analyticsService;
    }

    public function analytics($id)
    {
        $data = $this->analyticsService->getExpensiveData($id);

        return response()->json($data);
    }
}

Итоговые результаты

После полного внедрения кэширования через Redis метрики проекта изменились так:

  • Среднее время ответа сервера: с 450 мс до 120 мс
  • Нагрузка на MySQL: снизилась на 65%
  • Пропускная способность (requests per second): выросла с 80 до 250
  • Использование CPU на сервере БД: снизилось на 40%

Что я вынес из этого опыта

Кэширование — не серебряная пуля, которую можно применить один раз и забыть. Это постоянный процесс мониторинга, настройки TTL и грамотной инвалидации. Главные уроки:

  1. Начинайте с профилирования — используйте Laravel Telescope или Clockwork, чтобы найти реальные bottleneck-и, а не кэшировать всё подряд
  2. Тегированный кэш экономит часы отладки в будущем
  3. Мониторьте память Redis регулярно — без правильной eviction policy можно получить OOM в самый неподходящий момент
  4. Не забывайте про race conditions при высокой конкурентности

Redis оказался для моего проекта правильным выбором благодаря гибкости и богатству возможностей. Если ваше приложение растёт и начинает тормозить — не спешите вертикально масштабировать сервер, попробуйте сначала грамотно настроить кэширование. Часто это даёт больший эффект за меньшие деньги.