Redis-патерни в 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, скидати в базу періодично.

Саме так працює лічильник переглядів на цьому блозі. Ось 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:
INCRзбільшує лічильник для цього поста. RedisINCR— атомарна операція без блокувань, здатна обробляти мільйони інкрементів на секунду.SADDдодає ID поста до множини очікуючих, щоб задача синхронізації знала, які пости мають нові перегляди.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мс |
| Pipeline | 1 | ~0.3мс |
Це покращення в 14 разів. Зі 100 елементами різниця зростає до 40-50 разів, бо затримка мережі домінує при окремих викликах.
Коли використовувати pipeline
- Будь-який цикл з Redis-викликами
- Попереднє завантаження даних для списку
- Прогрівання кешу після деплою
- Масова інвалідація кількох ключів
Підсумок
Ці патерни не екзотичні — це основа продакшен-використання Redis. Головний висновок: думайте про Redis як про сервер структур даних, а не просто кеш. Підбирайте правильну структуру під вашу задачу:
| Задача | Структура Redis | Laravel 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/Sub | Redis::publish(), Redis::subscribe() |
| Атомарна багатокрокова логіка | Lua Scripts | Redis::eval() |
| Масові операції | Pipelines | Redis::pipeline() |
Почніть з одного патерну. Буферизований лічильник — найпростіша перемога: реалізація займає 30 хвилин і одразу знижує навантаження на базу. Коли освоїтесь — решта прийде природно.