voidlearn
 

7.3Структуры данных Redis и сценарии

Выбирай тип Redis под операцию и гарантию: строка для атомарного счётчика, hash для полей сущности, sorted set для ранжирования или временных оценок, Stream для подтверждаемой ленты. Сопоставляй сложность операции, объём состояния и восстановление после сбоя.

Карта типов

ТипЧто этоКлючевые командыСложностьТиповой сценарий
stringбайты до 512 МБ; число, если похоже на числоGET, SET, SET … NX, INCRBY, MGET, GETEX, GETDELO(1)кэш сериализованного объекта, счётчики, rate limit, лок
hashсловарь поле → значение внутри одного ключаHSET, HGET, HMGET, HINCRBY, HDEL, HSCAN, HEXPIREO(1) на полесессии, объекты, где нужно менять одно поле
listдвусвязный список (quicklist из listpack-узлов)LPUSH, RPOP, BRPOP, LMOVE, LRANGE, LTRIM, LLENO(1) по краям, O(N) в серединуочередь задач, стек, «последние N событий»
setмножество уникальных строк без порядкаSADD, SISMEMBER, SREM, SPOP, SINTER, SINTERCARD, SSCANO(1) добавить/проверитьтеги, множества прав, «общие друзья», дедупликация
sorted setмножество + вещественный score, всегда отсортированоZADD, ZSCORE, ZRANK, ZRANGE (с BYSCORE, REV), ZREMRANGEBYSCORE, ZPOPMINO(log N)лидерборд, отложенные задачи, sliding-window лимиты, индекс по времени
bitmapта же строка, адресуемая по битамSETBIT, GETBIT, BITCOUNT, BITPOS, BITOP, BITFIELDO(1) бит, O(N) счётактивность пользователей по дням, компактные флаги
HyperLogLogвероятностный счётчик уникальных, до 12 КБ на любой объёмPFADD, PFCOUNT, PFMERGEO(1)уникальные посетители, уникальные IP, охват
streamappend-only лог с ID и consumer groupXADD, XREAD, XREADGROUP, XACK, XAUTOCLAIM, XTRIMO(1) добавить, O(log N) поисклента событий, очередь с подтверждениями и переигровкой
geoнадстройка над zset: score = geohashGEOADD, GEOSEARCH, GEODISTO(log N)«ближайшие точки к координате»
Внутренние кодировки: маленькие значения лежат компактно и переходят в «настоящую» структуру по порогу Смотреть через OBJECT ENCODING key. Пороги настраиваются в redis.conf. string int 64-битное целое значение не число embstr в одном куске с ключом ключ + значение > 41 Б raw отдельный SDS-буфер list listpack мало и коротких больше 8 КБ quicklist связка listpack-узлов hash listpack плоский массив байт больше 512 полей или поле длиннее 64 Б hashtable dict, доступ за O(1) set intset только целые, сорт. появилась строка listpack до 128 элементов больше 128 hashtable dict без значений zset listpack пары member+score больше 128 пар или элемент > 64 Б skiplist + dict ранг за O(log N) Хеш назад не переключается, даже если удалить почти всё: только пересоздание ключа. Список с Redis 7.2 умеет назад. bitmap и HyperLogLog - это обычные строки с особой интерпретацией байт; stream хранится в radix-дереве (rax).
Компактные кодировки. На них Redis и экономит память на мелких объектах: хеш из десяти полей в listpack занимает в разы меньше полноценной хеш-таблицы.

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 в stringhash
Частичное чтениенет, тянем всёHMGET
Частичное обновлениеread-modify-write: гонка, нужен WATCH или LuaHSET/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 теряет задачи

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 - число элементов, которые перепрыгивает ссылка. По нему ранг считается без прохода по списку.

sorted set внутри: словарь для ZSCORE и skiplist для диапазонов и рангов dict: member -> score "dan" -> 90 "bob" -> 180 "alice" -> 320 "carol" -> 450 ZSCORE, ZINCRBY, ZADD находят элемент за O(1) уровень 3 уровень 2 уровень 1 head head head dan 90 bob 180 bob 180 alice 320 alice 320 alice 320 carol 450 ZRANGE 300 500 BYSCORE: идём по верхнему уровню, пока следующий score меньше 300, спускаемся на уровень ниже и продолжаем. Прыжки логарифмические - узел 90 не читается вообще. Высота башни у нового элемента выбирается случайно (с вероятностью 1/4 на следующий уровень, максимум 32) - поэтому skiplist не требует ребалансировки, в отличие от дерева, и хорошо ложится на однопоточную модель. Каждая ссылка хранит span - число перепрыгнутых элементов. Благодаря span ZRANK и ZRANGE по индексу тоже O(log N), а не O(N): ранг считается суммированием span по пути спуска.
Skiplist. Вероятностная структура вместо сбалансированного дерева: код проще, к кэшу процессора она дружелюбнее, а диапазонные запросы даёт «из коробки». Лидерборду нужно ровно это.
# Лидерборд
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.

Stream: append-only лог, группа потребителей и PEL 1712-0 order.created 1712-1 order.paid 1713-0 order.shipped 1713-1 user.updated 1714-0 ещё не выдано 1715-0 ещё не выдано XADD добавляет справа last-delivered-id группы workers XTRIM MAXLEN ~ 100000 обрезает слева PEL группы workers - выдано, но не подтверждено ID записи потребитель простаивает выдач 1712-1 worker-1 12 с 1 нормально, обрабатывается 1713-0 worker-2 340 с 1 воркер умер: XAUTOCLAIM отдаст запись другому 1713-1 worker-1 3 с 4 четвёртая попытка: пора в dead letter XREADGROUP выдаёт запись и заводит строку в PEL. XACK удаляет её. Всё, что осталось в PEL, будет переиграно - отсюда гарантия at-least-once, а значит, обработчик обязан быть идемпотентным.
Consumer group. Очередью с подтверждениями лог делает 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, минимум накладных
Счётчик, лимит, генератор idstring + INCRатомарно, O(1)
Объект, у которого меняют поляhashчастичное чтение и атомарный HINCRBY
Сессияhash + EXPIREполя разной природы, скользящий TTL
Простая очередь задачlist + BRPOPO(1), пять минут работы
Очередь с подтверждениями и переигровкойstream + consumer groupPEL, XACK, XAUTOCLAIM
Последние N событийlist + LTRIMпостоянный размер без чистки
Уникальность, теги, «есть ли в множестве»setSISMEMBER за O(1)
Рейтинг, топ-N, «моё место»sorted setранг и диапазон за O(log N)
Отложенные задачи, планировщикsorted set, score = времяZRANGE … BYSCORE + ZREM в Lua
Точный подсчёт уникальных при плотных числовых idbitmap1 бит на пользователя
Приблизительный подсчёт уникальных при любых idHyperLogLogдо 12 КБ на любой объём
Гео-поиск «рядом со мной»geo (поверх zset)GEOSEARCH по радиусу
Глубже, чем спросят: TTL живёт на ключе, а не на элементе

Частая ошибка проектирования: «положим лайки в 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
Суть: пять основных плюс bitmap, HyperLogLog, stream и geo; в Redis 8 встроены ещё JSON, time series, вероятностные структуры и vector set, с 8.8 - array. Выбор делается по тому, какая операция должна быть дешёвой: доступ к полю, порядок, уникальность, подтверждения.
  • 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 - когда читают или меняют отдельные поля; string с сериализованным объектом - когда объект всегда нужен целиком и внутри есть вложенность.

Аргументы за 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-ю ошибку.

Redis 7.4: TTL на поля хеша

Раньше главным минусом hash был TTL только на весь ключ. В 7.4 появились HEXPIRE, HPEXPIRE, HTTL, HPERSIST со сроком жизни для отдельных полей.

Суть: list + 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.

Где проходит граница с Kafka и RabbitMQ

Redis Streams хороши, пока очередь помещается в память и потеря последних сотен миллисекунд при failover допустима (репликация асинхронная - см. 7.6). Когда нужны длительное хранение, гарантии на диске, транзакции, сложная маршрутизация или трафик, не влезающий в RAM, берут Kafka или RabbitMQ. Хорошая формулировка: «Streams - это очередь на том, что уже стоит в инфраструктуре; брокер нужен, когда очередь становится частью контракта между сервисами».

Суть: zset - это словарь 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 - точно, но нужны плотные числовые id и память ~ max(id)/8. HyperLogLog - приблизительно (ошибка 0,81 %), до 12 КБ на любой объём, принимает любые строки.

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.

Суть: append-only лог с монотонными ID <мс>-<порядковый>. 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 так, чтобы данные не удалялись до допустимого срока восстановления.