Многоуровневое кэширование в Laravel: стратегии L1/L2 с Redis и PostgreSQL для максимальной производительности
Введение: почему одного Redis недостаточно
Redis — отличный инструмент, и большинство Laravel-приложений используют его как единственный уровень кэширования. Но как только приложение начинает обслуживать тысячи запросов в секунду, одного Redis перестаёт хватать. Причины разные: сетевые задержки при каждом обращении к Redis (даже 0,5–2 мс на запрос складываются в сотни миллисекунд под нагрузкой), «холодный старт» при инвалидации больших ключей, а также ситуации, когда агрегированные данные дешевле хранить на уровне базы данных в готовом виде.
Многоуровневое кэширование решает эту проблему за счёт иерархии хранилищ с разной скоростью и стоимостью доступа. Классическая схема выглядит так:
- L0 — in-process кэш (memory driver, Octane-совместимый): наносекундный доступ, живёт в рамках одного воркера.
- L1 — Redis: микросекундный доступ по сети, общий для всех воркеров.
- L2 — PostgreSQL Materialized Views: предвычисленные агрегаты прямо в базе данных, обновляются по расписанию.
В этой статье мы построим такую систему в Laravel-приложении шаг за шагом — от архитектуры до метрик и антипаттернов.
Архитектура L1/L2: три уровня кэширования
L0 — in-process кэш (array driver / Octane)
Если вы используете Laravel Octane (Swoole или RoadRunner), воркер живёт между запросами. Это позволяет хранить «горячие» данные прямо в памяти PHP-процесса через array-драйвер без какой-либо сети. Время доступа — единицы микросекунд. Недостаток: данные изолированы внутри воркера и сбрасываются при его перезапуске.
L1 — Redis
Redis выступает общим разделяемым кэшем для всех воркеров и серверов. Он хранит сериализованные PHP-объекты, поддерживает TTL, атомарные операции и Pub/Sub для инвалидации. Задержка доступа — 0,1–2 мс в зависимости от сети.
L2 — PostgreSQL Materialized Views
Materialized View — это физически сохранённый результат SQL-запроса. В отличие от обычного VIEW, данные хранятся на диске и не пересчитываются при каждом SELECT. Для агрегированной статистики (суммы продаж, топ товаров, дашборды) это идеальный L2-уровень: данные уже в базе, не нужно гонять большие объёмы по сети из Redis.
Реализация Cache Manager в Laravel
Кастомный многоуровневый драйвер
Laravel позволяет регистрировать собственные драйверы через Cache::extend(). Создадим класс TieredCacheStore, который реализует fallback-логику: сначала проверяет L0, затем L1 (Redis), затем L2 (PostgreSQL), и при промахе заполняет верхние уровни.
<?php
namespace App\Cache;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TieredCacheStore implements Store
{
private array $l0 = [];
private Repository $l1; // Redis
private int $l0Ttl;
private int $l1Ttl;
public function __construct(Repository $l1, int $l0Ttl = 5, int $l1Ttl = 300)
{
$this->l1 = $l1;
$this->l0Ttl = $l0Ttl;
$this->l1Ttl = $l1Ttl;
}
public function get($key): mixed
{
// L0: in-process
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
return $this->l0[$key]['value'];
}
// L1: Redis
$value = $this->l1->get($key);
if ($value !== null) {
$this->storeL0($key, $value);
return $value;
}
// L2: PostgreSQL materialized view или slow query
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
}
return $value;
}
private function storeL0(string $key, mixed $value): void
{
$this->l0[$key] = [
'value' => $value,
'expires_at' => now()->timestamp + $this->l0Ttl,
];
}
private function fetchFromL2(string $key): mixed
{
// Пример: ключ вида "catalog:category:{id}:top"
if (preg_match('/^catalog:category:(\d+):top$/', $key, $m)) {
return DB::select(
'SELECT * FROM mv_category_top_products WHERE category_id = ?',
[(int) $m[1]]
);
}
return null;
}
public function put($key, $value, $seconds): bool
{
$this->storeL0($key, $value);
return $this->l1->put($key, $value, $seconds);
}
public function forget($key): bool
{
unset($this->l0[$key]);
return $this->l1->forget($key);
}
// Остальные методы Store делегируют в L1
public function many(array $keys): array { return $this->l1->many($keys); }
public function putMany(array $values, $seconds): bool { return $this->l1->putMany($values, $seconds); }
public function increment($key, $value = 1): int|bool { return $this->l1->increment($key, $value); }
public function decrement($key, $value = 1): int|bool { return $this->l1->decrement($key, $value); }
public function forever($key, $value): bool { return $this->l1->forever($key, $value); }
public function flush(): bool { $this->l0 = []; return $this->l1->flush(); }
public function getPrefix(): string { return 'tiered:'; }
}
Регистрация драйвера в ServiceProvider
<?php
namespace App\Providers;
use App\Cache\TieredCacheStore;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class TieredCacheServiceProvider extends ServiceProvider
{
public function boot(): void
{
Cache::extend('tiered', function ($app) {
$l1 = Cache::store('redis');
return Cache::repository(new TieredCacheStore($l1, l0Ttl: 5, l1Ttl: 300));
});
}
}
После регистрации добавьте в config/cache.php:
'stores' => [
// ...
'tiered' => [
'driver' => 'tiered',
],
],
'default' => env('CACHE_DRIVER', 'tiered'),
PostgreSQL Materialized Views как L2-кэш
Создание Materialized View для каталога товаров
Создадим материализованное представление, которое предвычисляет топ-20 товаров по каждой категории с агрегированными оценками и количеством продаж:
CREATE MATERIALIZED VIEW mv_category_top_products AS
SELECT
p.category_id,
p.id AS product_id,
p.name,
p.price,
p.slug,
COALESCE(AVG(r.rating), 0)::NUMERIC(3,2) AS avg_rating,
COUNT(DISTINCT oi.id) AS total_orders,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC, AVG(r.rating) DESC
) AS rank
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE p.is_active = true
GROUP BY p.category_id, p.id, p.name, p.price, p.slug
HAVING ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC
) <= 20
WITH DATA;
CREATE UNIQUE INDEX ON mv_category_top_products (category_id, product_id);
CREATE INDEX ON mv_category_top_products (category_id, rank);
Уникальный индекс обязателен для использования REFRESH MATERIALIZED VIEW CONCURRENTLY — он позволяет обновлять вью без блокировки чтения.
Инвалидация кэша: event-driven подход
Observer + Redis Pub/Sub
Инвалидация — самая сложная часть многоуровневого кэширования. Используем паттерн Observer в Laravel для отслеживания изменений модели и Redis Pub/Sub для рассылки сигналов инвалидации всем воркерам.
<?php
namespace App\Observers;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class ProductObserver
{
public function saved(Product $product): void
{
$this->invalidateProductCache($product);
}
public function deleted(Product $product): void
{
$this->invalidateProductCache($product);
}
private function invalidateProductCache(Product $product): void
{
$keys = [
"catalog:category:{$product->category_id}:top",
"product:{$product->id}",
"product:{$product->id}:details",
];
// Удаляем из Redis (L1)
foreach ($keys as $key) {
Cache::store('redis')->forget($key);
}
// Публикуем событие для инвалидации L0 во всех воркерах
Redis::publish('cache:invalidate', json_encode([
'keys' => $keys,
'category_id'=> $product->category_id,
'timestamp' => now()->toISOString(),
]));
}
}
Подписчик для L0-инвалидации в Octane
В Octane-воркере запускаем фоновый слушатель Redis Pub/Sub, который очищает L0 при получении события:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CacheInvalidationListenerCommand extends Command
{
protected $signature = 'cache:listen-invalidation';
protected $description = 'Subscribe to Redis Pub/Sub for L0 cache invalidation';
public function handle(): void
{
$this->info('Listening for cache invalidation events...');
Redis::subscribe(['cache:invalidate'], function (string $message) {
$payload = json_decode($message, true);
// Здесь обращаемся к синглтону TieredCacheStore
// и очищаем его L0-массив для указанных ключей
app('cache.tiered')->forgetL0($payload['keys']);
$this->line('Invalidated: ' . implode(', ', $payload['keys']));
});
}
}
Обновление Materialized Views по расписанию
PostgreSQL Materialized Views не обновляются автоматически — это нужно делать явно. Используем Laravel Scheduler для периодического обновления без блокировки читателей:
<?php
// app/Console/Kernel.php или routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// CONCURRENTLY не блокирует SELECT во время обновления
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_top_products');
// После обновления инвалидируем L1 для всех затронутых ключей
$categories = DB::table('mv_category_top_products')
->distinct()
->pluck('category_id');
foreach ($categories as $categoryId) {
Cache::store('redis')->forget("catalog:category:{$categoryId}:top");
}
logger()->info('Materialized view refreshed', ['categories' => $categories->count()]);
})->everyFiveMinutes()->withoutOverlapping()->name('refresh-mv-category-top');
// Агрегированная статистика — реже, т.к. дороже
Schedule::call(function () {
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_stats_daily');
})->hourly()->withoutOverlapping()->name('refresh-mv-sales-stats');
Важно:
REFRESH MATERIALIZED VIEW CONCURRENTLYтребует уникального индекса на вью и выполняется в отдельной транзакции. При этом старые данные остаются доступны для чтения до завершения обновления.
Метрики эффективности кэша
Измерение hit rate и латентности
Без метрик невозможно понять, работает ли ваша система кэширования. Добавим инструментирование прямо в TieredCacheStore:
<?php
// Добавляем в TieredCacheStore
private array $stats = ['l0_hits' => 0, 'l1_hits' => 0, 'l2_hits' => 0, 'misses' => 0];
public function get($key): mixed
{
$start = hrtime(true);
// L0
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
$this->stats['l0_hits']++;
$this->recordLatency('l0', hrtime(true) - $start);
return $this->l0[$key]['value'];
}
// L1
$value = $this->l1->get($key);
if ($value !== null) {
$this->stats['l1_hits']++;
$this->storeL0($key, $value);
$this->recordLatency('l1', hrtime(true) - $start);
return $value;
}
// L2
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->stats['l2_hits']++;
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
$this->recordLatency('l2', hrtime(true) - $start);
return $value;
}
$this->stats['misses']++;
return null;
}
private function recordLatency(string $level, int $nanoseconds): void
{
$ms = $nanoseconds / 1_000_000;
// Отправляем в StatsD, Prometheus или просто логируем
logger()->debug("Cache hit [{$level}]", ['latency_ms' => $ms]);
}
public function getStats(): array
{
$total = array_sum($this->stats);
if ($total === 0) return $this->stats;
return array_merge($this->stats, [
'hit_rate' => round(($total - $this->stats['misses']) / $total * 100, 2),
'l0_hit_rate' => round($this->stats['l0_hits'] / $total * 100, 2),
'l1_hit_rate' => round($this->stats['l1_hits'] / $total * 100, 2),
]);
}
Целевые показатели производительности
- L0 (array/Octane): задержка <0,01 мс, hit rate цели — 60–70% для горячих ключей.
- L1 (Redis): задержка 0,1–2 мс, hit rate цели — 25–35% от оставшихся запросов.
- L2 (PostgreSQL MV): задержка 1–10 мс, покрывает оставшиеся 5–10%.
- Промах кэша (cold query): 50–500 мс — должен происходить редко.
Практические сценарии применения
Сценарий 1: Каталог товаров интернет-магазина
Страница категории отображает 20 товаров, отсортированных по популярности. Данные меняются при каждой покупке или отзыве. Стратегия:
- L0 хранит результат 5 секунд — защищает от спайков трафика на одну страницу.
- L1 (Redis) хранит 5 минут — общий кэш для всех серверов.
- L2 (MV) обновляется каждые 5 минут через Scheduler и служит источником для прогрева L1.
Сценарий 2: Дашборд с агрегированной статистикой
Графики продаж, DAU, выручки по часам. Данные дорогие в вычислении (JOIN нескольких больших таблиц), но допустима устаревание на 15–60 минут:
- Создаём MV
mv_sales_stats_dailyиmv_hourly_revenue. - Обновляем каждый час через
REFRESH MATERIALIZED VIEW CONCURRENTLY. - Redis хранит результаты API-запросов 30 минут.
- L0 не используется — данные редко запрашиваются повторно в рамках одного воркера.
Подводные камни и антипаттерны
1. Cache stampede (гром стада)
Когда большой ключ истекает одновременно, сотни запросов пытаются его пересчитать. Решение — Cache::lock() (атомарная блокировка через Redis) или паттерн «вероятностное раннее обновление» (probabilistic early expiration).
$value = Cache::remember('expensive:key', 300, function () {
// Только один воркер зайдёт сюда благодаря lock внутри remember
return DB::select('...');
});
// Или явно:
$lock = Cache::lock('lock:expensive:key', 10);
if ($lock->get()) {
try {
$value = computeExpensiveValue();
Cache::put('expensive:key', $value, 300);
} finally {
$lock->release();
}
}
2. Слишком маленький TTL на L0
TTL в 1–2 секунды на L0 при высоком RPS даёт мало пользы — кэш не успевает амортизировать нагрузку. Для стабильных данных (конфигурация, словари) используйте 30–60 секунд.
3. Инвалидация по шаблону (KEYS/SCAN)
Никогда не используйте Redis::keys('catalog:*') в продакшене — это блокирующая операция. Используйте явные ключи или теги через Cache::tags() (поддерживается только Redis и Memcached).
4. Игнорирование устаревания MV
Materialized View — это снимок данных. Если бизнес требует точности «в реальном времени», MV не подходит для L2. Используйте Redis Sorted Sets или PostgreSQL-индексированные запросы вместо MV.
5. Отсутствие прогрева кэша (cache warming)
После деплоя или flush'а все уровни пусты. Реализуйте команду прогрева, которая запускается после деплоя и наполняет L1 данными из L2:
// artisan cache:warm
$categories = DB::table('categories')->pluck('id');
foreach ($categories as $id) {
$data = DB::select('SELECT * FROM mv_category_top_products WHERE category_id = ?', [$id]);
Cache::store('redis')->put("catalog:category:{$id}:top", $data, 300);
}
Итоги
Многоуровневое кэширование в Laravel с Redis как L1 и PostgreSQL Materialized Views как L2 — не оверинжиниринг, а необходимость для приложений с высокой нагрузкой. Правильно настроенная иерархия позволяет достичь hit rate выше 95%, снизить среднюю задержку отклика с 200–500 мс до 5–20 мс и разгрузить основную базу данных в 10–50 раз.
Ключевые принципы, которые стоит запомнить: каждый уровень должен иметь понятную ответственность и TTL; инвалидация должна быть event-driven, а не по таймеру; метрики hit rate и латентности — обязательная часть системы, а не опция. Начните с реализации L1 (Redis) и L2 (MV) для самых тяжёлых запросов, измерьте эффект, и только потом добавляйте L0 для воркеров Octane.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →