Один інстанс Redis обслуговує чотири різні навантаження, і кожне по-своєму відповідає на питання «чи можна втратити цей ключ». Кеш можна втратити повністю, лічильник лімітів - з ризиком пропустити сплеск запитів, а от лок і чергу губити не можна взагалі. Далі про те, як ці чотири режими поводяться на практиці в PHP 8.4 і Laravel 13 і де вони ламаються тихо.
Атомарні лічильники й rate limiting
INCR атомарний, і на цьому приємна частина закінчується. Класична помилка виглядає так:
$redis = Redis::connection();
$hits = $redis->incr($key);
$redis->expire($key, 60);
Дві команди, між якими процес може впасти, а конекшн обірватись. Ключ лишається без TTL, лічильник ніколи не скидається, і користувач отримує 429 до кінця життя інстансу. Дебажити це весело: у 99.9% випадків код працює.
Laravel обходить проблему в Illuminate\Cache\RateLimiter::hit(): спершу add() на ключ-таймер і на сам лічильник (на Redis це SET ... NX EX, тобто TTL ставиться разом зі створенням), і лише потім increment(). Є ще й гілка на випадок, коли лічильник уже існував без TTL: якщо add() не спрацював, а значення після інкременту дорівнює одиниці, значення перезаписується через put() з TTL. Тому фасад RateLimiter і middleware throttle безпечні, а власний лічильник на голому incr() треба писати уважно.
Друге питання - який алгоритм ви насправді реалізували. INCR з TTL дає фіксоване вікно, а у фіксованого вікна є межа: ліміт 60/хв пропускає 120 запитів за дві секунди, якщо вони лягли на 59-ту секунду одного вікна і 1-шу наступного. Для публічного API цього зазвичай досить, для захисту дорогої операції - ні. Ковзне вікно на sorted set рахує події за реальні останні N секунд:
$script = <<<'LUA'
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local used = redis.call('ZCARD', KEYS[1])
if used >= limit then
return -1
end
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return limit - used - 1
LUA;
$remaining = Redis::connection()->eval(
$script, 1, "rl:{$userId}", now()->getPreciseTimestamp(3), 60_000, 60, Str::uuid()->toString(),
);
Laravel зводить сигнатуру eval() до одного вигляду для phpredis і Predis: скрипт, кількість ключів, далі ключі та аргументи. Викликати нативний phpredis-порядок не треба, інакше код зламається при зміні REDIS_CLIENT.
Ціна ковзного вікна - памʼять: кожна подія це окремий member у ZSET. Ліміт 1000/год на 50 тисяч користувачів це до 50 мільйонів елементів. Коли потрібна точність, але не потрібна історія, дешевше вийде token bucket на хеші з двох полів (tokens, updated_at) і тим самим Lua-скриптом: розмір ключа сталий незалежно від трафіку.
Ще одне: лічильники в Redis не годяться як джерело істини для біллінгу чи аналітики. RDB-снепшот втрачає все з моменту останнього збереження, AOF з appendfsync everysec втрачає до секунди записів. Для унікальних значень, де похибка прийнятна, є HyperLogLog: PFADD / PFCOUNT, близько 12 КБ на ключ і стандартна похибка 0.81% незалежно від кількості елементів.
Розподілені блокування і їхні пастки
Механіка Cache::lock() уже розібрана в матеріалі про інвалідацію кешу, тому тут про те, що ламається за межами одного процесу.
Лок з TTL не дає взаємного виключення в строгому сенсі. Двоє справді не візьмуть ключ одночасно, але ніщо не заважає першому продовжувати роботу після того, як його лок протух. Сценарій: воркер бере лок на 30 секунд, зупиняється на 40 секундах у fsync або в чужому HTTP-таймауті, лок протухає, другий воркер заходить, а потім перший прокидається і дописує свій результат. Обидва були «під локом».
Надійного універсального рішення тут немає, є два робочі підходи. Перший - fencing token: під локом робите INCR lock:catalog:fence і передаєте номер далі, а сховище приймає запис лише якщо його токен більший за останній збережений. Запізнілий воркер приходить зі старим номером і отримує відмову. Другий підхід - зробити операцію ідемпотентною, щоб повторне виконання нічого не зіпсувало.
Друга пастка - failover. Реплікація в Redis асинхронна, тому лок, записаний на майстер, може не доїхати до репліки до моменту її промоції. Sentinel переключає, новий майстер про лок не знає, другий клієнт спокійно його бере. Redlock на кількох незалежних майстрах адресує саме це, ціною складності й окремої дискусії про його коректність. На практиці це означає: якщо ціна подвійного виконання висока, лок у Redis має бути оптимізацією, а фінальну гарантію дає unique-констрейнт у базі.
У чергах локи часто присутні неявно. WithoutOverlapping тримає лок на час виконання джоби, і у нього є expireAfter() - якщо не задати його з запасом над реальним часом роботи, отримаєте той самий сценарій з двома власниками. ShouldBeUnique тримає лок від диспатчу до завершення або до вичерпання uniqueFor, а ShouldBeUniqueUntilProcessing відпускає його перед стартом обробки, що дозволяє покласти наступну таку саму джобу в чергу, поки перша ще виконується. Це різна семантика, і вибір між ними майже завжди роблять навмання.
Черги: Redis проти брокера
Redis-драйвер черги в Laravel тримає для кожної черги три ключі: queues:{name} (список), queues:{name}:delayed і queues:{name}:reserved (обидва sorted set). Взяття джоби йде Lua-скриптом: LPOP зі списку і ZADD у reserved зі score now + retry_after. Окремий скрипт перед цим переносить дозрілі елементи з delayed і reserved назад у список.
Звідси головне правило конфігурації. retry_after у config/queue.php має бути більшим за найдовше можливе виконання джоби, а --timeout воркера - меншим за retry_after. Якщо джоба працює 120 секунд, а retry_after дорівнює 90, Redis поверне її в чергу, поки перший воркер ще працює, і ви отримаєте два паралельних виконання без жодної помилки в логах.
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 900,
'block_for' => 5,
'after_commit' => false,
],
block_for перемикає воркер з поллінгу на BLPOP з таймаутом: менше зайвих команд і менша затримка підхоплення. Нуль тут ставити не можна, це блокування назавжди.
Redis-черга добре покриває типовий випадок: один застосунок, свої ж воркери, джоби ідемпотентні, обсяг у межах памʼяті. Horizon працює лише з нею, і це вагомий аргумент.
Брокер стає потрібним, коли зʼявляється щось із цього списку. Одну подію мають незалежно отримати кілька споживачів (у Redis-черги немає fan-out, ви або дублюєте диспатч, або пишете власного споживача на Streams). Потрібне відтворення історії після інциденту, бо LPOP видаляє повідомлення назавжди. Потрібен порядок у межах ключа. Споживачі написані іншими мовами і не мають розуміти формат payload Laravel. Потрібен нормальний dead-letter, який можна переглянути й переграти, а не таблиця failed_jobs.
Проміжний варіант - Redis Streams: XADD для запису, XREADGROUP для consumer groups, XACK для підтвердження, XPENDING і XAUTOCLAIM для підбирання зависань. Це майже семантика брокера всередині Redis, але штатний драйвер черги її не використовує, консюмера доведеться писати самому.
Що зберігати не варто
Великі значення. Redis виконує команди в одному потоці, тому GET на 5 МБ блокує всіх інших клієнтів на час читання й передачі. Те саме роблять HGETALL на хеші в сотні тисяч полів, SMEMBERS на великому сеті й DEL великого ключа (для видалення є UNLINK, який звільняє памʼять у фоні). Знайти такі ключі можна через redis-cli --bigkeys. Payload черги теж сюди належить: у джобу кладіть ідентифікатор файлу, а не сам файл у base64.
Необмежені колекції. LPUSH у список «останніх подій» без LTRIM і ZSET активності без ZREMRANGEBYRANK ростуть, поки не впруться в maxmemory. У таких структур немає TTL на елемент, лише на ключ цілком.
Серіалізовані PHP-обʼєкти. Після деплою, який перейменував властивість, unserialize() віддасть обʼєкт з відсутніми полями або __PHP_Incomplete_Class, і впаде це не в момент деплою, а коли черга дійде до старого payload. Масив або JSON переживають рефакторинг спокійніше.
Дані, до яких потрібен пошук за патерном. KEYS блокує сервер на час обходу всього простору ключів, SCAN не блокує, але не дає консистентного знімка і на великій базі йде довго. Якщо вам регулярно треба «всі ключі користувача», тримайте явний індекс у сеті й оновлюйте його разом з даними.
Памʼять і eviction
За замовчуванням maxmemory дорівнює нулю, тобто без обмеження, і Redis росте, поки його не вбʼє OOM killer ядра. Ліміт треба виставляти завжди, з запасом на фрагментацію та на буфери реплікації.
Далі політика. allkeys-lru і allkeys-lfu витісняють будь-який ключ, volatile-* - лише ті, що мають TTL. Остання група має неочевидний наслідок: якщо TTL не має жодного ключа, витісняти нема чого, і Redis почне повертати OOM command not allowed when used memory > 'maxmemory' на всі записи, тоді як читання продовжать працювати. Застосунок у такому стані виглядає майже здоровим, і це найгірша частина.
Через це кеш, черги й локи не повинні жити на одному інстансі з однією політикою. Кешу потрібен allkeys-lru, бо втрата ключа безпечна. Чергам і локам потрібен noeviction: витіснений reserved-ключ це втрачена джоба, витіснений лок це два власники. Розділення по database в межах одного інстансу від цього не рятує, maxmemory-policy налаштовується на інстанс, а не на базу. Потрібні різні процеси або різні порти.
Кілька речей, які видно тільки коли їх шукаєш. Redis не видаляє прострочені ключі миттєво: активний цикл кожні 100 мс бере вибірку з 20 ключів з TTL і повторює прохід, якщо прострочених виявилось більше чверті. Тому памʼять після масового протухання звільняється не одразу, а used_memory в INFO memory деякий час показує більше, ніж є живих даних. Масове протухання в одну секунду ще й дає сплеск навантаження на базу, тому TTL корисно розмазувати: 3600 + random_int(0, 300) замість рівно години для всіх ключів.
Дрібні обʼєкти дешевше зберігати в хешах, ніж окремими ключами: поки хеш не перевищив hash-max-listpack-entries (128) і hash-max-listpack-value (64 байти), він лежить компактним listpack без накладних витрат на окремий ключ. На мільйонах записів різниця вимірюється гігабайтами, і MEMORY USAGE покаже її на конкретному ключі.
І останнє: CONFIG SET maxmemory-policy allkeys-lru діє до перезапуску. Без CONFIG REWRITE або правки конфігурації інстанс повернеться до noeviction після наступного рестарту, зазвичай о третій ночі.