Redis · Сторы

Сторы

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

Пакет flytachi/winter-redisКласс RedisStoreМетодов 16

Что такое стор и зачем

Проблема. В Redis нет пространств имён. Все ключи лежат в одной плоской базе, и ключ 42, записанный модулем сессий, ничем не отличается от ключа 42, записанного очередью задач: кто пришёл вторым, тот и затёр. Разводят их единственным способом — договариваются об именах. Договорённость живёт в комментариях и в памяти команды, а строка 'session:' дублируется по всему коду, и однажды кто-то напишет 'sessions:'.

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

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() работать откажется: без префикса под шаблон попадёт всё, включая ключи, о которых вы не знаете.


Справочник

RedisStore

RedisStore — абстрактный класс, от которого наследуют сторы приложения. Он не создаётся сам по себе: смысл стора в двух свойствах, которые задаёт наследник.

$redisConfigClassName — класс конфигурации, то есть точка подключения. Обязателен.

$prefix — строка, приписываемая к каждому ключу. По умолчанию пустая.

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

Значения

get()

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

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

Синтаксис

php
public function get(string $key): mixed

Параметры

$key — имя ключа без префикса; полное имя соберёт сам стор.

Возвращает

Значение, либо null, если ключа нет.

Ошибки

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

Пример

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

Результат при неверном типе

text
RedisCommandException: WRONGTYPE Operation against a key holding the wrong kind of value

Что вернётся, если сохранить `false`

Зависит от сериализатора, и оба случая стоит знать.

Без сериализатора (умолчание) false уходит на сервер как пустая строка, и get() вернёт '' — не false и не null.

С настроенным сериализатором значение переживает поездку и приходит от драйвера как false, а стор превращает false в null — то есть от отсутствующего ключа его уже не отличить.

Если нужно хранить именно логическое значение, кодируйте его ('0' / '1') или проверяйте наличие отдельно через has().

set()

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

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

Синтаксис

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

Параметры

$key — имя ключа без префикса.

$value — значение. С сериализатором по умолчанию это должна быть строка или число: сервер хранит байты как есть, и массив превратится в строку "Array". Чтобы хранить массивы и объекты, нужен сериализатор — см. Конфигурация.

$ttl — срок жизни в секундах, отсчитываемый от текущего момента. По умолчанию null — ключ живёт, пока его не удалят.

Возвращает

true, если запись выполнена.

Ошибки

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

text
LogicException: A ttl must be a positive number of seconds; got 0.
To remove a key, call delete().

Пример

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

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

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

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

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

has()

Проверяет, существует ли ключ.

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

Синтаксис

php
public function has(string $key): bool

Параметры

$key — имя ключа без префикса.

Возвращает

true, если ключ существует.

Пример

php
$store->has('token');      // true
$store->has('nothing');    // false

delete()

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

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

Синтаксис

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

Параметры

...$keys — имена ключей без префикса, сколько угодно. Можно не передать ни одного — тогда запрос на сервер не уйдёт вовсе.

Возвращает

Число ключей, которые существовали и были удалены.

Пример

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

ttl()

Сообщает, сколько ключу осталось жить.

Синтаксис

php
public function ttl(string $key): ?int

Параметры

$key — имя ключа без префикса.

Возвращает

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

Пример

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

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

Счётчики

increment()

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

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

Синтаксис

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

Параметры

$key — имя ключа без префикса. Если ключа не было, он создаётся со значением 0 и сразу увеличивается.

$by — на сколько увеличить. По умолчанию 1.

Возвращает

Новое значение.

Ошибки

RedisCommandException — если в ключе лежит не число:

php
$store->set('name', 'Alice');
$store->increment('name');
text
RedisCommandException: ERR value is not an integer or out of range

Пример

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

Счётчик с окном

php
// Не больше ста обращений в минуту от одного пользователя
$key  = "rate:{$userId}";
$hits = $store->increment($key);

if ($hits === 1) {
  $store->raw()->expire($store->key($key), 60);   // окно заводится вместе со счётчиком
}

if ($hits > 100) {
  throw new TooManyRequests();
}

decrement()

Атомарно уменьшает числовое значение.

Полная противоположность increment() и во всём остальном идентичен ему: та же атомарность, то же создание отсутствующего ключа из нуля, то же исключение на нечисловом значении.

Синтаксис

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

Параметры

$key — имя ключа без префикса.

$by — на сколько уменьшить. По умолчанию 1.

Возвращает

Новое значение. Уйти ниже нуля оно может — ограничения снизу нет.

Ошибки

RedisCommandException — если в ключе лежит не число.

Пример

php
$store->increment('hits', 10);    // 10
$store->decrement('hits');        // 9
$store->decrement('hits', 3);     // 6

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

keys()

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

Инструмент инвентаризации: посмотреть, что лежит, собрать список для удаления, показать содержимое в админке. Для выборки данных он не нужен — за значением ходят по известному имени.

Синтаксис

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

Параметры

$pattern — glob-шаблон, действующий внутри стора: префикс подставляется сам, так что 'user:*' найдёт session:user:1, а не всё подряд в базе. По умолчанию '*' — все ключи стора.

Возвращает

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

Пример

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

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

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

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

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

flush()

Удаляет все ключи стора.

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

Синтаксис

php
public function flush(): int

Параметры

Нет.

Возвращает

Число удалённых ключей.

Ошибки

LogicException — если у стора нет префикса.

Пример

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

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

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

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

Структуры

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

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

hash()

Возвращает ручку на хеш, лежащий под этим ключом.

Хеш — это словарь внутри одного ключа: поля и их значения. Подробно — Хеши.

Синтаксис

php
public function hash(string $key): RedisHash

Параметры

$key — имя ключа без префикса.

Возвращает

RedisHash, привязанный к полному имени ключа.

Пример

php
$store->hash('cart:42')->set('qty', '2');

list()

Возвращает ручку на список, лежащий под этим ключом.

Список — упорядоченная последовательность с быстрыми операциями по краям; на нём делают очереди. Подробно — Списки.

Синтаксис

php
public function list(string $key): RedisList

Параметры

$key — имя ключа без префикса.

Возвращает

RedisList, привязанный к полному имени ключа.

Пример

php
$store->list('jobs')->push($payload);

stream()

Возвращает ручку на стрим, лежащий под этим ключом.

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

Синтаксис

php
public function stream(string $key): RedisStream

Параметры

$key — имя ключа без префикса.

Возвращает

RedisStream, привязанный к полному имени ключа.

Пример

php
$store->stream('events')->add(['type' => 'signup']);

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

raw()

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

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

Синтаксис

php
public function raw(): \Redis

Параметры

Нет.

Возвращает

Объект \Redis — клиента расширения ext-redis, уже подключённого и выбравшего нужную базу.

Пример

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()

Собирает полное имя ключа — то, которое увидит сервер.

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

Синтаксис

php
public function key(string $name): string

Параметры

$name — имя ключа без префикса.

Возвращает

Строку «префикс + имя».

Пример

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

transaction()

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

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

Синтаксис

php
public function transaction(callable $callback): mixed

Параметры

$callback — функция, получающая сырого клиента \Redis. Всё, что она вызовет, уйдёт по одному соединению.

Возвращает

То, что вернул колбэк, без изменений.

Пример

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()

Возвращает класс конфигурации, к которому привязан стор.

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

Синтаксис

php
public function configClass(): string

Параметры

Нет.

Возвращает

Полное имя класса конфигурации.

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

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

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

$store->set('n', 42);
$store->get('n');                    // '42' — строка, не int

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

Соответствие командам 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() / configClass() —
transaction() что вызовет колбэк

Дальше

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