Redis · Хеши

Хеши

Хеш — словарь «поле → значение» под одним ключом. Это способ хранить объект целиком, не размазывая его по десятку ключей и не перечитывая целиком ради одного поля. Ниже — что это за структура, где она уместна, и разбор каждого метода.

Пакет flytachi/winter-redisКласс RedisHashСроки полей Redis 7.4 / 8.0

Что такое хеш и зачем

Проблема. Объект приходится куда-то класть. Разложить по отдельным ключам — user:42:name, user:42:email, user:42:plan — значит завести три ключа вместо одного, трижды заплатить накладными расходами Redis на ключ и потерять возможность удалить объект одной командой. Сложить в один ключ строкой JSON — значит перечитывать и переписывать весь объект ради одного поля, а два одновременных обновления разных полей затрут друг друга.

Решение. Хеш — контейнер «поле → значение» внутри одного ключа. Поле читается и пишется отдельно от остальных, объект удаляется целиком одной командой, а память Redis тратится как на один ключ, а не на десять.

php
$cart = $store->hash('cart:42');

$cart->setAll(['qty' => '2', 'sku' => 'A-1']);
$cart->increment('qty');      // 3 — соседние поля не трогали
$cart->get('sku');            // 'A-1'

Свойства структуры

Хеш — это отображение строк в строки внутри одного ключа Redis: cart:42 содержит поля qty, sku, total. Свойства, из которых следует всё остальное:

  • Доступ к полю — O(1), независимо от того, сколько в хеше полей.
  • Значения — строки, как и везде в Redis. Вложенных структур нет: поле не может содержать другой хеш или список.
  • Порядок полей не определён. fields() вернёт их в том порядке, в каком удобно серверу, а не в порядке добавления.
  • Полей может быть до 4 294 967 295.

Маленькие хеши хранятся особым компактным представлением (listpack) и занимают заметно меньше памяти, чем те же данные в отдельных ключах — на текущих версиях порог составляет 512 полей (hash-max-listpack-entries). Это и есть главный практический аргумент за хеш: тысяча объектов по десять полей в виде хешей дешевле десяти тысяч отдельных ключей.

Как и список, хеш не нужно создавать: запись первого поля создаёт ключ, удаление последнего — удаляет.

Где принято применять

Объект или запись. Профиль пользователя, корзина, карточка заказа. Одно поле читается и обновляется без чтения и перезаписи всего объекта — в отличие от JSON в обычном ключе.

Сгруппированные счётчики. Просмотры по дням, метрики по эндпоинтам, лимиты по клиентам: hash('stats:2026-08') с полями-датами. increment() атомарен на уровне поля.

Настройки и флаги. Небольшой набор параметров, которые читают целиком (all()) и меняют по одному.

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

Где хеш — неправильный выбор:

Задача Почему не хеш Что вместо
Найти объекты по значению поля хеш не индексируется, придётся перебирать вторичный индекс на множествах
Хранить вложенную структуру значения — только строки сериализация или отдельные ключи
Сортировать по значению порядка нет сортированное множество (ZSET)
Хранить очередь или историю поля не упорядочены список или стрим
Сотни тысяч полей с частым обходом all() тянет всё разом несколько ключей или HSCAN

Справочник

RedisHash

RedisHash — ручка на один хеш: объект, через который выполняются команды над этим ключом. Получают её у стора:

php
$cart = $store->hash('cart:42');    // сервер увидит 'session:cart:42'

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

Все методы, кроме перечисленных в разделе Ключ целиком, работают с полями. Это же относится и к аргументу $ttl: он задаёт срок тому, что пишет вызов, — то есть полю, а не ключу.

Чтение

get()

Читает значение одного поля.

Синтаксис

php
public function get(string $field): mixed

Параметры

$field — имя поля.

Возвращает

Значение, либо null — и если поля нет, и если нет самого хеша. Драйвер в обоих случаях отдаёт false; null выбран, чтобы «нет значения» не путалось с сохранённым false.

Ошибки

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

Пример

php
$cart->get('qty');       // '2'
$cart->get('nothing');   // null

getMany()

Читает несколько полей за один обмен с сервером.

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

Синтаксис

php
public function getMany(string ...$fields): array

Параметры

...$fields — имена полей, сколько угодно. Можно не передать ни одного.

Возвращает

Массив «поле → значение», в котором всегда есть все запрошенные имена: отсутствующие приходят как null. Без аргументов — пустой массив.

Пример

php
$cart->getMany('qty', 'sku', 'promo');

Результат

text
['qty' => '2', 'sku' => 'A-1', 'promo' => null]

Разбор в переменные

php
// три поля — один запрос к серверу, а не три
['name' => $name, 'email' => $email] = $profile->getMany('name', 'email');

all()

Читает весь хеш целиком.

Синтаксис

php
public function all(): array

Параметры

Нет.

Возвращает

Массив «поле → значение»; для несуществующего ключа — пустой массив.

Пример

php
$cart->all();        // ['qty' => '2', 'sku' => 'A-1']
$missing->all();     // []

Это `HGETALL`: весь хеш одним ответом

Redis однопоточен, поэтому сборка ответа по хешу в сотни тысяч полей блокирует сервер целиком, а результат ещё и оказывается в памяти процесса. Для таких хешей берите нужное через getMany() или обходите пачками через HSCAN из raw().

fields()

Читает только имена полей.

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

Синтаксис

php
public function fields(): array

Параметры

Нет.

Возвращает

Массив имён; для несуществующего ключа — пустой. Порядок не определён.

Пример

php
$cart->fields();    // ['qty', 'sku']

values()

Читает только значения полей.

Синтаксис

php
public function values(): array

Параметры

Нет.

Возвращает

Массив значений; для несуществующего ключа — пустой.

Порядок не определён, но он тот же, что у fields(): значения соответствуют именам позиция в позицию, если оба вызова сделаны без изменений между ними.

Пример

php
$cart->fields();    // ['qty', 'sku']
$cart->values();    // ['2', 'A-1']

has()

Проверяет, существует ли поле.

Отличается от get() !== null тем, что не передаёт значение по сети, — а для большого значения это заметно.

Синтаксис

php
public function has(string $field): bool

Параметры

$field — имя поля.

Возвращает

true, если поле существует.

count()

Сообщает число полей.

Синтаксис

php
public function count(): int

Параметры

Нет.

Возвращает

Количество полей; для несуществующего ключа — 0. Операция O(1): сервер хранит это число и не пересчитывает его.

Пример

php
if ($cart->count() === 0) {
  return null;    // корзины нет
}

Запись

set()

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

С $ttl это одна команда HSETEX, а не запись с последующим HEXPIRE. Разница существенная: между двумя командами есть щель, в которой поле уже есть, а срока у него ещё нет, — и если в этот момент что-то сорвётся, поле останется навсегда.

Синтаксис

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

Параметры

$field — имя поля.

$value — значение. Строка или число; чтобы записать массив или объект, нужен сериализатор — см. Конфигурация.

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

Возвращает

true, если команда выполнена.

Ошибки

LogicException — при $ttl меньше или равном нулю. Чтобы убрать поле, есть delete().

RedisFeatureException — если сервер не умеет HSETEX; см. Требования к версии.

Пример

php
$cart->set('qty', '2');
$cart->set('lock', '1', ttl: 30);     // это поле исчезнет через полминуты

Перезапись без `ttl` снимает срок с поля

Проверено на живом сервере: поле со сроком в сто секунд после set('lock', '2') без ttl теряет срок и остаётся навсегда. Так устроен HSET, и это легко не заметить — обновление значения выглядит безобидно. Если срок нужен, задавайте его при каждой записи.

setAll()

Записывает несколько полей одной командой.

Записать пять полей одним вызовом — это один обмен с сервером вместо пяти. На горячем пути разница заметна.

Синтаксис

php
public function setAll(array $fields, ?int $ttl = null): bool

Параметры

$fields — массив «поле → значение». Пустой массив ничего не делает.

$ttl — один срок жизни на все записываемые поля. По умолчанию null.

Возвращает

true, если команда выполнена; для пустого массива — тоже true, поскольку записать нечего и это не ошибка.

Ошибки

LogicException — при $ttl меньше или равном нулю.

RedisFeatureException — если сервер не умеет HSETEX.

Пример

php
$cart->setAll([
  'qty'   => '2',
  'sku'   => 'A-1',
  'total' => '1990',
]);

$session->setAll(['user' => $id, 'ip' => $ip], ttl: 3600);   // срок у обоих полей

increment()

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

Это одна команда на сервере, а не «прочитать, прибавить, записать»: два одновременных запроса не могут оба прочитать 5 и оба записать 6.

Синтаксис

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

Параметры

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

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

Возвращает

Новое значение поля.

Ошибки

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

php
$cart->set('sku', 'A-1');
$cart->increment('sku');
text
RedisCommandException: ERR hash value is not an integer

Пример

php
$stats = $store->hash('stats:2026-08');

$stats->increment('16');        // 1  — первый просмотр за 16-е
$stats->increment('16', 5);     // 6

decrement()

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

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

Синтаксис

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

Параметры

$field — имя поля.

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

Возвращает

Новое значение поля; уйти ниже нуля оно может.

Ошибки

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

Пример

php
$stats->increment('16', 6);     // 6
$stats->decrement('16');        // 5
$stats->decrement('16', 2);     // 3

delete()

Удаляет поля.

Синтаксис

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

Параметры

...$fields — имена полей, сколько угодно. Можно не передать ни одного — тогда запрос не отправляется.

Возвращает

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

Пример

php
$cart->delete('promo');                 // 1
$cart->delete('promo');                 // 0 — его уже нет
$cart->delete('qty', 'sku', 'total');   // 3

Хеш без полей перестаёт существовать

Redis не хранит пустые контейнеры: удаление последнего поля удаляет и ключ. Проверить можно так же, как и наличие — count() === 0.

Сроки жизни полей

Отдельный срок у каждого поля — возможность новых версий Redis (см. ниже). Она пригодится там, где часть данных временная: блокировка внутри объекта, одноразовый код в сессии, кеш поверх записи.

ttl()

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

Синтаксис

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

Параметры

$field — имя поля.

Возвращает

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

Ошибки

RedisFeatureException — если сервер не умеет HTTL.

Пример

php
$cart->set('lock', '1', ttl: 60);

$cart->ttl('lock');      // 60
$cart->ttl('sku');       // null — у соседнего поля срока нет
$cart->ttl('nothing');   // null — и поля нет

persist()

Снимает срок с поля, оставляя само поле на месте.

Синтаксис

php
public function persist(string $field): bool

Параметры

$field — имя поля.

Возвращает

true, если срок был снят; false, если снимать было нечего — у поля не было срока либо поля нет.

Ошибки

RedisFeatureException — если сервер не умеет HPERSIST.

Пример

php
$session->set('code', $otp, ttl: 300);

$session->persist('code');     // true — код подтверждён, пусть остаётся
$session->persist('code');     // false — срока уже нет

Ключ целиком

Методы этого раздела относятся к хешу как к одному ключу. Слово Key в названии стоит именно поэтому: ttl('lock') — про поле, keyTtl() — про весь хеш.

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

expireKey()

Задаёт срок жизни всему хешу.

Синтаксис

php
public function expireKey(int $seconds): bool

Параметры

$seconds — через сколько секунд удалить хеш целиком. Отсчёт от текущего момента: expireKey(time() + 86400) попросит пятьдесят шесть лет и не сообщит об этом.

Возвращает

true, если срок установлен; false, если ключа не существует.

Пример

php
$cart->setAll(['qty' => '1']);
$cart->expireKey(86400);     // брошенная корзина живёт сутки

keyTtl()

Сообщает, сколько осталось жить всему хешу.

Синтаксис

php
public function keyTtl(): ?int

Параметры

Нет.

Возвращает

Оставшиеся секунды, либо null — и когда срока нет, и когда нет ключа.

deleteKey()

Удаляет хеш со всеми полями.

Синтаксис

php
public function deleteKey(): bool

Параметры

Нет.

Возвращает

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

Пример

php
$cart->deleteKey();    // true
$cart->deleteKey();    // false — уже удалён

name()

Возвращает имя ключа с префиксом — то, что видит сервер.

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

Синтаксис

php
public function name(): string

Параметры

Нет.

Возвращает

Полное имя ключа.

Пример

php
$cursor = null;
$store->raw()->hScan($cart->name(), $cursor, '*', 100);   // обход большого хеша

Требования к версии сервера

Что С какой версии
Всё, кроме сроков полей любая
ttl(), persist() Redis 7.4 (HTTL, HPERSIST)
set() и setAll() с ttl Redis 8.0 (HSETEX)

На более старом сервере эти методы бросают RedisFeatureException с точным объяснением — какая команда, какая версия нужна, какая стоит и что делать вместо:

text
RedisFeatureException: Redis HSETEX (per-field lifetimes) needs server 8.0 or newer;
this server reports 7.2.4. Give the whole hash a lifetime with expireKey() instead.

Почему отказ, а не запасной путь

На 7.4 можно было бы откатываться на HSET + HEXPIRE. Тогда поведение зависело бы от версии сервера, а щель между двумя командами вернулась бы — и проявилась бы однажды в проде, а не на глазах у разработчика. Один способ, который либо работает, либо честно отказывается, надёжнее двух похожих.

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

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

Метод Команда
get() / getMany() / all() HGET / HMGET / HGETALL
fields() / values() HKEYS / HVALS
has() / count() HEXISTS / HLEN
set() / setAll() HSET, со сроком — HSETEX
increment() / decrement() HINCRBY
delete() HDEL
ttl() / persist() HTTL / HPERSIST
expireKey() / keyTtl() / deleteKey() EXPIRE / TTL / DEL

Чего здесь нет — HSCAN, HRANDFIELD, HINCRBYFLOAT, HSETNX — доступно через raw() вместе с name().

Дальше

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