Архитектура

Многоуровневое кэширование в Laravel: стратегии L1/L2 с Redis и PostgreSQL для максимальной производительности

Ruslan Ismailov Опубликовано 14 мин чтения
М

Введение: почему одного 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 товаров, отсортированных по популярности. Данные меняются при каждой покупке или отзыве. Стратегия:

  1. L0 хранит результат 5 секунд — защищает от спайков трафика на одну страницу.
  2. L1 (Redis) хранит 5 минут — общий кэш для всех серверов.
  3. L2 (MV) обновляется каждые 5 минут через Scheduler и служит источником для прогрева L1.

Сценарий 2: Дашборд с агрегированной статистикой

Графики продаж, DAU, выручки по часам. Данные дорогие в вычислении (JOIN нескольких больших таблиц), но допустима устаревание на 15–60 минут:

  1. Создаём MV mv_sales_stats_daily и mv_hourly_revenue.
  2. Обновляем каждый час через REFRESH MATERIALIZED VIEW CONCURRENTLY.
  3. Redis хранит результаты API-запросов 30 минут.
  4. 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. Подробнее обо мне →