Redis вглубь: структуры данных и их внутренние кодировки

Redis — не «строковый кэш», а сервер структур данных. Разбираем strings/hashes/lists/sets/zsets/streams и то, что скрыто под ними: внутренние кодировки (listpack, intset, skiplist, quicklist) и правила переключения между компактным и «большим» представлением. Честно: когда какая структура уместна и во что она обходится по памяти и по времени.

Про Redis чаще всего думают как про «кэш ключ-значение на пять минут». Это сильно занижает картину: под капотом Redis — сервер структур данных, где у каждого значения есть не только логический тип (string, hash, list, set, zset, stream), но и внутреннее представление, которое движок выбирает сам в зависимости от размера и содержимого. Одна и та же хеш-таблица может лежать в памяти как компактный listpack или как полноценная hash-таблица — и от этого зависят и расход памяти, и стоимость операций.

Эта статья открывает серию «Redis: глубокое погружение» и задаёт фундамент для остальных: прежде чем говорить про персистентность, репликацию и вытеснение, нужно понимать, из чего вообще состоят данные в Redis. Сквозная нить серии — «Redis как кэш vs Redis как источник истины» — начинается уже здесь: выбор структуры и кодировки определяет, насколько предсказуемо Redis ведёт себя под нагрузкой.

Два зеркальных складских ряда — красный «Redis warehouse» и синий «Valkey warehouse». На полках компактные ящики listpack и intset; один хеш-ящик с хлопком «POP!» раскрывается в большой шкаф hashtable с выдвижными ящичками, над ним табло «512 → 513», рядом zset «128 → 129» и intset «512 → 513». Кладовщики с лупами «OBJECT ENCODING» смотрят на переход с изумлением. Внизу таблички «пороги — дефолты, их можно двигать» и «quicklist (ziplist/listpack nodes)»; в центре стенд «Triggers (defaults)» перечисляет пороги, включая list ~8 KiB per node — байты, а не штуки

В статье

Пять типов и одна общая идея

У Redis/Valkey пять типов данных, с которыми работает практика ежедневно, — и один шестой, который держится особняком.

  • String — байтовая строка произвольной длины: текст, счётчик (INCR/INCRBY), битовое поле, сериализованный блоб. Самый универсальный тип и самый обманчивый: именно из-за него Redis чаще всего описывают как «кэш ключ-значение», хотя это только один из шести типов.
  • Hash — объект с полями, как строка-запись или неглубокий JSON без вложенности: профиль пользователя, агрегат счётчиков.
  • List — двусторонняя очередь с сохранением порядка вставки; поддерживает блокирующие операции (BLPOP и родственные), что делает её удобной как простую очередь задач.
  • Set — неупорядоченное множество уникальных элементов с операциями пересечения/объединения/разности.
  • Sorted set (zset) — множество, где у каждого элемента есть числовой score, и элементы всегда упорядочены по нему: рейтинги, лидерборды, индексы по диапазону.

Шестой тип — Stream — журнал записей с монотонно растущими ID, ближе к логу событий, чем к перечисленным пяти. У него собственная модель потребления (consumer groups, XREADGROUP, XACK) и собственная статья дальше в серии — здесь он появляется только как участник таблицы памяти ниже, без деталей группового чтения.

Общая идея, ради которой стоит читать дальше: у каждого из пяти типов логический тип (то, что вы видите через TYPE key и API) — это контракт с клиентом, а не описание того, как значение физически лежит в памяти. Под одним и тем же TYPE hash может скрываться два совершенно разных представления в памяти, и то, какое из них выбрано, зависит только от размера и формы данных, а не от того, как вы объявляли ключ. Движок переключается между ними сам, без единой команды от клиента — и именно эта развилка определяет, во сколько байт и во сколько операций обходится структура на практике.

Компактное представление vs полное: где проходят живые пороги

У каждого из пяти типов (кроме string) есть два представления в памяти: компактное — плотно упакованный линейный буфер без указателей и хеш-таблиц — и полное, оптимизированное под быстрый произвольный доступ ценой большего оверхеда на элемент.

  • listpack — компактная кодировка-преемница ziplist (сама ziplist в актуальных версиях уже не используется, но термин часто встречается в старой документации и обсуждениях): плоский буфер, элементы лежат подряд, поиск конкретного элемента — линейный проход. Используется как компактное представление hash, list, zset и small set.
  • intset — отсортированный массив целых чисел, специализация для set, все элементы которого — числа.
  • hashtable — полная кодировка hash и set при выходе за компактный порог: настоящая хеш-таблица, O(1) на доступ к произвольному элементу, но с оверхедом на элемент (указатели, метаданные бакетов).
  • skiplist — полная кодировка zset: пропускающий список поверх хеш-таблицы (сама хеш-таблица нужна для O(1)-доступа по member, skip list — для упорядоченных операций по score и диапазону). Почему skip list, а не сбалансированное дерево, — историческое инженерное решение Redis: реализация проще дерева при сравнимой асимптотике (O(log N) на вставку/поиск/удаление) и естественно ложится на операции по диапазону, которые для zset основные.
  • quicklist — полная кодировка list: небольшой список лежит единым плоским listpack, как и остальные типы в компактном представлении. Когда он перестаёт умещаться в отведённый узлу бюджет в 8 КиБ, структура становится quicklist — связанным списком из listpack-узлов, и с этого момента лимит применяется уже не к списку целиком, а к размеру отдельного узла.

Пороги переключения — не документационные ориентиры «на глаз» и не константы, зашитые в движок, а конфигурационные параметры, читаемые живьём через CONFIG GET. Значения ниже — дефолты, идентичные на обоих образах (redis:8.8 и valkey/valkey:8.1):

Параметр Значение
hash-max-listpack-entries 512
hash-max-listpack-value 64
list-max-listpack-size -2 (лимит по байтам на узел quicklist, 8 КиБ)
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512
set-max-listpack-entries 128
set-max-listpack-value 64

Это именно дефолты, а не свойства движка: любой из этих параметров меняется через CONFIG SET (или конфиг-файл), и точки перехода сдвигаются вместе с ним. То есть «hash переключается на 513-м поле» — верно ровно до тех пор, пока hash-max-listpack-entries равен 512; на своём проде первым делом стоит посмотреть, что там на самом деле, а не полагаться на числа из статьи.

Числа в конфиге — это ещё не доказательство, что переключение происходит ровно там; поэтому точки перехода были найдены не по документации, а бинарным поиском по факту — заполняя структуру и проверяя OBJECT ENCODING после каждого шага (при дефолтных значениях выше):

Сценарий Переход Наблюдаемая точка
hash по числу полей listpackhashtable count=513 (порог hash-max-listpack-entries=512, переход на 512+1)
hash по длине значения одного поля listpackhashtable len=65 (порог hash-max-listpack-value=64)
list по числу элементов (короткие eN, ~4–6 байт) listpackquicklist count=1328 (упирается в байтовый лимит -2/8 КиБ узла, не в число элементов)
zset по числу элементов listpackskiplist count=129 (порог zset-max-listpack-entries=128)
zset по длине member listpackskiplist len=65 (порог zset-max-listpack-value=64)
set из целых чисел по числу элементов intsethashtable count=513 (порог set-max-intset-entries=512; переход сразу в hashtable, минуя listpack, т.к. 513 больше set-max-listpack-entries=128)
set: 5 целых + 1 нечисловой элемент intsetlistpack сразу (5 меньше set-max-listpack-entries=128)
set: 138 целых + 1 нечисловой элемент intsethashtable сразу (138 больше set-max-listpack-entries=128)

Точки перехода совпали побитово на redis:8.8 и valkey/valkey:8.1 — построчное сравнение обоих логов прогона не нашло ни одного расхождения. Это ожидаемо: за кодировки структур данных отвечает общий движок, унаследованный форком, и на этом уровне расхождений ожидать не приходится — что прогон и подтвердил.

flowchart LR subgraph Hash["Hash"] H1["listpack
≤512 полей, значение каждого ≤64 байт"] -->|"513-е поле или значение поля >64 байт"| H2["hashtable"] end subgraph ZSet["Sorted set"] Z1["listpack
≤128 элементов, member ≤64 байт"] -->|"129-й элемент или member >64 байт"| Z2["skiplist"] end subgraph Set["Set"] S1["intset
≤512 целых"] -->|"513-е целое"| S2["hashtable"] S1 -->|"нечисловой элемент, N≤128"| S3["listpack"] S3 -->|"N>128 элементов"| S2 end subgraph List["List"] L1["listpack
≤8 КиБ суммарно"] -->|"суммарный размер >8 КиБ"| L2["quicklist
лимит 8 КиБ теперь на каждый узел"] end

flowchart LR
    subgraph Hash["Hash"]
        H1["listpack
≤512 полей, значение каждого ≤64 байт"] -->|"513-е поле или значение поля >64 байт"| H2["hashtable"] end subgraph ZSet["Sorted set"] Z1["listpack
≤128 элементов, member ≤64 байт"] -->|"129-й элемент или member >64 байт"| Z2["skiplist"] end subgraph Set["Set"] S1["intset
≤512 целых"] -->|"513-е целое"| S2["hashtable"] S1 -->|"нечисловой элемент, N≤128"| S3["listpack"] S3 -->|"N>128 элементов"| S2 end subgraph List["List"] L1["listpack
≤8 КиБ суммарно"] -->|"суммарный размер >8 КиБ"| L2["quicklist
лимит 8 КиБ теперь на каждый узел"] end

List стоит отдельного пояснения, потому что его порог — исключение из общего шаблона «число элементов». list-max-listpack-size=-2 — это не количество элементов, а лимит в байтах на узел quicklist (8 КиБ). Прямой бинарный поиск через redis-cli на коротких элементах вида e1eN дал:

N элементов OBJECT ENCODING MEMORY USAGE
1250 listpack 7690
1300 listpack 8040
1326–1327 listpack
1328 quicklist
1330 quicklist 8385
1350 quicklist 8525
2000 quicklist

Переход происходит между 1327 и 1328 короткими элементами — то есть примерно на границе суммарного размера в 8 КиБ, что согласуется с list-max-listpack-size=-2. Практический вывод: для более крупных элементов переход наступит при заметно меньшем числе элементов — порог всегда байтовый, а не количественный, и «сколько элементов влезает в listpack» — вопрос без универсального ответа без знания размера самих элементов. Конкретные пороги для элементов другой формы и длины на этом стенде не измерялись — проверялся только этот один профиль (короткие eN).

Память на структуру: что реально показывает MEMORY USAGE

Пороги показывают, когда движок переключает представление; MEMORY USAGE показывает, во что это выливается в байтах. На одних и тех же N (10 / 100 / 1000 элементов) — вот фактические цифры для обоих образов:

Структура N Redis 8.8 Valkey 8.1
string (значение из N байт) 10 48 48
string 100 152 152
string 1000 1064 1064
hash (N полей) 10 119 128
hash 100 1019 1056
hash 1000 46060 42024
list (N элементов) 10 79 80
list 100 529 544
list 1000 5929 6176
set (N целых) 10 60 64
set 100 240 256
set 1000 37170 33896
zset (N элементов) 10 99 112
zset 100 729 800
zset 1000 78680 69336
stream (N записей) 10 398 4296
stream 100 1298 4296
stream 1000 12113 15860

Для string/hash/list/set/zset числа Redis и Valkey близки, но не идентичны — это ожидаемо: разные билды и аллокаторы дают разную байт-в-байт раскладку памяти, тогда как точки перехода кодировок (раздел выше) совпадают побитово, потому что это вопрос движка структур данных, а не конкретной сборки. Разбирать этот разброс по образам отдельно смысла нет — направление и порядок величины одинаковы.

Со stream — иначе, и здесь стоит задержаться. У Valkey MEMORY USAGE для стрима на N=10 и N=100 дал одинаковое значение — 4296 байт: похоже на предвыделение под radix-tree/consumer-group метаданные, которое не меняется, пока стрим не вырастет за пределы одного блока. У Redis рост более плавный: 398 → 1298 → 12113. При N=1000 оба образа растут (Redis — 12113, Valkey — 15860), но абсолютные числа не совпадают.

Сравнивать два движка по результатам с 1–4 прогонов на каждый обычно нельзя — слишком велик риск принять шум измерения за системное расхождение. Здесь это правило можно обоснованно нарушить, и вот почему. MEMORY USAGE — не сэмплирующее и не зависящее от нагрузки хоста измерение (в отличие, например, от приближённого вытеснения или конкурентных failover-таймингов): это детерминированная функция от состояния структуры данных и конкретной сборки, то есть повторный прогон на той же сборке дал бы то же число. Константа 4296 байт, совпавшая у Valkey и на N=10, и на N=100 записей, — структурная сигнатура (похоже на фиксированное предвыделение под блок radix-tree), а не два случайно совпавших шумных замера. И разрыв здесь — на порядок (398 против 4296 при N=10), а не пара процентов, которые случайный разброс мог бы объяснить.

При этом важно не переносить вывод дальше, чем позволяют данные. Что здесь честно не проверено: поведение при N больше 1000 (если 4296 байт у Valkey — правда про фиксированный блок, он должен рано или поздно кончиться — не измерялось); сама причина расхождения инструментально («похоже на предвыделение под radix-tree узел» — правдоподобная гипотеза по форме чисел, не подтверждённая чтением исходников или профилировщиком); и поведение под конкурентной записью или изменением ключей — все сценарии здесь однопоточные, ключи пишутся один раз и не меняются, это снимок в состоянии покоя, а не под нагрузкой.

OBJECT ENCODING как инструмент диагностики

Всё, что описано выше, — не то, что нужно держать в голове наизусть при написании кода. Это то, что нужно уметь проверить одной командой, когда поведение структуры в проде выглядит неожиданно (внезапный скачок памяти, просевшая латентность операции). OBJECT ENCODING key возвращает текущую фактическую кодировку значения — то представление, в которое движок реально перевёл ключ, а не то, которое подразумевал разработчик, когда его создавал.

# список на 1300 коротких элементов e1..e1300 — ещё укладывается в 8 КиБ узла
$ redis-cli OBJECT ENCODING list1300
listpack
$ redis-cli MEMORY USAGE list1300
8040

# список на 1330 элементов — уже за байтовым лимитом узла
$ redis-cli OBJECT ENCODING list1330
quicklist
$ redis-cli MEMORY USAGE list1330
8385

Связка OBJECT ENCODING + MEMORY USAGE — рабочий инструмент, а не любопытство: если ключ неожиданно перескочил в hashtable, skiplist или quicklist, это почти всегда значит, что данные выросли за один из порогов из таблицы выше (число полей/элементов или длина значения), и дальнейший рост памяти и латентности отдельных операций — не аномалия, а следствие смены представления. DEBUG OBJECT key даёт больше служебных деталей (в том числе внутренние счётчики конкретной кодировки), но OBJECT ENCODING/MEMORY USAGE — то, чем стоит пользоваться в первую очередь, потому что они дёшевы и однозначны.

Что дальше

Кодировки — это не только про память. Полное представление (hashtable, skiplist, quicklist) не просто занимает больше байт на элемент — операции над ним стоят иначе по времени, и это выполняется в том же однопоточном событийном цикле, который обрабатывает вообще все команды сервера. Как устроен этот цикл и почему «дорогая» команда на большой структуре — не абстрактная угроза, а конкретный блокирующий эффект для всех остальных клиентов, — тема следующей статьи серии, «Redis: однопоточность и событийный цикл».

Кодировки также определяют, как ключ ведёт себя под давлением памяти: полная кодировка тяжелее компактной не только в состоянии покоя, но и как кандидат на вытеснение, когда maxmemory начинает поджимать. Это разбирается в статье «Redis: память и вытеснение» дальше в серии.

Три темы, которые здесь намеренно не раскрывались, потому что у них есть отдельные, более подходящие места: клиентские библиотеки и то, как они отражают эти типы в Go и Java, — в «Redis: клиенты на Go, Java, Rust»; MULTI/EXEC/WATCH вокруг операций над структурами — в «KV и документные: транзакций почти нет»; а geo-индексация (структура поверх zset с географическим кодированием) — отдельная большая тема в статье про гео-поискСкоро. Персистентность и репликация, где эти же структуры сериализуются в RDB/AOF и передаются по сети, — в статьях «Redis: RDB и AOF» и «Redis: репликация, Cluster, Sentinel». Consumer groups и атомарность через Lua/FUNCTION — в статье «Redis: Streams, Lua, Functions»; практический выбор структуры под конкретную задачу и распространённые ошибки применения — в вводной статье серии «Redis: паттерны и антипаттерны» и в финальной «Redis: эксплуатация и принятие решений».

Серия трактует Redis и Valkey как равноправную, API-совместимую пару: там, где поведение совпадает (а для кодировок и порогов оно совпадает побитово), обе системы разбираются вместе; там, где расходится по факту (как со stream в разделе выше), это отмечается явно, без сглаживания в одну сторону. История самого форка и лицензионная разница (AGPLv3 у Redis, BSD у Valkey) — отдельный сюжет в статье про форк Valkey и лицензионную совместимостьСкоро.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии