Redis · Хеши

Хеши

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

Что такое хеш

Хеш — это отображение строк в строки внутри одного ключа 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()) и меняют по одному.

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

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

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

Ручка

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

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

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


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

Чтение

get()

php
public function get(string $field): mixed

Значение одного поля.

Аргумент Тип По умолчанию Что делает
$field string имя поля

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

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

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

getMany()

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

Несколько полей за один round-trip.

Аргумент Тип По умолчанию Что делает
...$fields string имена полей; можно ни одного

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

php
$cart->getMany('qty', 'sku', 'promo');
// ['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() и values()

php
public function fields(): array
public function values(): array

Только имена полей либо только значения. Аргументов нет. Возвращают массив; для несуществующего ключа — пустой.

Порядок не определён, но у обоих методов он одинаков — значения соответствуют именам позиция в позицию.

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

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

has()

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

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

count()

php
public function count(): int

Число полей. Аргументов нет. Возвращает количество, для несуществующего ключа — 0. Операция O(1).

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

Запись

set()

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

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

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

Возвращает true, если команда выполнена. Бросает LogicException при ttl меньше или равном нулю (это ошибка вызывающего: чтобы убрать поле, есть delete()) и RedisFeatureException, если сервер старше 8.0 — см. Требования к версии.

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

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

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

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

setAll()

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

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

Аргумент Тип По умолчанию Что делает
$fields array массив «поле → значение». Пустой массив ничего не делает
$ttl ?int null один срок жизни на все записываемые поля

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

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

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

Записать пять полей одним setAll() — это один round-trip вместо пяти. На горячем пути разница заметна.

increment() и decrement()

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

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

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

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

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

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

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

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

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

delete()

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

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

Аргумент Тип По умолчанию Что делает
...$fields string имена полей; можно ни одного

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

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 string имя поля

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

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 string имя поля

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

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 int через сколько секунд удалить хеш целиком

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

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

Срок считается от текущего момента: expireKey(time() + 86400) попросит пятьдесят шесть лет и не сообщит об этом.

keyTtl()

php
public function keyTtl(): ?int

Аргументов нет. Возвращает оставшиеся секунды жизни ключа либо null — и когда срока нет, и когда нет ключа.

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

deleteKey()

php
public function deleteKey(): bool

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

name()

php
public function name(): string

Имя ключа с префиксом — то, что видит сервер. Нужно для команд, которых нет в ручке:

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

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

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

На более старом сервере эти три метода бросают RedisFeatureException с точным объяснением:

php
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() и транзакции
  • Конфигурация — сериализация значений