7.3Структуры данных Redis и сценарии
Выбирай тип Redis под операцию и гарантию: строка для атомарного счётчика, hash для полей сущности, sorted set для ранжирования или временных оценок, Stream для подтверждаемой ленты. Сопоставляй сложность операции, объём состояния и восстановление после сбоя.
Карта типов
| Тип | Что это | Ключевые команды | Сложность | Типовой сценарий |
|---|---|---|---|---|
| string | байты до 512 МБ; число, если похоже на число | GET, SET, SET … NX, INCRBY, MGET, GETEX, GETDEL | O(1) | кэш сериализованного объекта, счётчики, rate limit, лок |
| hash | словарь поле → значение внутри одного ключа | HSET, HGET, HMGET, HINCRBY, HDEL, HSCAN, HEXPIRE | O(1) на поле | сессии, объекты, где нужно менять одно поле |
| list | двусвязный список (quicklist из listpack-узлов) | LPUSH, RPOP, BRPOP, LMOVE, LRANGE, LTRIM, LLEN | O(1) по краям, O(N) в середину | очередь задач, стек, «последние N событий» |
| set | множество уникальных строк без порядка | SADD, SISMEMBER, SREM, SPOP, SINTER, SINTERCARD, SSCAN | O(1) добавить/проверить | теги, множества прав, «общие друзья», дедупликация |
| sorted set | множество + вещественный score, всегда отсортировано | ZADD, ZSCORE, ZRANK, ZRANGE (с BYSCORE, REV), ZREMRANGEBYSCORE, ZPOPMIN | O(log N) | лидерборд, отложенные задачи, sliding-window лимиты, индекс по времени |
| bitmap | та же строка, адресуемая по битам | SETBIT, GETBIT, BITCOUNT, BITPOS, BITOP, BITFIELD | O(1) бит, O(N) счёт | активность пользователей по дням, компактные флаги |
| HyperLogLog | вероятностный счётчик уникальных, до 12 КБ на любой объём | PFADD, PFCOUNT, PFMERGE | O(1) | уникальные посетители, уникальные IP, охват |
| stream | append-only лог с ID и consumer group | XADD, XREAD, XREADGROUP, XACK, XAUTOCLAIM, XTRIM | O(1) добавить, O(log N) поиск | лента событий, очередь с подтверждениями и переигровкой |
| geo | надстройка над zset: score = geohash | GEOADD, GEOSEARCH, GEODIST | O(log N) | «ближайшие точки к координате» |
string: счётчики и rate limit
INCR атомарен и возвращает новое значение, так что распределённый счётчик получается
без единой строчки синхронизации. На нём считают просмотры, держат лимиты, генерируют id.
// Счётчик просмотров: одна команда, атомарно, без гонок.
views, err := rdb.Incr(ctx, "post:42:views").Result()
// Пакетный сброс в БД раз в минуту (write-behind, см. 7.4):
// забираем значение и обнуляем одной атомарной операцией
n, _ := rdb.GetDel(ctx, "post:42:views").Int64() // до Redis 6.2: GETSET key 0
Rate limit: фиксированное окно
Проще всего устроен лимит «не больше N запросов в минуту»: минута в имени ключа, счётчик растёт, а TTL с запасом ставится вместе с каждым инкрементом.
// Ключ вида rl:user:42:29795911: номер минуты от эпохи, TTL чуть больше окна.
key := fmt.Sprintf("rl:%s:%d", userID, time.Now().Unix()/60)
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 70*time.Second) // TTL с запасом, чтобы ключ точно умер
if _, err := pipe.Exec(ctx); err != nil {
return false, err // Redis лёг: пропускать или резать, решай сам
}
return incr.Val() <= limit, nil
Фиксированное окно пропускает двойной всплеск на стыке: при лимите 100/мин клиент делает
100 запросов в 14:00:59 и ещё 100 в 14:01:00. Двести запросов за одну секунду, и формально ничего
не нарушено. Если это важно (защита от абьюза, а не грубый учёт), берут скользящее окно на
sorted set или token bucket на Lua. Вторая типовая ошибка: EXPIRE уходит отдельным
запросом без пайплайна. Упадёт процесс между INCR и EXPIRE - ключ
останется без TTL и будет лежать в памяти вечно. А в схеме без минуты в имени
(rl:user:42 плюс EXPIRE 60) клиент ещё и окажется заблокирован навсегда.
Rate limit: скользящее окно на sorted set
// Один Lua-скрипт вместо четырёх round-trip: удалить старое, посчитать, добавить, продлить.
var slidingWindow = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1]) -- текущее время в мс
local window = tonumber(ARGV[2]) -- размер окна в мс
local limit = tonumber(ARGV[3])
local id = ARGV[4] -- уникальный идентификатор запроса
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local used = redis.call('ZCARD', key)
if used >= limit then
return {0, used}
end
redis.call('ZADD', key, now, id)
redis.call('PEXPIRE', key, window)
return {1, used + 1}
`)
res, err := slidingWindow.Run(ctx, rdb,
[]string{"rl:sw:" + userID},
time.Now().UnixMilli(), 60_000, 100, uuid.NewString(),
).Slice()
За точность платят памятью: в zset лежит запись на каждый запрос в окне. При лимите 100/мин это
копейки, при 100 000/мин на каждого пользователя уже нет. Тогда берут token bucket:
в хеше лежат tokens и last_refill, Lua-скрипт доливает токены по
прошедшему времени, и память на пользователя остаётся постоянной.
hash: сессии и объекты
Хеш выигрывает у «JSON в строке», когда нужно читать или менять отдельные поля.
HINCRBY атомарно увеличивает поле, не читая весь объект, а
HMGET достаёт три поля из тридцати и не гоняет по сети остальные.
# объект как JSON в строке
SET user:42 '{"name":"Иван","age":30,"visits":7}'
# поменять visits: GET, распарсить,
# изменить, сериализовать, SET
# - два round-trip и гонка между ними
# объект как hash
HSET user:42 name Иван age 30 visits 7
HINCRBY user:42 visits 1 # атомарно, O(1)
HMGET user:42 name age # только нужные поля
HGET user:42 name # без лишнего трафика
| JSON в string | hash | |
|---|---|---|
| Частичное чтение | нет, тянем всё | HMGET |
| Частичное обновление | read-modify-write: гонка, нужен WATCH или Lua | HSET/HINCRBY, атомарно |
| Вложенные структуры | любые | только плоские, значения - строки |
| Память на маленьком объекте | хуже: JSON многословен | лучше: listpack без имён типов |
| Память на большом объекте | лучше, если жать (msgpack, gzip) | хуже после перехода в hashtable |
| TTL | на весь ключ | на весь ключ; с Redis 7.4 - и на отдельные поля (HEXPIRE) |
| Версионирование схемы | надо думать при десериализации | новое поле просто добавляется |
Сессию обычно кладут в hash: HSET sess:<token> uid 42 ip ... ua ... csrf ... плюс
EXPIRE sess:<token> 1800. Скользящий срок продлевают через EXPIRE при
каждом обращении; сессию-строку читает и продлевает одна GETEX. Обратный индекс
SADD user:42:sessions <token> держи обязательно, иначе «разлогинить со всех устройств»
превращается в KEYS. И не клади в сессию то, без чего нельзя работать: Redis не
источник правды, сессия должна пересоздаваться.
list: очередь задач
Список двусвязный (физически это quicklist из listpack-узлов, а маленький - один listpack), поэтому вставка и удаление по краям стоят O(1), а обращение к середине O(N). Отсюда два честных применения: очередь/стек и «последние N».
# Очередь FIFO: кладём слева, забираем справа
LPUSH queue:jobs '{"id":1,"type":"email"}'
BRPOP queue:jobs 5 # ждём до 5 с, блокируется только клиент
# «Последние 100 событий» постоянного размера без внешней чистки
LPUSH user:42:feed "event"
LTRIM user:42:feed 0 99 # O(N) по числу удалённых, здесь по одному
# Надёжная очередь: атомарно перекладываем в processing
BLMOVE queue:jobs queue:processing RIGHT LEFT 5
# ... обработали ...
LREM queue:processing 1 '{"id":1,...}'
BRPOP даёт at-most-once: элемент уже удалён из списка, а воркер ещё ничего не
сделал. Упал под нагрузкой или получил OOM-kill - задача исчезла бесследно. Для at-least-once
нужен либо BLMOVE в отдельный processing-список с фоновым «сторожем», который
возвращает зависшие элементы обратно, либо Streams с consumer group, где PEL и
XAUTOCLAIM делают это из коробки. На собесе здесь удобная развилка: «на list очередь
делается за пять минут, а если нужны подтверждения и переигровка, берут Streams или нормальный
брокер».
set: уникальность и множества
SADD post:42:likes 7 9 13 # идемпотентно: повтор ничего не меняет
SCARD post:42:likes # O(1): размер не пересчитывается
SISMEMBER post:42:likes 9 # O(1)
SPOP raffle:participants 3 # розыгрыш: 3 случайных, сразу удаляются
SINTER user:1:friends user:2:friends # общие друзья, O(N*M)
SINTERCARD 2 user:1:friends user:2:friends LIMIT 100 # 7.0: только размер, с ограничением
SRANDMEMBER feed:pool 5 # 5 случайных, не удаляя
Пока в множестве только целые и их не больше set-max-intset-entries, оно лежит
отсортированным intset: компактно, поиск бинарный. Так дёшево хранить «кому
показали баннер» или «какие id уже обработаны», если идентификаторы числовые.
sorted set: лидерборд, отложенные задачи, индекс по времени
Sorted set держит две структуры одновременно: словарь member → score для
ZSCORE за O(1) и список с пропусками, упорядоченный по score, для
диапазонных запросов и рангов за O(log N). Каждый узел skiplist хранит ещё и span - число
элементов, которые перепрыгивает ссылка. По нему ранг считается без прохода по списку.
# Лидерборд
ZADD game:lb 1500 "player:7" # O(log N)
ZINCRBY game:lb 25 "player:7" # начислили очки
ZRANGE game:lb 0 9 REV WITHSCORES # топ-10
ZREVRANK game:lb "player:7" # моё место, O(log N)
ZCOUNT game:lb 1000 2000 # сколько игроков в диапазоне
# Отложенные задачи: score = время запуска в миллисекундах
ZADD jobs:delayed 1756209600000 "job:991"
ZRANGE jobs:delayed 0 <now> BYSCORE LIMIT 0 100 # что пора выполнить
# забирать надо атомарно, иначе двое воркеров возьмут одну задачу.
# ZPOPMIN не годится: заберёт и задачи из будущего. Нужен Lua-скрипт:
# ZRANGE … BYSCORE по now, затем ZREM этих же id — одной операцией
До Redis 6.2 для этого были отдельные команды ZREVRANGE и ZRANGEBYSCORE.
Они работают и сейчас и часто попадаются в старом коде, но считаются устаревшими: их заменил
ZRANGE с опциями REV и BYSCORE.
ZREVRANK работает за O(log N), поэтому «моё место среди миллиона» считается
мгновенно. В этом главная ценность zset: в SQL тут нужна оконная функция по всей таблице.
Обсудить стоит две вещи. Одинаковые очки сортируются лексикографически по member, и если
нужен тайбрейк по времени, его кодируют прямо в score (например, score = очки * 10^10 + (10^10 - timestamp)),
помня, что score хранится как double и точных целых в нём только 53 бита. Второе:
лидерборд на миллион записей получается большим ключом и в кластере целиком лежит на одном
шарде; если он ещё и горячий, шард станет узким местом (см. 7.5).
bitmap и HyperLogLog: считать дёшево
# Битовая карта: активность пользователя по дням года
SETBIT user:42:active:2026 238 1 # в день 238 заходил
BITCOUNT user:42:active:2026 # сколько дней всего был активен
BITPOS user:42:active:2026 1 # первый день активности
# DAU: бит на каждого пользователя за день
SETBIT dau:2026-08-26 42 1
BITCOUNT dau:2026-08-26 # уникальные за день, точно
BITOP AND weekly dau:2026-08-20 dau:2026-08-21 # кто был оба дня
# 10 млн пользователей = 10^7 бит = 1,25 МБ на день. Точно и очень дёшево,
# но только если id плотные и числовые.
# HyperLogLog: уникальные без хранения самих значений
PFADD uv:2026-08-26 "ip:1.2.3.4" "ip:5.6.7.8"
PFCOUNT uv:2026-08-26 # до 12 КБ на ключ, ошибка ~0,81 %
PFMERGE uv:week uv:2026-08-20 uv:2026-08-21 uv:2026-08-22
Спрашивают про разницу: bitmap точен, но требует плотных числовых идентификаторов и
памяти пропорционально максимальному id. HyperLogLog приблизителен (стандартная ошибка
0,81 %), зато занимает не больше 12 КБ, будь там миллион уникальных или миллиард, и
принимает любые строки. Сами элементы из HLL не достать, и вычитать HLL нельзя, только
объединять через PFMERGE.
stream: лента событий с подтверждениями
Stream устроен как append-only лог. Каждая запись получает монотонно растущий ID вида
<миллисекунды>-<порядковый>. В отличие от pub/sub, данные сохраняются, и новый
потребитель может прочитать историю. Самое ценное здесь consumer group: группа помнит, до
какого ID дошла, и ведёт PEL.
XACK, она считается в работе, и её можно отобрать у зависшего
потребителя.# Продюсер: добавляем событие, автоматически обрезая старое
XADD events:orders MAXLEN ~ 100000 * type order.paid id 991 amount 1200
# Группу создаём один раз: $ = только новое, 0 = с начала
XGROUP CREATE events:orders workers $ MKSTREAM
# Консьюмер: читаем новые записи (символ > означает «то, что ещё никому не выдавали»)
XREADGROUP GROUP workers worker-1 COUNT 10 BLOCK 5000 STREAMS events:orders >
# Подтверждаем обработку
XACK events:orders workers 1712-1
# Забираем зависшее у мёртвых воркеров: всё, что простаивает дольше 60 с
XAUTOCLAIM events:orders workers worker-3 60000 0 COUNT 10
XPENDING events:orders workers # сводка: сколько и у кого
Как выбирать структуру
| Задача | Структура | Почему |
|---|---|---|
| Кэш готового ответа / объекта целиком | string | один GET, минимум накладных |
| Счётчик, лимит, генератор id | string + INCR | атомарно, O(1) |
| Объект, у которого меняют поля | hash | частичное чтение и атомарный HINCRBY |
| Сессия | hash + EXPIRE | поля разной природы, скользящий TTL |
| Простая очередь задач | list + BRPOP | O(1), пять минут работы |
| Очередь с подтверждениями и переигровкой | stream + consumer group | PEL, XACK, XAUTOCLAIM |
| Последние N событий | list + LTRIM | постоянный размер без чистки |
| Уникальность, теги, «есть ли в множестве» | set | SISMEMBER за O(1) |
| Рейтинг, топ-N, «моё место» | sorted set | ранг и диапазон за O(log N) |
| Отложенные задачи, планировщик | sorted set, score = время | ZRANGE … BYSCORE + ZREM в Lua |
| Точный подсчёт уникальных при плотных числовых id | bitmap | 1 бит на пользователя |
| Приблизительный подсчёт уникальных при любых id | HyperLogLog | до 12 КБ на любой объём |
| Гео-поиск «рядом со мной» | geo (поверх zset) | GEOSEARCH по радиусу |
Частая ошибка проектирования: «положим лайки в set и поставим TTL на каждый лайк». Так не выйдет -
EXPIRE работает только на весь ключ. У элементов коллекции своего времени жизни нет,
и эмулировать его можно только через zset со score = временем и периодический
ZREMRANGEBYSCORE. Исключение одно, и появилось оно в Redis 7.4:
HEXPIRE/HPEXPIRE ставят, а HPERSIST снимает TTL у полей хеша.
Этого годами не хватало для сессий и для кэша с раздельным сроком жизни
полей. Второй нюанс: SET key val без KEEPTTL сбрасывает TTL, а
HSET, LPUSH, SADD, INCR его не трогают. Отсюда классика:
ключ с TTL внезапно стал вечным после безобидного SET.
Вопросы
7- string хранит байты, до 512 МБ. Если значение похоже на число, атомарно работают
INCR/INCRBY. Сценарии: кэш сериализованного объекта, счётчики, rate limit, распределённый лок черезSET NX PX. - hash держит словарь внутри ключа, поля читаются и меняются по отдельности:
HMGET,HINCRBY. На нём делают сессии, объекты с изменяемыми полями, агрегаты (счётчики по разрезам в одном ключе). - list, двусвязный список, с краёв работает за O(1). Отсюда очередь
(
LPUSH/BRPOP), стек, «последние N» черезLPUSH+LTRIM. - set хранит уникальные строки без порядка,
SISMEMBERза O(1). Сценарии: теги, дедупликация, «кому уже показали», операции над множествами (общие друзья черезSINTER). - sorted set, множество с вещественным score, всегда отсортирован.
ZADD/ZRANK/ZRANGE … BYSCOREза O(log N). Берут для лидерборда, отложенных задач (score = время), скользящего окна rate limit, индекса по времени. - bitmap адресует ту же строку по битам: активность по дням, DAU. На 10 млн пользователей уходит 1,25 МБ в день.
- HyperLogLog - приблизительный счётчик уникальных, до 12 КБ на любой объём, ошибка 0,81 %.
- stream даёт append-only лог с consumer group, PEL и
XACK, то есть очередь с подтверждениями и переигровкой.
Кроме типов, назови внутренние кодировки. Маленький хеш лежит в listpack,
плоском куске памяти с линейным поиском, и при превышении 512 полей превращается в
хеш-таблицу. Sorted set состоит из словаря и skiplist, поэтому ZSCORE стоит O(1), а
ZRANK O(log N). И упомяни, что назад кодировка не откатывается (кроме списков с Redis 7.2).
INCR, он атомарен. Лимит - либо
фиксированное окно (INCR + EXPIRE в одном пайплайне), либо скользящее
окно на sorted set, либо token bucket в Lua.Фиксированное окно
INCR rl:user42:202608261431
EXPIRE rl:user42:202608261431 70
Просто и дёшево: один ключ на пользователя на окно, память постоянная. Ловушек две.
Первая: двойной всплеск на границе - 100 запросов в последнюю секунду минуты и ещё 100
в первую секунду следующей формально укладываются в лимит 100/мин. Вторая:
EXPIRE отдельным запросом. Если процесс умрёт между командами, ключ останется
навсегда и будет занимать память, а в схеме без окна в имени пользователь ещё и окажется
заблокирован. Поэтому обе команды шлют одним пайплайном или
Lua-скриптом, а с Redis 8.8 хватает одной INCREX key BYINT 1 UBOUND 100 EX 60 ENX:
сверх лимита она не прибавляет, а срок ставит только ключу без TTL.
Скользящее окно на zset
В zset кладут по записи на каждый запрос со score = временем. При проверке
ZREMRANGEBYSCORE убирает всё старше окна, ZCARD считает оставшееся,
и если до лимита не дошли, следуют ZADD и PEXPIRE. Всё это одним
Lua-скриптом, иначе между командами вклинятся конкуренты. Точно, но память растёт линейно от лимита.
Token bucket
В хеше лежат tokens и last_refill; скрипт доливает токены по
прошедшему времени и списывает один. Память постоянная, всплески в пределах ёмкости ведра
проходят, и обычно нужно именно это. Готовый вариант есть в модуле
redis-cell: команда CL.THROTTLE (алгоритм GCRA).
«А что делать, если Redis недоступен: пускать или резать?» Правильно ответить, что решение
продуктовое и принять его надо заранее: fail-open (пускаем, лимит тут вежливость, а не
безопасность) или fail-closed (режем, лимит защищает от абьюза). Плюс запасной
локальный лимитер: golang.org/x/time/rate в каждом поде с лимитом
N/число подов.
Аргументы за hash
- Частичное чтение.
HMGET user:42 name avatarотдаёт два поля из тридцати, без лишнего трафика и без разбора всего JSON. - Атомарное частичное обновление.
HINCRBY user:42 visits 1обходится одной командой. С JSON пришлось бы делать read-modify-write, а это гонка: два процесса прочитают одно и то же, и один перезапишет чужое изменение. - Память на маленьких объектах: listpack без повторяющихся кавычек и имён типов.
- Эволюция схемы: новое поле просто добавляется, старые клиенты его не видят.
Аргументы за string
- Вложенные структуры и массивы: в hash значения только строки, вложенность придётся кодировать вручную.
- Один
GETвместоHGETALL, и можно сжать (msgpack, gzip, protobuf): на больших объектах выходит сильно меньше памяти и трафика. - Версионировать проще целиком: положил новую версию, и всё.
Сессия - конкретно
HSET sess:abc123 uid 42 ip 1.2.3.4 csrf xyz
EXPIRE sess:abc123 1800 # скользящий TTL, продлевать при обращении
SADD user:42:sessions abc123 # обратный индекс для «выйти везде»
Обратный индекс обязателен: без него «разлогинить со всех устройств» превращается в
KEYS sess:*. И принципиально: Redis не источник правды для сессии. Если он
потеряет данные при failover, пользователь должен просто перелогиниться, а не увидеть
500-ю ошибку.
Раньше главным минусом hash был TTL только на весь ключ. В 7.4 появились
HEXPIRE, HPEXPIRE, HTTL, HPERSIST со сроком
жизни для отдельных полей.
BRPOP - это at-most-once, задача теряется при
падении воркера. Для at-least-once нужен либо BLMOVE в processing-список со
сторожем, либо Streams с consumer group, PEL и XACK.Наивный вариант
LPUSH queue:jobs "{...}"
BRPOP queue:jobs 5
Работает, дёшево, O(1). Но BRPOP удаляет элемент сразу: если воркер упал между
извлечением и концом работы, задача исчезла - и никто об этом не узнает. Нет и
ретраев, приоритетов, истории, нескольких независимых групп потребителей.
Reliable queue на списках
BLMOVE queue:jobs queue:processing RIGHT LEFT 5 # атомарно переложили
# ... обработали ...
LREM queue:processing 1 "{...}" # сняли из обработки
Плюс фоновый сторож, который возвращает в основную очередь всё, что провисело в
processing дольше N минут. Время взятия задачи при этом надо где-то хранить,
поэтому вместо processing-списка обычно берут zset со score = дедлайном.
Streams
Здесь всё это уже встроено. XREADGROUP выдаёт запись и заводит строку в
PEL, XACK её убирает, XPENDING показывает зависшее,
XAUTOCLAIM отдаёт живому воркеру записи, простаивающие дольше порога, а по счётчику
выдач «ядовитое» сообщение перекладывают в dead letter уже сами. Один поток читают несколько независимых групп,
и есть история: переиграть можно с любого ID.
Redis Streams хороши, пока очередь помещается в память и потеря последних сотен миллисекунд при failover допустима (репликация асинхронная - см. 7.6). Когда нужны длительное хранение, гарантии на диске, транзакции, сложная маршрутизация или трафик, не влезающий в RAM, берут Kafka или RabbitMQ. Хорошая формулировка: «Streams - это очередь на том, что уже стоит в инфраструктуре; брокер нужен, когда очередь становится частью контракта между сервисами».
member → score для
ZSCORE за O(1) плюс skiplist по score для рангов и диапазонов за O(log N). Топ-N —
ZRANGE … REV (до Redis 6.2 — ZREVRANGE), «моё место» — ZREVRANK.ZINCRBY game:lb 25 player:7 # начислили очки, O(log N)
ZRANGE game:lb 0 9 REV WITHSCORES # топ-10
ZREVRANK game:lb player:7 # место игрока, O(log N)
ZCOUNT game:lb 1000 2000 # сколько в диапазоне очков
Почему skiplist, а не дерево
Skiplist вероятностный: «высоту башни» каждому элементу выбирает случай (следующий уровень с вероятностью 1/4, максимум 32). Ребалансировка не нужна вообще, код проще красно-чёрного дерева, а нижний уровень обходится как обычный связный список, что идеально для диапазонных запросов. Каждая ссылка хранит span - число элементов, которые она перепрыгивает; суммируя span на пути спуска, Redis считает ранг за O(log N), а не за O(N).
Что обсудить дальше
- Тайбрейк. При равных очках порядок лексикографический по member. Если нужен «кто
раньше набрал, тот выше», время кодируют в score. Только score хранится как
double, и целые в нём точны лишь до 2^53. - Размер. Лидерборд на миллион игроков становится большим ключом и в кластере целиком живёт на одном шарде. Если он ещё и горячий, шард станет узким местом. Помогает локальный кэш топ-100 на 1–2 секунды: топ читают все, а меняется он редко.
- Сегментация. Лидерборды обычно заводят по периодам:
lb:2026-08,lb:2026-w34. Старые просто протухают по TTL, и чистить один вечный ключ не нужно. - Ранг вокруг меня. «Показать 5 выше и 5 ниже»:
ZREVRANK, затемZRANGE max(0, rank-5) rank+5 REV(отрицательный индекс Redis считает с конца), два round-trip или один Lua.
Bitmap
SETBIT dau:2026-08-26 42 1
BITCOUNT dau:2026-08-26 # точное число уникальных
BITOP AND both dau:2026-08-25 dau:2026-08-26 # кто был оба дня
BITCOUNT both
10 млн пользователей дают 10^7 бит, то есть 1,25 МБ на день. Дёшево, точно, а главное - работают пересечение и объединение: retention, «был вчера и сегодня», когорты. Ограничение жёсткое: идентификатор должен быть числом, желательно плотным. Если id разреженные, память улетает: строка занимает байты до самого старшего бита, разреженной она не бывает. А snowflake-ID из шардированной БД не влезут вовсе: номер бита не может превышать 2^32 − 1, потому что строка по умолчанию ограничена 512 МБ.
HyperLogLog
PFADD uv:2026-08-26 user:42 ip:1.2.3.4 fp:abcdef
PFCOUNT uv:2026-08-26
PFMERGE uv:week uv:2026-08-20 uv:2026-08-21 # объединение без потери точности
HLL смотрит на позицию первого единичного бита в хеше значения: чем реже встречается длинная серия нулей, тем меньше мощность множества. Регистров 16384 по 6 бит, отсюда потолок ~12 КБ (маленький HLL хранится разреженно и весит меньше) и стандартная ошибка 0,81 %. Идеально для «сколько уникальных IP за день» на сотнях ключей.
Ограничения HLL, о которых спрашивают
- Нельзя достать элементы обратно и нельзя спросить «был ли конкретный».
- Нельзя вычитать:
PFMERGEесть, «PFDIFF» нет. - Значения приблизительны, и подвох не там, где его обычно ждут. Уже виденный
элемент оценку не меняет никогда (
PFADDвернёт 0 - ни один регистр не тронут), а вот новый часто тоже её не двигает: его регистр уже держит максимум.
Для точности и операций над когортами при числовых id бери bitmap. Если нужен просто масштаб
охвата по произвольным ключам и 1 % погрешности никого не волнует, хватит HyperLogLog. Точный
список даст обычный set, только памяти он съест O(N): миллион строк по
20 байт тянет на десятки мегабайт в одном ключе, а это уже big key.
<мс>-<порядковый>. Consumer group помнит last-delivered-id и ведёт PEL -
список выданных, но неподтверждённых записей. Пока нет XACK, запись можно отобрать
и переиграть.Механика
XADD stream * field valueдобавляет запись,*означает «сгенерируй ID сам». ID монотонно растут, поэтому по ним можно делать диапазонные запросы за O(log N) (внутри radix-дерево).XREADчитает как из лога: каждый читатель сам держит позицию. По сути это «fan-out всем».XREADGROUP GROUP g consumer >читает в составе группы: запись выдаётся одному потребителю группы и попадает в PEL.XACKудаляет запись из PEL, то есть она обработана.XPENDINGпоказывает, что зависло и у кого;XAUTOCLAIM(6.2) переназначает живому потребителю записи, простаивающие дольше порога. По счётчикуdelivery count«ядовитое» сообщение отправляют в dead letter.XTRIM stream MAXLEN ~ 100000илиMINIDобрезают старое. Тильда включает приблизительную обрезку по границе узла, она дешевле точной.
Гарантии
At-least-once: всё неподтверждённое будет выдано снова, поэтому обработчик обязан быть идемпотентным. Exactly-once Redis не даёт. Kafka даёт его только внутри себя, транзакциями «прочитал - обработал - записал»; для внешних эффектов его добиваются идемпотентностью на стороне потребителя. И оговорка: репликация Redis асинхронная, при failover последние записи и подтверждения могут потеряться.
Чем отличается от pub/sub
Pub/sub работает как fire-and-forget: нет истории, нет подтверждений, отключившийся подписчик просто теряет сообщения. Stream хранит данные, помнит позицию группы, подтверждает обработку и умеет переигрывать. На практике pub/sub годится для сигналов «сбрось локальный кэш», а stream для задач, которые нельзя терять.
Что стоит запомнить
- INCR атомарно меняет счётчик одной командой, но последующая запись в БД и очистка требуют отдельной схемы надёжности.
- Streams дают consumer groups, pending entries и ack, тогда как Pub/Sub не хранит сообщение для отключённого подписчика.
- HyperLogLog оценивает число уникальных элементов с малой памятью, но не хранит их список.
Частые ошибки
- Pipeline уменьшает сетевые обмены, но сам по себе не делает пакет атомарным.
- LIST/Stream не становятся надёжной очередью только из-за типа данных; настрой обработку повторов, подтверждение и восстановление pending.
- Rate limit через INCR+EXPIRE отдельными командами может потерять TTL при сбое между командами; используй атомарный сценарий/скрипт.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проверь счётчик с GetDel
Сервис увеличил post:42:views на 12, затем флашер вызвал GETDEL. Какое число получит флашер и что случится с последующим INCR после GETDEL?
views, _ := rdb.Incr(ctx, "post:42:views").Result()
n, _ := rdb.GetDel(ctx, "post:42:views").Int64()
Ответ и разбор
GETDEL атомарно вернёт 12 и удалит ключ. Следующий INCR создаст ключ снова со значением 1. Если флашер упадёт после GETDEL, но до записи 12 в БД, эти просмотры потеряются; атомарность Redis-команды не делает Redis и БД одной транзакцией.
Код-ревью
2. Найди окно без TTL в rate limit
Проверь две команды: приложение делает INCR rl:user:42, затем отдельный EXPIRE на 70 секунд. Если процесс завершится между ними, какой эффект и как его убрать?
n, err := rdb.Incr(ctx, key).Result()
if err == nil && n == 1 {
_ = rdb.Expire(ctx, key, 70*time.Second).Err()
}
Ответ и разбор
Ключ останется без TTL и будет учитываться бесконечно, потому что EXPIRE не успел выполниться. Выполни увеличение и назначение TTL одним Lua-скриптом либо транзакцией Redis с корректным условием для первого инкремента. Скрипт из главы задаёт срок, когда значение становится 1.
Применение
3. Выбери тип для лидерборда
Нужно хранить score пользователей, получать top 20 и ранги одного пользователя. Выбери структуру Redis и приведи нужные операции.
Ответ и разбор
Используй sorted set: ZADD обновляет score, ZREVRANGE/ZRANGE с reverse-порядком выбирает верхние значения, ZREVRANK возвращает ранг участника. Явно реши поведение равных score и порядок по member; обновление одного участника идемпотентно по ключу и member.
Применение
4. Pub/Sub или Stream для аудита
Сервис аудита должен пережить 10 минут отключения consumer и после восстановления обработать накопившиеся записи. Что выбрать и какие два шага нужны consumer group?
Ответ и разбор
Выбирай Stream с consumer group, а не Pub/Sub: Stream сохраняет записи и позволяет продолжить чтение. Consumer читает записи в группу, обрабатывает идемпотентно, затем подтверждает через XACK; после сбоя нужно проверять и забирать зависшие pending entries. Настрой retention так, чтобы данные не удалялись до допустимого срока восстановления.