← назад к разделу

Redis — не просто хранилище строк «ключ → значение». Он предоставляет несколько встроенных структур данных, и правильный выбор между ними напрямую влияет на простоту кода и производительность.

задача одна: поменять у профиля возраст 30 → 31 String: профиль лежит одной строкой JSON GET user:42 читаем всё age 30 → 31 разбор и сборка SET user:42 пишем всё по сети едет всё значение — и туда, и обратно Hash: те же данные разложены по полям HSET user:42 age 31 user:42 name Alex age 30 31 city Msk меняется одно поле — остальные не читаются и не пишутся

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

Обязательно

Почему важно выбирать правильную структуру

Представьте: вы храните профиль пользователя в одной строке JSON. Чтобы обновить одно поле — возраст — вы читаете всю строку, разбираете JSON, меняете поле, сериализуете обратно и пишете снова. Это пять шагов вместо одного.

Hash позволяет обновить одно поле напрямую командой HSET user:42 age 31. Меньше кода, меньше трафика, меньше ошибок.

Цена команды — главный критерий выбора

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

Опасные команды выглядят безобидно:

КомандаСтоимостьЧем заменить на больших ключах
HGETALL keyO(N) по числу полейHMGET нужных полей или HSCAN порциями
SMEMBERS keyO(N)SSCAN порциями, SISMEMBER для проверки
LRANGE key 0 -1O(N)LRANGE окном, LPOP/RPOP
SINTER big1 big2O(N×M) в худшем случаезаранее посчитанное множество, SINTERCARD для счёта
ZRANGE key 0 -1O(N)ZRANGE окном по рангу или по score
KEYS patternO(всех ключей)SCAN MATCH порциями
DEL big_keyO(N)UNLINK (освобождает память в фоне)

Курсорные команды (SCAN, HSCAN, SSCAN, ZSCAN) — это тот же обход, но порциями: каждая возвращает небольшой пакет и курсор для продолжения, поэтому сервер не блокируется. Гарантии у них слабее (элемент, добавленный во время обхода, может не попасть в выдачу, а существующий может прийти дважды), но для обслуживания и обхода этого достаточно.

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

Большие ключи и внутренние представления

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

Пока в хэше, списке, множестве или отсортированном множестве мало элементов и они короткие, Redis держит их одной плоской упаковкой (listpack, а для множеств из целых чисел — intset): нет отдельных объектов, нет указателей, расход памяти в разы меньше. Как только число элементов или длина значения переходит порог — структура перестраивается в полноценную (хэш-таблица, список, дерево пропусков), и память вырастает скачком.

Пороги задаются настройками и по умолчанию примерно такие: hash-max-listpack-entries 128 элементов и hash-max-listpack-value 64 байта, set-max-intset-entries 512, zset-max-listpack-entries 128, list-max-listpack-size 128. Перестройка необратима: даже если элементы удалить, компактное представление не вернётся, пока ключ не создан заново.

Отсюда практический вывод, который объясняет распространённый совет: много маленьких хэшей дешевле одного большого. Миллион полей в одном хэше — это полноценная хэш-таблица со всеми накладными расходами и опасным HGETALL; тот же миллион, разложенный по десяти тысячам хэшей по сто полей, живёт в компактном представлении и обходится точечными командами.

И отдельно про большие ключи (значение на сотни мегабайт, коллекция на миллионы элементов). Чем они плохи: их нельзя прочитать целиком без паузы для всех; их удаление (DEL) — тоже O(N), то есть пауза (поэтому есть UNLINK, который освобождает память в фоне); при репликации и снимке они передаются одним куском; в кластере такой ключ нельзя разделить между узлами — он целиком лежит на одном, и балансировка ломается. Ищут их командой redis-cli --bigkeys или --memkeys, и найденный большой ключ — это не «интересный факт», а задача на перепроектирование.

String — строки и счётчики

String — базовый тип Redis. Значением может быть обычная строка, число, JSON или бинарный blob (например, сжатые данные). Максимальный размер — 512 МБ.

живой пример

SET user:42:name "Алексей"
GET user:42:name          # → "Алексей"

SET page:views 0
INCR page:views           # → 1
INCR page:views           # → 2
INCRBY page:views 10      # → 12
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Короткая формула: INCR / INCRBY — атомарные операции без риска гонки, удобны для счётчиков и ограничителей частоты запросов (rate limiter).

Типичные применения String:

  • кэш HTML-фрагмента или API-ответа;
  • счётчик просмотров, лайков;
  • токен сессии или одноразовый код подтверждения.

TTL и истечение ключей

Любой ключ Redis можно сделать временным через TTL (time to live). Ключ будет автоматически удалён по истечении срока.

живой пример

SET session:abc123 "user_data"
EXPIRE session:abc123 3600    # истекает через 1 час

# установить TTL сразу при записи:
SET otp:phone:79001234567 "8814" EX 300   # живёт 300 секунд

TTL session:abc123            # → секунды (-1 = без срока, -2 = ключа нет)
PERSIST session:abc123        # убрать TTL, сделать постоянным
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Redis использует два механизма вытеснения просроченных ключей:

  • Passive expiration — ключ проверяется и удаляется в момент обращения к нему.
  • Active expiration — фоновый процесс периодически проходит по выборке ключей и удаляет просроченные.

Это значит, что просроченные ключи не исчезают мгновенно — они могут «жить» в памяти ещё несколько секунд до следующего фонового прохода.

И ещё одна тонкость, на которой спотыкаются чаще всего: срок ставится на ключ целиком. Положили профиль пользователя в Hash (см. ниже) — истечёт весь профиль сразу, а завести отдельный срок на поле email не получится: EXPIRE product:10 60 снесёт и name, и price, и stock разом. С версии 7.4 у полей хэша появился собственный срок — команды HEXPIRE, HTTL, HPERSIST, — но до неё привычка «хранить объект в Hash» молча означала «или всё, или ничего».

Рядом со значением лежит отметка времени, и при чтении Redis сравнивает её с текущей. Тот же приём на чистой Java — просроченный ключ, которого никто не касался, память всё ещё занимает:

живой пример

import java.util.HashMap;
import java.util.Map;

public class ExpiryDemo {
    static final Map<String, String> data = new HashMap<>();
    static final Map<String, Long> deadline = new HashMap<>();

    static void set(String key, String value, long ttl, long now) {
        data.put(key, value);
        deadline.put(key, now + ttl);
    }

    static String get(String key, long now) {
        if (deadline.getOrDefault(key, Long.MAX_VALUE) <= now) {
            data.remove(key);
            deadline.remove(key);
            return null;
        }
        return data.get(key);
    }

    public static void main(String[] args) {
        set("session:a", "user-1", 300, 0);
        set("session:b", "user-2", 300, 0);
        System.out.println("секунда 100, session:a = " + get("session:a", 100));
        System.out.println("секунда 400, session:a = " + get("session:a", 400));
        System.out.println("ключей в памяти: " + data.size() + " (session:b просрочен, но его не читали)");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Hash — объект по полям

Hash хранит набор пар «поле → значение» под одним ключом. Идеально подходит для объектов с множеством атрибутов.

живой пример

HSET product:10 name "Ноутбук" price 89990 stock 15
HGET product:10 price          # → "89990"
HGETALL product:10             # → все поля и значения
HINCRBY product:10 stock -1    # уменьшить остаток на 1
HDEL product:10 stock          # удалить одно поле
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Типичные применения Hash:

  • профиль пользователя (имя, email, роль, дата регистрации);
  • корзина покупок (товар → количество);
  • конфигурация приложения.

List — очереди и стеки

List — двусторонняя очередь строк. Поддерживает добавление и чтение с обоих концов.

живой пример

LPUSH tasks "задача-1"       # добавить в начало
RPUSH tasks "задача-2"       # добавить в конец
LPOP tasks                   # взять из начала → "задача-1"
RPOP tasks                   # взять из конца → "задача-2"
LLEN tasks                   # длина списка
LRANGE tasks 0 9             # первые 10 элементов
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Короткая формула: LPUSH + RPOP = очередь FIFO; LPUSH + LPOP = стек LIFO.

Блокирующие варианты BLPOP / BRPOP ожидают появления элемента — это простая замена брокеру для несложных сценариев:

BLPOP tasks 5   # ждать элемент до 5 секунд, потом вернуть nil

Типичные применения List:

  • очередь задач (фоновая обработка);
  • лента последних событий (с LTRIM для ограничения длины);
  • история действий пользователя.

Две вещи про списки, которые решают дело на практике.

Цена концов и середины. LPUSH, RPUSH, LPOP, RPOP — постоянное время независимо от длины списка. А вот LINSERT, LREM, LSET и LRANGE по большому диапазону — линейные: они идут по списку. То есть список хорош как очередь или стек и плох как «коллекция, в которой ищут».

Очередь на списке теряет сообщения. Обработчик сделал RPOP, получил задачу и упал — задача исчезла: она уже не в списке и ещё не выполнена. Для надёжности есть LMOVE (и блокирующий BLMOVE): команда атомарно перекладывает элемент из очереди в список «в обработке», а обработчик удаляет его оттуда только после успеха. Незавершённые задачи остаются видимыми, и отдельный сторож возвращает зависшие обратно.

Это рабочий шаблон, но он требует дисциплины и не даёт групп потребителей. Если очередь — важная часть системы, честнее взять Streams с их подтверждениями (о них ниже) или настоящий брокер.

Set — уникальные элементы

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

живой пример

SADD tags:post:5 "java" "redis" "backend"
SADD tags:post:5 "redis"         # дубликат — игнорируется
SMEMBERS tags:post:5             # → {"java", "redis", "backend"}
SISMEMBER tags:post:5 "java"     # → 1 (есть) / 0 (нет)
SCARD tags:post:5                # → 3 (размер множества)

# Операции над несколькими множествами:
SINTER tags:post:5 tags:post:7   # пересечение
SUNION tags:post:5 tags:post:7   # объединение
SDIFF  tags:post:5 tags:post:7   # разность
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Типичные применения Set:

  • список уникальных посетителей страницы;
  • теги статьи;
  • список друзей или подписчиков (пересечение = общие друзья).

Sorted Set — рейтинги и диапазоны

Sorted Set похож на Set, но каждый элемент имеет числовой score (вес). Элементы хранятся отсортированными по score — это ключевое свойство.

ключ по возрастанию dave 900 alice 1500 carol 1800 bob 2300 ZINCRBY 600 alice dave 900 carol 1800 alice 2100 bob 2300 ZREVRANGE 0 2 bob 2300 alice 2100 carol 1800

Элементы лежат по возрастанию оценки, поэтому смена оценки сама переставляет участника на новое место, а выборка по рангу берёт готовое окно сверху.

живой пример

ZADD leaderboard 1500 "alice"
ZADD leaderboard 2300 "bob"
ZADD leaderboard 1800 "carol"

ZRANGE leaderboard 0 -1 WITHSCORES   # все, от меньшего к большему
ZREVRANGE leaderboard 0 2            # топ-3, от большего к меньшему
ZSCORE leaderboard "alice"           # → "1500"
ZRANK leaderboard "alice"            # позиция (0-based) по возрастанию
ZREVRANK leaderboard "bob"           # позиция в обратном порядке → 0 (1-й)

ZINCRBY leaderboard 200 "alice"      # добавить 200 к score alice
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

С Redis 6.2 ZREVRANGE помечена в документации как устаревшая: тот же результат даёт ZRANGE leaderboard 0 2 REV.

Типичные применения Sorted Set:

  • таблица лидеров игры (score = очки);
  • очередь задач с приоритетом (score = приоритет или временная метка);
  • история событий с поиском по диапазону времени.

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

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

ZADD requests:user:42 1746600000 "req-1"
ZREMRANGEBYSCORE requests:user:42 -inf (1746599940     # выбросить всё старше минуты
ZCARD requests:user:42                                  # сколько осталось в окне

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

Bitmap, HyperLogLog и Streams — кратко

Bitmap — битовый массив поверх String. Каждый бит адресуется смещением. Используется для компактной записи булевых фактов по большому числу сущностей.

200 000 id по биту на пользователя 200 000 бит SETBIT ключ id 1 25 000 байт восемь бит в байте 25 КБ весь день в одном ключе

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

SETBIT active_users:2026-06-01 42 1   # пользователь 42 был активен
GETBIT active_users:2026-06-01 42     # → 1
BITCOUNT active_users:2026-06-01      # количество активных в этот день

HyperLogLog — вероятностная структура для подсчёта уникальных элементов. Занимает не более 12 КБ независимо от числа элементов, но даёт приближённый результат (погрешность ~0,81%).

PFADD visitors:2026-06-01 "user-1" "user-2" "user-3"
PFCOUNT visitors:2026-06-01   # приблизительное число уникальных

Streams — структура для потоков событий, близкая по модели к Kafka. Каждая запись имеет уникальный ID и набор полей. Поддерживает группы потребителей и чтение с подтверждением.

XADD events * action "click" user_id "42"   # добавить событие
XREAD COUNT 10 STREAMS events 0              # первые 10 записей от начала потока
XREVRANGE events + - COUNT 10                # а вот так — последние 10

Streams подходят для журнала аудита, передачи событий между сервисами и простого брокера внутри одного Redis.

Про Streams стоит добавить то, без чего они превращаются в утечку памяти: поток не подрезается сам. Каждая запись остаётся в нём навсегда, даже после подтверждения обработки, — Redis не знает, нужна ли она ещё кому-то.

Поэтому длину ограничивают прямо при записи:

XADD orders MAXLEN ~ 100000 * order_id 42 status NEW

Тильда означает «примерно»: Redis обрежет поток до ближайшей удобной границы, и это заметно дешевле точного обрезания. Есть и вариант по возрасту (MINID с меткой времени) — он удобнее, когда важно «хранить сутки», а не «сто тысяч записей». Уже существующий поток подрезают командой XTRIM.

Ориентир для выбора длины простой: сколько записей нужно, чтобы переживший сбой обработчик смог догнать отставание, плюс запас. Без ограничения поток в памяти растёт линейно нагрузке и однажды упирается в maxmemory.

Какую структуру выбрать

ЗадачаСтруктураЦена типовой операции
Кэш произвольного значения или счётчикStringGET, SET, INCR за O(1)
Объект с именованными полямиHashHGET и HSET за O(1), HGETALL за O(N)
Очередь или стекListконцы за O(1), середина и LRANGE 0 -1 за O(N)
Коллекция уникальных значенийSetSADD и SISMEMBER за O(1), SMEMBERS за O(N)
Рейтинг, очередь с приоритетомSorted SetZADD и ZSCORE за O(log N), окно за O(log N + M)
Активность миллионов сущностейBitmapSETBIT и GETBIT за O(1), BITCOUNT за O(N)
Количество уникальных (приблизительно)HyperLogLogPFADD и PFCOUNT за O(1)
Поток событий с группами потребителейStreamsXADD за O(1), чтение за O(N) по числу выданных
Дополнительно: при первом чтении можно пропустить

Глубже: цена команды: O(N) на одном потоке, listpack и большие ключирасширенное

Redis исполняет команды одним потоком по очереди, и это главное, что нужно знать о его производительности. Пока одна команда идёт, все остальные клиенты ждут. Для GET это микросекунды, и очередь незаметна. Для команды, которая обходит миллион элементов, это десятки миллисекунд, и на это время встаёт весь сервис, а не один запрос. В документации у каждой команды написана её сложность, и это не формальность: O(N) там означает «время растёт с размером ключа».

Опасные команды на больших структурах: KEYS (обход всех ключей базы), SMEMBERS и HGETALL (вся структура целиком), LRANGE 0 -1, ZRANGE по всему множеству, SUNION больших множеств, и даже DEL большого ключа, потому что освобождение миллиона элементов тоже работа. Замены: SCAN, SSCAN, HSCAN идут порциями и не держат сервер, UNLINK вместо DEL освобождает память в фоновом потоке, HMGET по нужным полям вместо HGETALL. Кто уже сейчас держит сервер, показывает SLOWLOG GET 10, порог задаёт slowlog-log-slower-than в микросекундах.

Из того же устройства растёт выбор структуры. Маленькие хеши, списки и сортированные множества Redis хранит не как хеш-таблицу, а как плотный массив, listpack (раньше ziplist): элементы лежат подряд, памяти уходит в разы меньше, а поиск линейный, но на десятке элементов это быстрее хеширования. Пороги настраиваются: hash-max-listpack-entries 128 и hash-max-listpack-value 64 для хешей, аналогичные для списков и сортированных множеств; множество из целых чисел до set-max-intset-entries 512 живёт как отсортированный массив чисел. Стоит хешу вырасти за 128 полей или одному значению за 64 байта, как он перестраивается в настоящую хеш-таблицу с накладными расходами на каждый элемент. OBJECT ENCODING key показывает, в каком виде ключ живёт сейчас. Отсюда классический приём экономии памяти: миллион мелких объектов хранят не как миллион ключей, а как десять тысяч хешей по сотне полей, что укладывается в listpack и экономит память в несколько раз.

Большой ключ это проблема сама по себе. Хеш на миллион полей или список на десять миллионов элементов живёт на одном узле кластера, мигрирует между узлами целиком, блокирует сервер на удалении и на HGETALL, а redis-cli --bigkeys находит такие ключи за один проход. Ориентиры: коллекция до десяти тысяч элементов и значение до мегабайта; всё крупнее делят по ключам (orders:2026-09-24, orders:2026-09-25) или выносят из Redis. Формальные пределы, строка до 512 МБ и четыре миллиарда элементов в коллекции, к практике отношения не имеют.

Коротко

  • Базовых структур пять — String, Hash, List, Set, Sorted Set. Bitmap и HyperLogLog отдельными типами не являются: это способы использовать String. Streams стоят особняком — это поток событий, а не коллекция.
  • String работает и как строка, и как атомарный счётчик (INCR/INCRBY).
  • Hash позволяет обновлять отдельные поля объекта без перезаписи всего значения.
  • List реализует очереди (FIFO) и стеки (LIFO), дёшев концами и линеен серединой; BLPOP делает очередь блокирующей, но задача теряется при падении обработчика — лечится LMOVE в список «в обработке».
  • Set хранит уникальные элементы и умеет пересечение, объединение, разность; Sorted Set добавляет к ним числовой score и держит элементы в порядке.
  • Временный ключ задаётся через EXPIRE, но память просроченный ключ отдаёт не в момент истечения: при обращении или на фоновом проходе.
  • Redis однопоточный, и цена команды на большом ключе — главный критерий выбора структуры: HGETALL, SMEMBERS, LRANGE 0 -1, KEYS линейны и останавливают всех; берут точечные команды, курсорные HSCAN/SSCAN/SCAN и UNLINK вместо DEL, а виновных показывает SLOWLOG.
  • Маленькие хеши и множества живут как listpack и intset (пороги 128 полей, 64 байта, 512 чисел) и после порога перестраиваются необратимо — поэтому много маленьких хэшей дешевле одного большого; большие ключи ищут --bigkeys и делят.
  • Отсортированное множество стоит логарифм: на нём делают окно по времени через оценку и ZREMRANGEBYSCORE. Поток XADD без MAXLEN ~ растёт неограниченно.

Что почитать дальше