Как ускорить 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 и грамотной инвалидации. Главные уроки:
- Начинайте с профилирования — используйте Laravel Telescope или Clockwork, чтобы найти реальные bottleneck-и, а не кэшировать всё подряд
- Тегированный кэш экономит часы отладки в будущем
- Мониторьте память Redis регулярно — без правильной eviction policy можно получить OOM в самый неподходящий момент
- Не забывайте про race conditions при высокой конкурентности
Redis оказался для моего проекта правильным выбором благодаря гибкости и богатству возможностей. Если ваше приложение растёт и начинает тормозить — не спешите вертикально масштабировать сервер, попробуйте сначала грамотно настроить кэширование. Часто это даёт больший эффект за меньшие деньги.