Redis · Сторы

Сторы

Стор — не структура Redis, а способ разделить одну базу между частями приложения: префикс ключей плюс те команды, которыми пользуются чаще всего. Ниже — зачем он нужен, и разбор каждого метода с аргументами и примерами.

Что такое стор

В Redis нет пространств имён: все ключи лежат в одной плоской базе, и 42 от сессии неотличим от 42 от очереди. Единственный способ их развести — договориться об именах. Стор превращает эту договорённость в код.

main/Stores/SessionStore.php
<?php

namespace Main\Stores;

use Flytachi\Winter\Redis\Store\RedisStore;
use Main\Configurations\MainRedisConfig;

class SessionStore extends RedisStore
{
  protected string $redisConfigClassName = MainRedisConfig::class;   // обязательно
  protected string $prefix               = 'session:';               // по желанию
}

Класс задаёт две вещи: куда ходить (конфигурация) и под каким именем там жить (префикс). Дальше это обычная зависимость:

php
use Flytachi\Winter\DI\Attribute\Autowired;

class AuthService
{
  #[Autowired]
  private SessionStore $sessions;

  public function remember(int $userId, string $token): void
  {
      $this->sessions->set((string) $userId, $token, ttl: 3600);
  }
}

Стор не держит соединения: каждый вызов спрашивает у пула клиента текущей единицы работы. Поэтому стор можно спокойно хранить в поле синглтона, хотя соединение хранить там нельзя.

Что даёт префикс

php
$sessions->set('42', 'token');    // на сервере: session:42
$queue->set('42', 'job');         // на сервере: queue:42

$sessions->get('42');             // 'token'
$queue->get('42');                // 'job'

Два стора на одной базе не мешают друг другу, а flush() и keys() работают в границах своего стора, а не всей базы. Префикс — это просто начало имени ключа, не отдельная сущность на сервере: он должен быть уникален в пределах базы, а двоеточие в конце — привычка, а не требование.

Когда префикс не нужен

Стор без префикса допустим — например, когда приложение единственный владелец базы. Но тогда flush() работать откажется: без префикса под шаблон попадёт всё, включая ключи, о которых вы не знаете.


Справочник методов

Значения

get()

php
public function get(string $key): mixed

Читает значение по ключу.

Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса

Возвращает значение либо null, если ключа нет. Драйвер на этом месте отдаёт false, что неотличимо от сохранённого false; null эту двусмысленность снимает.

Бросает RedisCommandException, если ключ занят структурой другого типа — списком, хешем. Это ошибка в коде, а не отсутствие данных, поэтому она не притворяется null.

php
$store->get('token');      // 'abc123'
$store->get('nothing');    // null

Если нужно хранить именно false

Сохранённое false вернётся как null — различить их через get() нельзя. Храните такие значения закодированными ('0'/'1') или проверяйте наличие отдельно через has().

set()

php
public function set(string $key, mixed $value, ?int $ttl = null): bool

Записывает значение, при необходимости — со сроком жизни.

Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса
$value mixed значение. Строка или число; для остального нужен сериализатор
$ttl ?int null срок жизни в секундах от текущего момента. null — без срока

Возвращает true, если запись выполнена. Бросает LogicException, если $ttl меньше или равен нулю: Redis такой срок не примет, а тихое false в ответ выглядело бы как «не записалось по неизвестной причине».

php
$store->set('token', $jwt);               // навсегда
$store->set('token', $jwt, ttl: 3600);    // на час
$store->set('token', $jwt, ttl: 0);       // LogicException

ttl — это длительность, а не момент времени

ttl: 60 означает «шестьдесят секунд от текущего момента». ttl: time() + 60 просит примерно пятьдесят шесть лет, и никто об этом не сообщит: ключ просто никогда не протухнет. Проявится это не сразу и будет выглядеть как утечка памяти на сервере.

Абсолютного момента в API стора нет намеренно — два похожих смысла в одном аргументе как раз и порождают такую путаницу. Если нужен именно момент:

php
$store->set('session', $data);
$store->raw()->expireAt($store->key('session'), $expiresAt);

Повторная запись существующего ключа снимает с него срок, если новый не задан, — как и обычный SET в Redis.

has()

php
public function has(string $key): bool
Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса

Возвращает true, если ключ существует. В отличие от get() !== null, значение не передаётся по сети — для больших значений это заметно, и это единственный способ отличить сохранённое false от отсутствия.

delete()

php
public function delete(string ...$keys): int

Удаляет один или несколько ключей.

Аргумент Тип По умолчанию Что делает
...$keys string имена ключей без префикса; можно ни одного

Возвращает число ключей, которые существовали и были удалены. Без аргументов — 0, запрос не отправляется.

php
$store->delete('token');                  // 1
$store->delete('token');                  // 0 — его уже нет
$store->delete('a', 'b', 'c');            // 2, если 'c' не существовал

Удаление отсутствующего ключа — не ошибка. Возвращаемое число полезно, когда нужно знать, была ли работа проделана: например, «сессия действительно существовала».

Счётчики

increment() и decrement()

php
public function increment(string $key, int $by = 1): int
public function decrement(string $key, int $by = 1): int

Атомарно изменяют числовое значение.

Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса
$by int 1 на сколько изменить

Возвращают новое значение. Если ключа не было, он создаётся со значением 0 и сразу изменяется. Бросают RedisCommandException, если в ключе лежит не число.

php
$store->increment('hits');        // 1
$store->increment('hits', 10);    // 11
$store->decrement('hits');        // 10

Это одна команда на сервере, а не «прочитать, прибавить, записать»: два одновременных запроса не могут оба прочитать 10 и оба записать 11. Именно поэтому счётчики держат в Redis, а не в переменной приложения.

php
// счётчик обращений с ограничением по времени
$key = "rate:{$userId}";
$hits = $store->increment($key);

if ($hits === 1) {
  $store->raw()->expire($store->key($key), 60);   // окно в минуту
}

if ($hits > 100) {
  throw new TooManyRequests();
}
php
$store->set('name', 'Alice');
$store->increment('name');    // RedisCommandException: value is not an integer

Раньше такой вызов возвращал 0 — отказ выглядел как «счётчик обнулился». Теперь это исключение.

ttl()

php
public function ttl(string $key): ?int
Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса

Возвращает оставшиеся секунды жизни либо null — и когда срок не задан, и когда ключа нет. Если различать эти случаи важно, спросите has().

php
$store->set('token', $jwt, ttl: 3600);

$store->ttl('token');      // 3600
$store->ttl('forever');    // null — ключ есть, срока нет
$store->ttl('nothing');    // null — ключа нет

Обзор содержимого

keys()

php
public function keys(string $pattern = '*'): array

Перечисляет ключи стора.

Аргумент Тип По умолчанию Что делает
$pattern string '*' glob-шаблон, действующий внутри стора

Возвращает массив имён без префикса — то есть в том виде, в каком их принимают get() и delete(). Для пустого стора — пустой массив.

php
$store->keys();            // ['42', 'abc']
$store->keys('user:*');    // только совпавшее, по-прежнему внутри стора

Это SCAN, а не KEYS — и разница не косметическая

KEYS обходит весь keyspace одной командой, а Redis однопоточен: на большой базе он останавливает всех клиентов на всё время обхода. SCAN идёт пачками и даёт серверу дышать между ними.

Плата — результат собирается по частям: ключ, добавленный во время обхода, может в него попасть, а может и нет; удалённый — может ещё успеть попасть в список. Для инвентаризации это нормально, для точного счётчика — нет.

Весь результат материализуется в памяти, поэтому на сторе с миллионами ключей стоит задать узкий шаблон.

flush()

php
public function flush(): int

Удаляет все ключи стора. Аргументов нет.

Возвращает число удалённых ключей. Бросает LogicException, если у стора нет префикса.

php
$sessions->flush();     // 128 — соседние сторы не тронуты

Внутри — тот же SCAN пачками с удалением, поэтому на большом сторе это обход, а не мгновенная операция. Зато сервер не блокируется.

Стор без префикса очистить нельзя

LogicException: Main\Stores\LegacyStore::flush() needs a $prefix —
without one it would delete the whole database.

Это отказ, а не перестраховка: без префикса под шаблон попадает вся база, включая чужие ключи. Если очистить базу целиком — это осознанное действие: raw()->flushDB().

Структуры

hash(), list() и stream()

php
public function hash(string $key): RedisHash
public function list(string $key): RedisList
public function stream(string $key): RedisStream

Возвращают ручку, привязанную к ключу: на хеш, на список или на стрим.

Аргумент Тип По умолчанию Что делает
$key string имя ключа без префикса

Возвращают объект-представление. Ни один из вызовов ничего не отправляет на сервер: это взгляд на ключ, а не запрос.

php
$store->hash('cart:42')->set('qty', '2');
$store->list('jobs')->push($payload);
$store->stream('events')->add(['type' => 'signup']);

Префикс применяется в момент получения ручки — дальше его негде забыть. В этом и разница с raw(), где о нём приходится помнить.

Прямой доступ

raw()

php
public function raw(): \Redis

Возвращает клиента текущей единицы работы. Аргументов нет.

У Redis сотни команд, и обернуть их все — работа без конца. Всё, чего нет в сторе и в ручках, берётся отсюда: сортированные множества, pub/sub, SETNX, скрипты.

php
$store->raw()->zAdd($store->key('leaders'), 100, 'user:1');
$store->raw()->publish('events', $payload);

Префикс здесь не применяется — и это тихая ошибка

$store->raw()->zAdd('leaders', ...) не выбросит исключения и не вернёт ошибку. Ключ просто запишется мимо стора, и всё будет выглядеть работающим.

Последствия появятся позже и в другом месте: keys() и get() этот ключ не увидят, flush() его не удалит, а два стора, оба забывшие префикс, тихо поделят одно имя — ровно та коллизия, ради предотвращения которой префикс и заводился. Забытый key() не ломает вызов, он отменяет единственную гарантию стора.

Правило простое: каждый ключ, попадающий в raw(), оборачивается в key() — или, если у структуры один ключ на всю работу, берите ручку hash()/list(), где префикс уже применён.

Сам клиент действителен только в пределах текущего запроса: под Swoole он уходит обратно в пул, когда корутина завершилась. Сохранённый в поле долгоживущего объекта, он раздаст один сокет всем последующим запросам — ровно то, ради предотвращения чего существует пул.

key()

php
public function key(string $name): string
Аргумент Тип По умолчанию Что делает
$name string имя ключа без префикса

Возвращает полное имя с префиксом — то, что видит сервер. Нужно везде, где ключ уходит в raw().

php
$store->key('42');      // 'session:42'
$store->key('');        // 'session:' — сам префикс

transaction()

php
public function transaction(callable $callback): mixed

Выполняет колбэк на одном и том же соединении от начала до конца.

Аргумент Тип По умолчанию Что делает
$callback callable(\Redis): mixed получает сырого клиента, его результат возвращается наружу

Возвращает то, что вернул колбэк.

Это единственное место, где автоматического возврата соединения в пул недостаточно: MULTI и pipeline() держат состояние на соединении, поэтому все команды последовательности обязаны попасть в один сокет.

php
$result = $store->transaction(function (\Redis $redis) use ($store) {
  $redis->multi();
  $redis->incr($store->key('a'));
  $redis->incr($store->key('b'));
  return $redis->exec();
});    // [1, 1]

Ключи здесь не префиксуются автоматически — в колбэк приходит сырой клиент, поэтому key() обязателен, как и в raw().

Транзакция Redis — не транзакция базы данных

MULTI/EXEC гарантируют, что команды выполнятся подряд и без чужих команд между ними. Отката нет: если одна из команд не сработает, остальные всё равно применятся. Проверять условия нужно до, а не после.

configClass()

php
public function configClass(): string

Класс конфигурации, к которому привязан стор. Аргументов нет. Нужен для диагностики и для RedisPool::stats(), где ключи — имена конфигураций.

Что стор ожидает от значений

С сериализатором по умолчанию (SERIALIZER_NONE) значение должно быть строкой или числом:

php
$store->set('user', ['id' => 1]);   // в базе строка "Array", только PHP-warning
$store->get('user');                 // 'Array'

Чтобы хранить массивы и объекты, задайте сериализатор в конфигурации. Он применяется к соединению, поэтому распространяется и на значения полей хеша, и на элементы списков.

Соответствие командам Redis

Метод Команда
get() / set() GET / SET, со сроком — SETEX
has() / delete() EXISTS / DEL
increment() / decrement() INCRBY / DECRBY
ttl() TTL
keys() SCAN, не KEYS
flush() SCAN + DEL
hash() / list() / stream() — (ничего не отправляют)
key() / raw()
transaction() что вызовет колбэк

Дальше

  • Хеши — поля, их сроки жизни и требования к версии сервера
  • Списки — очереди и блокирующее чтение
  • Стримы — журнал событий и группы потребителей
  • Пул соединений — откуда берётся клиент и когда возвращается