Про Redis чаще всего думают как про «кэш ключ-значение на пять минут». Это сильно занижает картину: под капотом Redis — сервер структур данных, где у каждого значения есть не только логический тип (string, hash, list, set, zset, stream), но и внутреннее представление, которое движок выбирает сам в зависимости от размера и содержимого. Одна и та же хеш-таблица может лежать в памяти как компактный listpack или как полноценная hash-таблица — и от этого зависят и расход памяти, и стоимость операций.
Эта статья открывает серию «Redis: глубокое погружение» и задаёт фундамент для остальных: прежде чем говорить про персистентность, репликацию и вытеснение, нужно понимать, из чего вообще состоят данные в Redis. Сквозная нить серии — «Redis как кэш vs Redis как источник истины» — начинается уже здесь: выбор структуры и кодировки определяет, насколько предсказуемо Redis ведёт себя под нагрузкой.
В статье
- Пять типов и одна общая идея
- Компактное представление vs полное: где проходят живые пороги
- Память на структуру: что реально показывает
MEMORY USAGE OBJECT ENCODINGкак инструмент диагностики- Что дальше
- Источники
Пять типов и одна общая идея
У 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 по числу полей | listpack → hashtable |
count=513 (порог hash-max-listpack-entries=512, переход на 512+1) |
| hash по длине значения одного поля | listpack → hashtable |
len=65 (порог hash-max-listpack-value=64) |
list по числу элементов (короткие eN, ~4–6 байт) |
listpack → quicklist |
count=1328 (упирается в байтовый лимит -2/8 КиБ узла, не в число элементов) |
| zset по числу элементов | listpack → skiplist |
count=129 (порог zset-max-listpack-entries=128) |
| zset по длине member | listpack → skiplist |
len=65 (порог zset-max-listpack-value=64) |
| set из целых чисел по числу элементов | intset → hashtable |
count=513 (порог set-max-intset-entries=512; переход сразу в hashtable, минуя listpack, т.к. 513 больше set-max-listpack-entries=128) |
| set: 5 целых + 1 нечисловой элемент | intset → listpack |
сразу (5 меньше set-max-listpack-entries=128) |
| set: 138 целых + 1 нечисловой элемент | intset → hashtable |
сразу (138 больше set-max-listpack-entries=128) |
Точки перехода совпали побитово на redis:8.8 и valkey/valkey:8.1 — построчное сравнение обоих логов прогона не нашло ни одного расхождения. Это ожидаемо: за кодировки структур данных отвечает общий движок, унаследованный форком, и на этом уровне расхождений ожидать не приходится — что прогон и подтвердил.
≤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 на коротких элементах вида e1…eN дал:
| 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 и лицензионную совместимостьСкоро.
Источники
- Официальная документация: redis.io, раздел redis.io/docs.
- Типы данных: redis.io/docs — Data types.
- Команды интроспекции
OBJECT ENCODING,MEMORY USAGE— по справочнику команд redis.io/commands. - Стенд: digital-cookbook/databases/redis/deep-dive (
redis:8.8,valkey/valkey:8.1, три сценария:listpack-threshold,intset-threshold,memory-per-structure).
Комментарии