Redis-патерни в Laravel, які ви не використовуєте (а варто)

AxVi
7 хв читання
3 перегляди
Redis-патерни в Laravel, які ви не використовуєте (а варто)
Вийдіть за межі Cache::get() та Cache::put(). Розглянемо перевірені в продакшені Redis-патерни — буферизовані лічильники, розподілені блокування, sliding window rate limiting, Lua-скрипти, pub/sub та pipeline batching — з реальним кодом на Laravel.

Вступ

Більшість Laravel-розробників використовують Redis однаково: Cache::get(), Cache::put(), може Cache::remember() для тих, хто почувається сміливіше. Це покриває 80% випадків, і чесно кажучи — цього достатньо.

Але Redis — не просто key-value кеш. Це сервер структур даних — він підтримує sorted sets, списки, множини, хеш-таблиці, стріми, pub/sub канали та атомарні операції через Lua-скрипти. Laravel дає вам чистий доступ до всього цього через фасад Redis та абстракцію Cache, але більша частина потужності залишається невикористаною.

У цій статті я покажу сім Redis-патернів, які використовую в продакшені. Кожен вирішує реальну проблему, яку Cache::get() не здатен розв'язати. Покажу справжній код, поясню, коли варто використовувати кожен патерн, і вкажу на підводні камені, на які сам натрапив.

1. Буферизовані лічильники

Найпоширеніша помилка: писати напряму в базу на кожен перегляд сторінки, кожен клік, кожну подію. Ваш PostgreSQL це витримає — поки не перестане. Кожен UPDATE posts SET views_count = views_count + 1 бере блокування рядка, записує WAL-запис і запускає всіх спостерігачів та слухачів подій.

Рішення просте: буферизувати записи в Redis, скидати в базу періодично.

Buffered counter flow — Redis as a write buffer between your app and the database

Саме так працює лічильник переглядів на цьому блозі. Ось action, який інкрементує кількість переглядів поста:

<?php

namespace App\Actions\Posts;

use Illuminate\Support\Facades\Redis;
use Lorisleiva\Actions\Concerns\AsAction;

class IncrementPostView
{
    use AsAction;

    private const REDIS_KEY_PREFIX = 'post_views:';
    private const REDIS_VIEWS_SET = 'post_views:pending';
    private const UNIQUE_VIEW_TTL = 3600; // 1 година

    public function handle(int $postId, ?string $visitorIp = null): void
    {
        if ($visitorIp && $this->hasRecentView($postId, $visitorIp)) {
            return;
        }

        Redis::pipeline(function ($pipe) use ($postId, $visitorIp) {
            $pipe->incr(self::REDIS_KEY_PREFIX . $postId);
            $pipe->sadd(self::REDIS_VIEWS_SET, $postId);

            if ($visitorIp) {
                $pipe->setex(
                    $this->uniqueKey($postId, $visitorIp),
                    self::UNIQUE_VIEW_TTL,
                    1
                );
            }
        });
    }

    private function hasRecentView(int $postId, string $visitorIp): bool
    {
        return (bool) Redis::exists($this->uniqueKey($postId, $visitorIp));
    }

    private function uniqueKey(int $postId, string $visitorIp): string
    {
        return "post_view_unique:{$postId}:" . md5($visitorIp);
    }
}

Три речі відбуваються атомарно всередині pipeline:

  1. INCR збільшує лічильник для цього поста. Redis INCR — атомарна операція без блокувань, здатна обробляти мільйони інкрементів на секунду.
  2. SADD додає ID поста до множини очікуючих, щоб задача синхронізації знала, які пости мають нові перегляди.
  3. SETEX позначає цю IP як "вже переглянуту" з TTL в 1 годину, запобігаючи подвійному підрахунку.

Задача синхронізації запускається кожні 5 хвилин через планувальник:

<?php

namespace App\Actions\Posts;

use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
use Lorisleiva\Actions\Concerns\AsAction;

class SyncPostViewsToDatabase
{
    use AsAction;

    public string $commandSignature = 'posts:sync-views';
    public string $commandDescription = 'Sync post view counts from Redis to database';

    private const REDIS_KEY_PREFIX = 'post_views:';
    private const REDIS_VIEWS_SET = 'post_views:pending';

    public function handle(): int
    {
        $postIds = Redis::smembers(self::REDIS_VIEWS_SET);

        if (empty($postIds)) {
            return 0;
        }

        $synced = 0;

        foreach ($postIds as $postId) {
            $views = (int) Redis::getdel(self::REDIS_KEY_PREFIX . $postId);

            if ($views > 0) {
                Post::withoutTimestamps(
                    fn () => Post::where('id', $postId)
                        ->increment('views_count', $views)
                );
                $synced++;
            }

            Redis::srem(self::REDIS_VIEWS_SET, $postId);
        }

        return $synced;
    }

    public function asCommand(Command $command): void
    {
        $synced = $this->handle();
        $command->info("Synced view counts for {$synced} posts.");
    }
}

Реєстрація в routes/console.php:

use App\Actions\Posts\SyncPostViewsToDatabase;

Schedule::job(SyncPostViewsToDatabase::class)->everyFiveMinutes();

Читання кількості переглядів комбінує обидва джерела — базу (персистентні дані) та Redis (буфер):

public function handle(int|Post $post): int
{
    $postId = $post instanceof Post ? $post->id : $post;
    $dbCount = $post instanceof Post
        ? $post->views_count
        : (Post::where('id', $postId)->value('views_count') ?? 0);

    $redisCount = (int) Redis::get('post_views:' . $postId);

    return $dbCount + $redisCount;
}

Чому це краще за прямі записи в базу

  • 100 переглядів/секунду = 100 записів у базу vs. 100 інкрементів у Redis + 1 запис у базу кожні 5 хвилин
  • Жодних блокувань рядків, що конкурують з вашими SELECT-запитами
  • Якщо Redis впаде, ви втратите максимум 5 хвилин лічильників — а не стабільність бази даних
  • Post::withoutTimestamps() запобігає оновленню updated_at під час синхронізації, що інакше б інвалідувало кеш та запускало спостерігачі без потреби

Коли НЕ використовувати

Не буферизуйте, якщо потрібні точні лічильники в реальному часі для бізнес-логіки (наприклад, залишки на складі, ставки на аукціоні). Для них використовуйте INCR + read-through — все ще Redis, але без затримки синхронізації.

2. Розподілені блокування

Race conditions — це баги, які чудово працюють на вашій машині і вибухають у продакшені, коли два запити одночасно потрапляють на один endpoint. Laravel Cache::lock() використовує Redis SET NX EX під капотом — атомарну операцію "встановити, якщо не існує, з терміном дії".

Запобігання подвійній обробці платежу

use Illuminate\Support\Facades\Cache;

class ProcessPayment
{
    use AsAction;

    public function handle(Order $order): PaymentResult
    {
        $lock = Cache::lock("payment:{$order->id}", ttl: 30);

        if (! $lock->get()) {
            throw new PaymentInProgressException(
                "Payment for order {$order->id} is already being processed."
            );
        }

        try {
            // Списуємо кошти — захищено від подвійної обробки
            $result = $this->chargeGateway($order);
            $order->update(['status' => 'paid']);

            return $result;
        } finally {
            $lock->release();
        }
    }
}

Безпечне виконання крону

Якщо ваш планувальник працює на кількох серверах (або контейнер перезапускається під час виконання), завдання можуть перекриватись. Laravel withoutOverlapping() використовує Redis-блокування внутрішньо, але можна зробити це й вручну:

Schedule::job(SyncPostViewsToDatabase::class)
    ->everyFiveMinutes()
    ->withoutOverlapping(expiresAt: 10); // Блокування спливає через 10 хвилин

Для власної логіки блокувань усередині action:

public function handle(): void
{
    $lock = Cache::lock('daily-report-generation', ttl: 300);

    $lock->block(10, function () {
        // Чекаємо до 10 секунд на отримання блокування, потім виконуємо
        $this->generateReport();
    });
}

Підводні камені

  • TTL занадто короткий: Якщо операція виконується довше за TTL блокування, воно спливає і інший процес його перехоплює. Встановлюйте TTL щонайменше у 2 рази довше за очікуваний час виконання.
  • Втрачений токен власника: Cache::lock() повертає екземпляр Lock з токеном власника. Якщо ви серіалізуєте/десеріалізуєте його (наприклад, через чергу), токен втрачається і release() не спрацює. У таких випадках використовуйте Cache::restoreLock('key', $ownerToken).
  • Забули finally: Завжди звільняйте блокування в блоці finally. Якщо код кидає виключення до release(), блокування висить до закінчення TTL.

3. Sliding Window Rate Limiting

Вбудований Laravel RateLimiter використовує фіксоване вікно — він скидається на жорстких межах (наприклад, щохвилини о :00). Це означає, що користувач може зробити 60 запитів о 12:00:59 і ще 60 о 12:01:00 — 120 запитів за 2 секунди.

Ковзне вікно на sorted sets це виправляє:

use Illuminate\Support\Facades\Redis;

class SlidingWindowRateLimiter
{
    use AsAction;

    public function handle(
        string $key,
        int $maxAttempts,
        int $windowSeconds
    ): bool {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;

        $result = Redis::pipeline(function ($pipe) use ($key, $now, $windowStart, $windowSeconds) {
            // Видаляємо записи поза вікном
            $pipe->zremrangebyscore($key, '-inf', $windowStart);
            // Додаємо поточний запит
            $pipe->zadd($key, $now, $now . ':' . mt_rand());
            // Рахуємо записи у вікні
            $pipe->zcard($key);
            // Встановлюємо TTL для автоочищення
            $pipe->expire($key, $windowSeconds);
        });

        $count = $result[2]; // результат zcard

        return $count <= $maxAttempts;
    }
}

Квоти API для різних тарифних планів

class ApiRateLimitMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();
        $key = "api_rate:{$user->id}";

        $limits = match ($user->plan) {
            'free'       => ['max' => 100,  'window' => 3600],
            'pro'        => ['max' => 1000, 'window' => 3600],
            'enterprise' => ['max' => 10000, 'window' => 3600],
        };

        $allowed = SlidingWindowRateLimiter::run(
            $key,
            $limits['max'],
            $limits['window']
        );

        if (! $allowed) {
            return response()->json([
                'error' => 'Rate limit exceeded',
                'retry_after' => $limits['window'],
            ], 429);
        }

        return $next($request);
    }
}

Коли використовувати замість вбудованого RateLimiter

  • Вам потрібна точна посекундна/похвилинна точність без можливості burst-експлойтів
  • У вас різні ліміти для різних тарифних планів
  • Потрібно запитати "скільки запитів залишилось" — ZCARD дає точну кількість

Коли вбудованого RateLimiter достатньо

  • Обмеження спроб входу, скидання пароля — фіксовані вікна цілком підходять
  • Вам не важлива burst-поведінка на межах вікон

4. Redis як легка черга

Іноді потрібен простий конвеєр задач без накладних витрат повноцінної системи черг Laravel — без таблиці failed_jobs, без логіки повторних спроб, без ланцюжка middleware. Redis-списки з LPUSH/BRPOP дають елементарну FIFO-чергу:

// Продюсер: відправляємо події на асинхронну обробку
Redis::lpush('webhook:events', json_encode([
    'type' => 'post.published',
    'post_id' => $post->id,
    'timestamp' => now()->toIso8601String(),
]));
// Споживач: проста artisan-команда
class ProcessWebhookEvents extends Command
{
    protected $signature = 'webhooks:process';

    public function handle(): void
    {
        $this->info('Waiting for webhook events...');

        while (true) {
            // Блокуємо до 5 секунд в очікуванні елемента
            $result = Redis::brpop('webhook:events', 5);

            if ($result) {
                [$key, $payload] = $result;
                $event = json_decode($payload, true);
                $this->processEvent($event);
            }
        }
    }
}

Коли використовувати Redis-списки vs черги Laravel

СценарійRedis ListЧерга Laravel
Прості fire-and-forget подіїДобреНадлишково
Потрібні повтори, backoff, трекінг помилокНіТак
Комунікація між сервісамиДобреМожливо, але важче
Job middleware, rate limiting, батчіНіВбудовано
Надмала затримка (<1ms push)Так~5ms накладних

Використовуйте черги Laravel для всього, що потребує надійності. Redis-списки — для легких внутрішніх конвеєрів, де втрата випадкового повідомлення допустима.

5. Pub/Sub для інвалідації кешу

Коли ви запускаєте Laravel Octane з FrankenPHP, ваш застосунок живе в довготривалих робочих процесах. Кожен воркер має власний стан у пам'яті. Якщо ви кешуєте щось у статичній властивості або використовуєте in-memory драйвер кешу, інші воркери не побачать оновлення.

Redis pub/sub вирішує крос-воркерну інвалідацію кешу:

// Публікація: коли пост оновлено
class PostObserver
{
    public function updated(Post $post): void
    {
        Redis::publish('cache:invalidate', json_encode([
            'type' => 'post',
            'id' => $post->id,
            'tags' => ["post:{$post->id}", "category:{$post->category_id}"],
        ]));
    }
}
// Підписка: artisan-команда як daemon
class CacheInvalidationSubscriber extends Command
{
    protected $signature = 'cache:subscribe';

    public function handle(): void
    {
        Redis::subscribe(['cache:invalidate'], function (string $message) {
            $data = json_decode($message, true);

            foreach ($data['tags'] as $tag) {
                Cache::tags([$tag])->flush();
            }

            $this->info("Invalidated cache for {$data['type']}:{$data['id']}");
        });
    }
}

Як це працює з воркерами Octane

Кожен воркер Octane завантажується незалежно. Команда-підписник запускається як окремий процес (не всередині воркера). Коли вона отримує повідомлення, вона очищує відповідні теги кешу Redis — і оскільки всі воркери читають з одного й того ж Redis, застарілий кеш зникає для всіх.

Застереження

  • Pub/sub — fire-and-forget — якщо підписник не працює в момент публікації повідомлення, воно втрачається. Для критичної інвалідації записуйте в Redis stream (XADD/XREAD), який зберігає повідомлення.
  • Не підписуйтесь усередині Octane-воркерівRedis::subscribe() блокується назавжди. Запускайте як окремий artisan-daemon.
  • Використовуйте окреме Redis-з'єднання — підписка блокує з'єднання. Якщо використовувати те саме з'єднання для кешування, все зависне. Налаштуйте окреме з'єднання в config/database.php.

6. Lua-скрипти для атомарних операцій

Окремі команди Redis атомарні, але послідовність команд — ні. Між вашим GET і SET інший процес може змінити значення. Lua-скрипти виконуються атомарно на сервері Redis — жодна інша команда не виконається, поки ваш скрипт не завершиться.

Атомарний лічильник зі стелею

Обмеження лічильника максимальним значенням (наприклад, 100 завантажень файлу на день):

$script = <<<'LUA'
    local current = tonumber(redis.call('GET', KEYS[1]) or 0)
    local max = tonumber(ARGV[1])
    if current < max then
        redis.call('INCR', KEYS[1])
        return 1
    end
    return 0
LUA;

$allowed = Redis::eval(
    $script,
    1,                          // кількість KEYS
    "downloads:{$fileId}:daily", // KEYS[1]
    100                          // ARGV[1] — стеля
);

if ($allowed) {
    return $this->streamDownload($fileId);
}

return response()->json(['error' => 'Daily download limit reached'], 429);

Token bucket rate limiter

Витонченіший за sliding window — дозволяє контрольовані сплески:

$script = <<<'LUA'
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

    local data = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(data[1]) or capacity
    local last_refill = tonumber(data[2]) or now

    local elapsed = now - last_refill
    local refill = math.floor(elapsed * rate)
    tokens = math.min(capacity, tokens + refill)

    if tokens >= 1 then
        tokens = tokens - 1
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
        return 1
    end

    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
    return 0
LUA;

$allowed = Redis::eval(
    $script,
    1,
    "token_bucket:{$userId}",
    50,                        // capacity: 50 токенів
    10,                        // rate: 10 токенів/секунду
    microtime(true)            // поточний час
);

Чому не просто Redis-транзакції (MULTI/EXEC)?

Redis-транзакції (MULTI/EXEC) групують команди, але не дозволяють прочитати значення і розгалузитись на його основі в межах однієї транзакції. WATCH/MULTI/EXEC дає оптимістичне блокування, але повторює при конфлікті. Lua-скрипти простіші, швидші (один round-trip) і гарантовано атомарні.

7. Pipeline Batching

Кожна команда Redis — це мережевий round-trip. Якщо ви робите 100 викликів у циклі, це 100 round-trip'ів. Redis::pipeline() групує їх в один round-trip.

До: N+1 Redis-викликів

// Отримання кількості переглядів для 50 постів — 50 round-trip'ів
$posts->each(function (Post $post) {
    $post->redis_views = (int) Redis::get("post_views:{$post->id}");
});

Після: один pipeline

$keys = $posts->map(fn (Post $post) => "post_views:{$post->id}")->all();

$counts = Redis::pipeline(function ($pipe) use ($keys) {
    foreach ($keys as $key) {
        $pipe->get($key);
    }
});

$posts->each(function (Post $post, int $index) use ($counts) {
    $post->redis_views = (int) ($counts[$index] ?? 0);
});

Масове прогрівання кешу

Redis::pipeline(function ($pipe) use ($posts) {
    foreach ($posts as $post) {
        $pipe->setex(
            "post_cache:{$post->id}",
            3600,
            json_encode($post->toSearchableArray())
        );
    }
});

Різниця в продуктивності

Я виміряв це на сторінці зі списком постів блогу. З 20 постами:

ПідхідRedis-викликівЧас
Окремі GET у циклі20~4.2мс
Pipeline1~0.3мс

Це покращення в 14 разів. Зі 100 елементами різниця зростає до 40-50 разів, бо затримка мережі домінує при окремих викликах.

Коли використовувати pipeline

  • Будь-який цикл з Redis-викликами
  • Попереднє завантаження даних для списку
  • Прогрівання кешу після деплою
  • Масова інвалідація кількох ключів

Підсумок

Ці патерни не екзотичні — це основа продакшен-використання Redis. Головний висновок: думайте про Redis як про сервер структур даних, а не просто кеш. Підбирайте правильну структуру під вашу задачу:

ЗадачаСтруктура RedisLaravel API
Швидкий підрахунокStrings (INCR)Redis::incr()
Запобігання дублікатамSets (SADD, SISMEMBER)Redis::sadd()
Дані з часовим вікномSorted Sets (ZADD, ZRANGEBYSCORE)Redis::zadd()
Прості чергиLists (LPUSH, BRPOP)Redis::lpush()
Складний станHashes (HMSET, HMGET)Redis::hmset()
Міжпроцесна комунікаціяPub/SubRedis::publish(), Redis::subscribe()
Атомарна багатокрокова логікаLua ScriptsRedis::eval()
Масові операціїPipelinesRedis::pipeline()

Почніть з одного патерну. Буферизований лічильник — найпростіша перемога: реалізація займає 30 хвилин і одразу знижує навантаження на базу. Коли освоїтесь — решта прийде природно.


Знайшли корисним? Підписуйтесь на GitHub, LinkedIn або X.

Поділитися статтею