Структуры данных Redis: от строк до Streams
Redis — не просто «ключ-значение». Это набор специализированных структур данных, каждая из которых решает свой класс задач. Знание структур — это разница между «Redis как кэш строк» и «Redis как основа для лидербордов, очередей, геопоиска и pub/sub».
В этой главе — полный обход всех структур с командами, паттернами и компромиссами. К концу ты выберешь правильную структуру для любой задачи.
Строки: база и счётчики
Заголовок раздела «Строки: база и счётчики»Строка — простейшая структура, но с мощными операциями.
SET user:1:name "Alice" EX 3600 # строка с TTL 1 часGET user:1:name # "Alice"SET counter:page:home 0 # инициализация счётчикаINCR counter:page:home # 1 — атомарный инкрементINCRBY counter:page:home 10 # 11DECR counter:page:home # 10GETSET counter:page:home 100 # вернёт 10, установит 100
# Для rate limiting: INCR + EXPIREINCR rl:ip:1.2.3.4 # 1EXPIRE rl:ip:1.2.3.4 60 # TTL 60 секунд, только если ключ новыйСтроки хранят до 512 MB. Бинарно-безопасны — можно хранить JPEG, сериализованные объекты, что угодно.
Хэши: объекты с полями
Заголовок раздела «Хэши: объекты с полями»Хэш — мини-таблица внутри ключа. Удобен для профилей, настроек, объектов с частичным обновлением. Полная страница по структуре — в документации Redis по хэшам.
HSET user:1 name "Alice" email "alice@example.com" age 30HGET user:1 name # "Alice"HGETALL user:1 # все поля и значенияHSET user:1 age 31 # обновление одного поляHDEL user:1 age # удаление поляHEXISTS user:1 email # 1 — существуетHINCRBY user:1 login_count 1 # атомарный счётчик внутри хэшаHKEYS user:1 # список полейВ Node.js:
await redis.hSet('user:1', { name: 'Alice', email: 'alice@example.com' });const user = await redis.hGetAll('user:1'); // { name: 'Alice', email: 'alice@example.com' }await redis.hIncrBy('user:1', 'login_count', 1);Преимущество перед JSON-строкой: обновление одного поля без чтения-перезаписи всего объекта. Для объектов с 20 полями, из которых часто меняется одно — существенно.
Списки: очереди и стеки
Заголовок раздела «Списки: очереди и стеки»Список — упорядоченная коллекция строк, двусторонняя.
LPUSH queue:jobs "job:1" # в началоLPUSH queue:jobs "job:2" # в началоRPUSH queue:jobs "job:3" # в конецLRANGE queue:jobs 0 -1 # [job:2, job:1, job:3] — от начала к концуLPOP queue:jobs # job:2 — из начала (FIFO)BRPOP queue:jobs 0 # blocking pop из конца, ждёт бесконечноBRPOP queue:jobs 5 # ждёт до 5 секундLLEN queue:jobs # 2Паттерн producer-consumer:
# ProducerRPUSH queue:email '{"to":"a@b.c","template":"welcome"}'
# Consumer (в цикле)BRPOP queue:email 0 # ждёт, блокируется, возвращает когда появитсяОграничение: нет повторной доставки. Если consumer взял задачу и упал — задача потеряна. Для надёжности — Redis Streams.
Сеты: уникальные коллекции
Заголовок раздела «Сеты: уникальные коллекции»Сет — неупорядоченная коллекция уникальных строк. Мгновенная проверка принадлежности, пересечения, объединения.
SADD tags:post:1 "redis" "database" "cache"SADD tags:post:2 "redis" "queue"SADD tags:post:3 "database" "sql"
SISMEMBER tags:post:1 "redis" # 1 — естьSISMEMBER tags:post:1 "sql" # 0 — нетSCARD tags:post:1 # 3 — количество
# Кто лайкнул оба поста?SINTER tags:post:1 tags:post:2 # ["redis"]# Все теги из двух постов?SUNION tags:post:1 tags:post:2 # ["redis", "database", "cache", "queue"]# Разница: что в первом, чего нет во втором?SDIFF tags:post:1 tags:post:2 # ["database", "cache"]Классика: «кто из друзей онлайн» — SINTER online users:friends:42. Проверка «лакнул ли пользователь пост» — SISMEMBER likes:post:1 user:7, O(1), без запроса к БД.
Sorted Sets: лидерборды и рейтинги
Заголовок раздела «Sorted Sets: лидерборды и рейтинги»Sorted Set — сет с числовым score. Redis держит его отсортированным. Каждая операция — O(log N), даже на миллионах элементов. Все команды структуры собраны на странице Sorted Sets в документации Redis.
ZADD leaderboard 950 "user:7" # добавить/обновить очкиZADD leaderboard 820 "user:3" 1250 "user:9"
ZREVRANGE leaderboard 0 9 WITHSCORES # топ-10 (по убыванию)# 1) "user:9" 2) "1250" 3) "user:7" 4) "950" ...
ZREVRANK leaderboard "user:7" # 1 — место (0-based)ZSCORE leaderboard "user:7" # 950ZRANGEBYSCORE leaderboard 800 1000 # диапазон по очкамZINCRBY leaderboard 50 "user:7" # +50 очков, атомарноZCARD leaderboard # количество игроковZREM leaderboard "user:3" # удалитьПолноценный лидерборд в Node.js:
// Добавить результат игрыawait redis.zAdd('leaderboard', { score: 1250, value: `user:${userId}` });
// Топ-10 с очкамиconst top = await redis.zRevRangeWithScores('leaderboard', 0, 9);// [{ value: 'user:9', score: 1250 }, ...]
// Место текущего игрокаconst rank = await redis.zRevRank('leaderboard', `user:${userId}`);
// Топ вокруг игрока (круто для «ты на 127 месте»)const around = await redis.zRevRangeWithScores('leaderboard', rank - 2, rank + 2);
// Диапазон: кто с 1000 до 2000 очкамиconst mid = await redis.zRangeByScoreWithScores('leaderboard', 1000, 2000);Pub/Sub: мгновенно, но без гарантий
Заголовок раздела «Pub/Sub: мгновенно, но без гарантий»Один паблишер — много подписчиков. Доставка мгновенная, но если подписчик был оффлайн — сообщение потеряно. Механика описана на странице Redis Pub/Sub в документации.
// Подписчикconst subscriber = redis.duplicate();await subscriber.connect();await subscriber.subscribe('orders:events', (message) => { const event = JSON.parse(message); console.log('Новый заказ:', event.id);});
// Паблишер (в другом процессе)await redis.publish('orders:events', JSON.stringify({ id: 123, total: 4500 }));Применение: уведомления по WebSocket, инвалидация кэша между инстансами, события для real-time дашбордов. Не применение: надёжная доставка, очереди задач.
Streams: очереди с гарантиями
Заголовок раздела «Streams: очереди с гарантиями»Redis Streams (5.0+) — append-only log с consumer groups. Сообщения сохраняются, есть подтверждение обработки, перечитывание недоставленных. Подробный разбор команд — на странице Redis Streams в документации.
# Продьюсер: XADD stream key * field valueXADD orders:stream * order_id 123 total 4500XADD orders:stream * order_id 124 total 3200
# Консьюмер в группе: XREADGROUPXGROUP CREATE orders:stream processors 0 # создать группу с началаXREADGROUP GROUP processors worker-1 COUNT 10 STREAMS orders:stream ># > — новые сообщения, ещё не выданные группе
# Подтверждение обработкиXACK orders:stream processors 1623456789012-0
# Недоставленные (worker упал, не подтвердил)XPENDING orders:stream processors - + 10Ключевые отличия от Pub/Sub: сообщения хранятся, consumer groups распределяют нагрузку, XACK подтверждает обработку, XPENDING показывает зависшие. Это настоящая очередь.
// Node.js: продьюсерawait redis.xAdd('orders:stream', '*', { orderId: '123', total: '4500' });
// Консьюмерconst messages = await redis.xReadGroup( 'processors', 'worker-1', { key: 'orders:stream', id: '>' }, // новые сообщения { COUNT: 10, BLOCK: 5000 } // ждать до 5 секунд);
for (const msg of messages) { try { await processOrder(msg.message); await redis.xAck('orders:stream', 'processors', msg.id); } catch (e) { // Не ack — вернётся в pending, перечитаем позже }}RedisJSON и RediSearch кратко
Заголовок раздела «RedisJSON и RediSearch кратко»Модули Redis Stack:
- RedisJSON: нативный JSON с путями
$.a.b[0].Окно терминала JSON.SET user:1 $ '{"name":"Alice","address":{"city":"Moscow"}}'JSON.GET user:1 $.address.city # "\"Moscow\""JSON.NUMINCRBY user:1 $.login_count 1 - RediSearch: индексы и полнотекстовый поиск по JSON и хэшам.
Окно терминала FT.CREATE userIdx ON JSON PREFIX 1 user: SCHEMA $.name AS name TEXTFT.SEARCH userIdx "@name:Alice"
Полезно для прототипов и специфических случаев, но не заменяет Elasticsearch для серьёзного поиска или PostgreSQL jsonb для сложных запросов с джойнами и агрегациями. Правило простое: если поиск — ядро продукта, бери специализированный движок; если это удобная фича поверх ключа — модулей Redis хватит.
Персистентность: RDB против AOF
Заголовок раздела «Персистентность: RDB против AOF»По умолчанию Redis держит всё в памяти и теряет при рестарте. Два механизма персистентности, подробно разобранных в главе документации про persistence:
RDB (snapshot): форк, сброс дампа на диск. Компактно, быстро на чтение, но потеряешь всё с последнего снапшота.
save 900 1 # снапшот каждые 15 минут, если ≥1 изменениеsave 300 10 # каждые 5 минут, если ≥10 измененийdbfilename dump.rdbAOF (append-only file): журнал каждой записи. Максимум потеряешь 1 секунду (с everysec). Больше файлы, медленнее рестарт.
appendonly yesappendfsync everysec # fsync каждую секунду — баланс скорости/надёжностиappendfsync always # fsync на каждую команду — максимум надёжности, медленноappendfsync no # ОС решает — быстро, рискованноРекомендация: AOF с everysec для сессий, лидербордов, очередей. RDB для кэша (или вообще без персистентности). Оба включены — максимум надёжности.
Eviction-политики: что удалять при нехватке памяти
Заголовок раздела «Eviction-политики: что удалять при нехватке памяти»Когда память заполнена, Redis удаляет ключи по политике. Все восемь политик и их семантика описаны на странице про eviction в документации:
| Политика | Что удаляет | Когда использовать |
|---|---|---|
noeviction |
Ничего, ошибка на запись | По умолчанию. Для важных данных. |
allkeys-lru |
Наименее используемые (LRU) | Чистый кэш. |
allkeys-lfu |
Наименее часто используемые (LFU) | Кэш с горячими ключами. |
volatile-lru |
LRU среди ключей с TTL | Кэш + важные ключи без TTL. |
volatile-ttl |
С наименьшим TTL | Приоритет свежим данным. |
allkeys-random |
Случайные | Редко, для тестов. |
maxmemory 1gbmaxmemory-policy allkeys-lfu # лучше LRU для большинства кэшейLFU (Least Frequently Used) учитывает частоту обращений, а не только давность. Для кэша с «горячими» ключами — лучше LRU.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Pub/Sub для надёжных событий. Подписчик оффлайн — событие потеряно. Для важного — Streams.
- Большие ключи.
KEYS *на проде — блокировка.SMEMBERSна сет с миллионом элементов — та же беда. ИспользуйSCAN,SSCAN, ограничивай размеры. - Списки как надёжные очереди.
LPOP/BRPOPбез ack — потеря задач при падении consumer. Используй Streams или ack-механизм. - Игнорирование eviction-политики. По умолчанию
noeviction— Redis упадёт по памяти. Для кэша поставьallkeys-lfu. - Хранение больших объектов в хэшах без срока. Хэш растёт бесконечно, память течёт. TTL на весь ключ, не на поля.
- Один Redis на всё. Кэш, сессии, очереди в одном инстансе с разными требованиями к персистентности. Разделяй или используй разные logical databases (0-15).
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Разница Pub/Sub и Streams? Pub/Sub — мгновенно, без хранения, потеря при оффлайне. Streams — хранит, consumer groups, ack, повторная доставка.
- Как устроен лидерборд на Sorted Sets?
ZADDдля очков,ZREVRANGEдля топа,ZREVRANKдля места,ZRANGEBYSCOREдля диапазонов. O(log N) на операцию. - RDB против AOF? RDB — снапшоты, компактно, потеря данных. AOF — журнал, больше, до 1 секунды потери. Для важного — AOF everysec.
- Что делать, если consumer Streams упал? Не ack — сообщение остаётся в pending.
XPENDINGпоказывает,XCLAIMпереназначает другому consumer. - Когда использовать хэш, а не JSON-строку? Когда нужно обновлять отдельные поля без чтения всего объекта. Хэш — O(1) на поле, JSON — перезапись целиком.
- Eviction-политики: чем отличаются LRU и LFU? LRU — по давности последнего использования. LFU — по частоте. LFU лучше для «горячих» ключей, которые используются часто.
Практика
Заголовок раздела «Практика»- Реализуй лидерборд на Sorted Sets: добавление очков, топ-10, место игрока, топ-5 вокруг игрока. Симулируй 1000 игроков, замери latency.
- Собери очередь на Streams: продьюсер пушит задачи, три воркера в consumer group обрабатывают. Убей одного воркера посреди обработки, проверь
XPENDINGи переназначь черезXCLAIM. - Реализуй rate limiting sliding window на Sorted Set без Lua. Сравни с Lua-версией: гонки, производительность.
- Подними Redis с AOF
everysecиmaxmemory-policy allkeys-lfu. Заполни память, наблюдай eviction черезINFO stats(evicted_keys). - Построй «кто онлайн» на сетах:
SADD online user:1,SINTERс друзьями. Добавь TTL через отдельный ключ-сердцебиение.