4. Redis под капотом
Структуры памяти, event loop и физика исполнения
Введение
В прошлой статье мы рассмотрели Redis с высоты птичьего полёта: event loop, структуры данных и паттерны применения. Теперь пришло время открыть крышку и понять, что происходит внутри процесса Redis в те микросекунды, пока обрабатывается ваш запрос. Для Go-разработчика это знание критически важно: оно объясняет, почему одни паттерны дают 100k RPS без усилий, а другие внезапно вызывают задержки и раздувание памяти.
Вся архитектура Redis держится на трёх китах:
- Эффективнейший менеджмент оперативной памяти и компактные структуры данных;
- Однопоточный (с нюансами) асинхронный event loop;
- Тщательно продуманный компромисс между персистентностью и скоростью через fork и copy-on-write.
Разберём каждый из этих слоёв.
Память и аллокатор: почему jemalloc
Redis агрессивно использует динамическую память, создавая миллионы маленьких объектов: строки, записи хеш-таблиц, элементы списков. Стандартный аллокатор glibc malloc имеет склонность к фрагментации и высоким накладным расходам при таком профиле. Поэтому Redis с самого начала использует jemalloc — аллокатор, изначально написанный для FreeBSD и оптимизированный для многопоточных приложений с огромным количеством всех размеров аллокаций.
Что даёт jemalloc:
- Thread-caching (tcache) — каждый поток держит пул предвыделенных блоков, чтобы избежать глобальных блокировок. Даже в однопоточном Redis это ускоряет повторные аллокации того же размера.
- Низкая фрагментация благодаря «аренам» и продуманному механизму рециклинга. Память делится на классы размеров (size classes), благодаря чему блоки одного размера переиспользуются мгновенно без внешнего дробления страниц.
- Минимальный оверхед на малые блоки — Redis активно использует SDS (Simple Dynamic String) с малым размером, и jemalloc возвращает блоки почти без wasted bytes.
flowchart LR
subgraph Glibc_Malloc["glibc malloc (Фрагментация)"]
G1["Блок 24B"] --- G2["Дырка 128B"] --- G3["Блок 40B"] --- G4["Дырка 64B"]
end
subgraph Jemalloc_Arenas["jemalloc (Size Classes & Arenas)"]
J1["Арена Tiny (8, 16, 32, 64B)"]
J2["Арена Small (128, 256, 512B)"]
J3["Арена Large (>4KB)"]
end
classDef glibc fill:#1e293b,stroke:#f43f5e,color:#f8fafc;
classDef jemalloc fill:#1e293b,stroke:#10b981,color:#f8fafc;
class Glibc_Malloc glibc;
class Jemalloc_Arenas jemalloc;
Для Go-разработчика здесь важна параллель с ареной кучи Go: рантайм Go тоже избегает фрагментации, выделяя большие куски памяти из ОС и раздавая их горутинам. Если вы запускаете Redis рядом с Go-сервисом на одной машине, убедитесь, что jemalloc не конфликтует с аллокатором Go — обычно проблем нет, но при использовании тюнинга cgroup нужно следить за memory limits.
Типы данных под микроскопом
В отличие от Memcached, где всё — плоская строка, Redis имеет несколько внутренних представлений для каждого типа, переключаясь между ними в зависимости от размера данных, чтобы экономить память и процессор.
SDS (Simple Dynamic String)
Практически все строки в Redis — это не char* из C, а структура sdshdr:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // длина строки
uint8_t alloc; // выделено памяти (без учёта заголовка и null-терминатора)
unsigned char flags;
char buf[]; // гибкий массив
};
Это даёт $O(1)$ получение длины строки, бинарную безопасность (в середине могут быть нули) и исключает переполнение буфера за счёт хранения выделенного размера. С точки зрения CPU это укладывается в одну-две кэш-линии, обеспечивая очень быстрый доступ.
Хеш-таблица (словарь)
Сердце Redis — это глобальный словарь server.db[i].dict, хранящий все ключи. Устройство такое же, как описано в 2. Key Value базы, но с уникальной деталью: инкрементальное рехэширование.
typedef struct dict {
dictType *type;
dictEntry **ht_table[2]; // две хеш-таблицы: старая и новая
unsigned long ht_used[2];
long rehashidx; // -1 если рехэш не активен, иначе позиция, до которой перенесены бакеты
...
} dict;
При росте таблицы Redis не блокирует весь сервер на перемещение миллионов ключей. Вместо этого с каждым последующим обращением к словарю (чтение, запись, даже SCAN) выполняется порция переноса ключей из таблицы 0 в таблицу 1. Это обеспечивает предсказуемый latency ценой временного двойного расхода памяти.
Компактные структуры: ziplist, listpack, intset
Чтобы хранить мелкие списки, хеши и множества, Redis использует не классические связные списки, а супер-компактные сериализованные массивы.
- ziplist (исторически): непрерывный кусок памяти, где элементы упакованы друг за другом с кодировкой длины. Не требует указателей
prev/next, что экономит память (вместо 3–4 указателей по 8 байтов только 2–3 байта на элемент). Однако обновление элемента в середине требует сдвига памяти, а при превышении размера (по умолчанию 8 КБ для хеша) Redis конвертирует его в обычную хеш-таблицу. - listpack (с Redis 7.0): эволюция ziplist, лишённая некоторых уязвимостей цепных обновлений (cascading updates). Используется для Streams, а также для мелких хешей и списков. В Valkey и новых Redis команда
DEBUGпозволяет проверить тип кодировки. - intset: если множество состоит только из целых чисел, Redis хранит их в отсортированном массиве фиксированной битности (int16, int32, int64). Добавление — O(N), но для малых множеств (до 512 элементов по умолчанию) это быстрее и во много раз компактнее, чем дикт.
- quicklist: для списков Redis долгое время использовал
quicklist— связный список ziplist'ов (каждый узел — ziplist). Это даёт компромисс между эффективностью обновлений и экономией памяти. С Redis 7.0 постепенно заменяется на listpack.
Sorted Set: skiplist + dict
Упорядоченное множество одновременно использует skip list (для операций по score) и dict (для поиска по member за O(1)). Элемент занимает память один раз, а структуры содержат указатели на него. Это классический пример trade-off «память за скорость». Для огромных лидербордов (миллионы записей) это означает, что каждый элемент обходится примерно в 64 байта (три указателя в skip list + dict entry + score).
Event Loop и сетевая подсистема
ae event library
Redis использует собственную абстракцию ae, которая под капотом подключает лучший механизм для платформы: epoll на Linux, kqueue на BSD, /dev/poll на Solaris. Цикл выглядит так:
flowchart TD
A["Инициализация сервера"] --> B["aeMain"]
B --> C["aeProcessEvents<br>вызов epoll_wait с таймаутом"]
C --> D{"События на сокетах?"}
D -- "Да" --> E["Чтение запросов<br>и постановка в очередь"]
D -- "Нет" --> F["Выполнение time-событий<br>serverCron, клиентские таймауты"]
E --> G["Обработка команд<br>однопоточно"]
G --> H["Запись ответов<br>в сокеты"]
H --> C
F --> C
classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
classDef loop fill:#312e81,stroke:#6366f1,color:#ffffff,stroke-width:2px;
class G loop;
serverCron — это функция, вызываемая сотни раз в секунду (настраивается hz). Она делает всё: удаление истёкших ключей, инкрементальный рехэш, сжатие AOF, переподключение реплик, снятие статистики. Если снизить hz, latency операций с истечением возрастает.
Многопоточный I/O (Redis 6.0+)
Чтобы утилизировать несколько ядер при высоком сетевом трафике, Redis выделяет потоки, которые могут параллельно читать запросы из сокетов и записывать ответы. Однако сама обработка команд всегда происходит в главном потоке. Потоки используют тот же event loop, но без выполнения команд. Это позволяет удвоить пропускную способность на мощных серверах.
Протокол RESP и его парсинг в Go-драйвере
Redis общается по протоколу RESP. Запросы — это массивы bulk strings, ответы — строки, числа, ошибки. Внутри Redis парсер работает в контексте каждого клиента: буфер накапливает данные от сокета, и конечный автомат разбирает кадр. Если данные получены не полностью, парсинг приостанавливается.
В Go-драйвере go-redis реализован похожий подход: данные читаются из net.Conn, и с помощью пула буферов (sync.Pool) парсятся без лишних аллокаций. Пайплайнинг (посылка нескольких команд подряд) позволяет драйверу не блокироваться на каждом ответе, а отправлять пачку и затем разбирать ответы. Под капотом это уменьшает количество системных вызовов write/read и снижает latency за счёт объединения пакетов в один TCP-сегмент.
Персистентность: танец с fork и COW
Redis хранит данные в памяти, но может сохранять их на диск в двух режимах: RDB и AOF. Нас интересует, как это работает на уровне ОС.
RDB (Snapshots)
Когда срабатывает правило save 900 1 или клиент вызывает BGSAVE:
- Redis вызывает
fork(). Ядро создаёт копию процесса (child) с общей памятью. - Благодаря Copy-on-Write, пока родитель не изменяет страницы, дочерний процесс читает те же физические страницы.
- Дочерний процесс обходит структуры данных и записывает их на диск в компактном бинарном формате.
- В это время родитель продолжает обслуживать запросы. Когда он изменяет ключ, ядро копирует изменённую страницу памяти для родителя, но дочерний продолжает видеть старую копию.
Объём памяти удваивается в худшем случае, если родитель интенсивно изменяет множество страниц. На практике при активной записи использование памяти может вырасти в 1.5–2 раза. Это критично учитывать при настройке лимитов памяти в Kubernetes: лимит должен быть минимум в два раза больше ожидаемого датасета, или нужно выключать RDB и полагаться на AOF.
AOF (Append-Only File)
Каждая операция записи может журналироваться в файл. Политики fsync:
- always — после каждой команды
fsync(), что вызывает syscall и реальную запись на диск. Максимальная сохранность, но катастрофическая производительность (системный вызов на каждый запрос, см. 34. Аппаратные прерывания и Системные вызовы — как дорогой syscall). - everysec — буферизация внутри Redis и
fsyncраз в секунду фоновым потоком. Компромисс: можно потерять 1 секунду данных. - no — отдаём управление ОС, полагаясь на периодический сброс page cache.
При срабатывании AOF rewrite Redis снова делает fork, и дочерний процесс пишет сжатую версию лога, а родитель накапливает новые команды в буфере.
Репликация: как данные попадают на реплики
Redis использует асинхронную репликацию master → replica. При начальном подключении:
- Replica отправляет
PSYNC ? -1(полная синхронизация). - Master делает
forkи начинает фоновое сохранение RDB. Параллельно он записывает все новые команды в replication backlog (циклический буфер). - После сохранения RDB отправляется на реплику и загружается в её память.
- Затем передаются накопленные команды из backlog'а.
- Далее идёт непрерывный поток команд.
Для Go-приложений это означает, что чтение с реплики может вернуть устаревшие данные. Если нужна сильная консистентность, можно использовать WAIT с количеством реплик, но это увеличивает задержку. Либо вынести строго консистентные операции на мастер, а чтение с реплик — для некритичных данных.
Кластер: разделение ключей
В кластерном режиме 16384 хеш-слотов распределены между нодами. Ключ хешируется алгоритмом CRC16, и берётся модуль 16384. Нода, владеющая слотом, обрабатывает запрос. Если ключ принадлежит другой ноде, клиент получает MOVED <slot> <ip:port>. Go-драйвер go-redis кэширует соответствие слотов и автоматически перенаправляет запросы.
Ноды общаются через gossip-протокол, обмениваясь информацией о состоянии кластера. При добавлении новой ноды слоты мигрируют. В процессе миграции команды, касающиеся мигрирующего ключа, могут получить ASK-редирект.
Здесь транзакции, затрагивающие ключи в разных слотах, не поддерживаются — их нужно проектировать так, чтобы все ключи были в одном слоте (например, через hash tags {user:42}:...).
Pub/Sub, Streams и Lua
Pub/Sub под капотом
При SUBSCRIBE клиент помещается в словарь pubsub_channels. Когда кто-то делает PUBLISH, сообщение рассылается всем подписанным клиентам (в их выходные буферы). Если клиент не успевает читать, буфер растёт. Когда буфер достигает лимита client-output-buffer-limit, Redis отключает медленного клиента. Это классическая проблема backpressure.
Streams
Потоки используют структуру rax (radix tree) для хранения ID сообщений и listpack для самих данных. Группы потребителей используют дополнительные структуры с последним доставленным ID. При подтверждении (XACK) запись удаляется из pending lists. Всё это в памяти, что ограничивает размер стримов объёмом RAM.
Lua-скрипты
Скрипты выполняются в главном потоке, монопольно. Redis использует одну Lua VM для всех скриптов. При репликации, если скрипт использует случайные функции или зависит от времени, это может привести к расхождению данных на мастере и реплике. Поэтому Redis реплицирует сам скрипт (или сгенерированный набор команд в redis 7+), а не результат. redis.replicate_commands() позволяет реплицировать только команды записи, созданные скриптом.
Mechanical Sympathy для Go-разработчика
Разберём цепочку выполнения команды GET из Go-сервиса, чтобы увидеть физику:
- Горутина вызывает
rdb.Get(ctx, "user:42"). - Драйвер берёт соединение из пула (переиспользуемый TCP-сокет).
- Формирует RESP-строку
*2\r\n$3\r\nGET\r\n$7\r\nuser:42\r\nв буфере (аллокация, которая может быть изsync.Pool). - Делает
conn.Write(data)— системный вызовwrite. В ядре данные копируются в send buffer сокета. - Горутина паркуется (планировщик Go убирает её из виртуального ядра).
- Событие
epoll_waitв главном потоке Redis сигнализирует о данных. Redis читает запрос (read), парсит RESP, выполняет поиск в словаре (несколько разыменований указателей), формирует ответ и пишет его в сокет (write). - На стороне Go сетевой поллер обнаруживает данные в recv buffer, пробуждает горутину.
- Драйвер читает ответ, парсит RESP, возвращает слайс байтов.
Вся операция заняла десятки микросекунд, из которых подавляющее время — ожидание сети и epoll. Доля Redis в этом — несколько микросекунд на поиск ключа.
Чтобы снизить сетевые обмены, используйте MGET/MSET (или пайплайны) для объединения операций. Тогда несколько команд упаковываются в один TCP-сегмент, что уменьшает количество syscall'ов и повышает throughput.
Опасности: большие ключи и медленные операции
В Go это нужно отслеживать через метрики latency и мониторинг Redis (17. Мониторинг баз данных).
Итог
Внутренности Redis — это учебник по эффективному использованию памяти, асинхронного I/O и управлению fork. Каждая деталь, от SDS до listpack, от jemalloc до инкрементального рехэша, служит одной цели: минимальная задержка при максимальной утилизации ресурсов. Для Go-инженера понимание этого «подкапотного» устройства напрямую влияет на выбор структур данных ключей, стратегию пайплайнинга и настройку персистентности.
Теперь, вооружившись этим знанием, мы двинемся к другому легендарному in-memory хранилищу — легковесному, многопоточному и специализированному на кэшировании. 5. Memcached