В сентябре 2024 года вышел релиз Valkey 8.0 — это key-value-хранилище также часто называют BSD-клоном Redis. В отличие от Redis, Valkey изначально создавался как опенсорс-проект. У него нет энтерпрайз-версии, а значит, развитие не сдерживается коммерческими ограничениями.
Весной 2024 года, когда началась активная работа над форком, команда разработчиков смогла принять и стабилизировать ряд патчей, которые заметно улучшили производительность по сравнению с Redis 7.2.
В этой статье Евгений Дюков, разработчик Managed Databases в Yandex Cloud, разбирает некоторые из изменений и делится результатами проведённых бенчмарков, которые позволяют оценить, как именно новые патчи повлияли на производительность — и в позитивном, и, в некоторых случаях, в негативном ключе. Особенно интересно будет тем, кто ждёт релиз Valkey 8.1 этой весной.

Redis: от опенсорса к коммерческой модели
Redis — это main memory база данных (DBMS). Если кто-то не знаком с термином, это значит, что основной датасет хранится в памяти. У Redis есть энтерпрайз-версия, которая умеет выгружать часть данных на SSD. Но в опенсорс-версии все данные остаются в оперативной памяти.
Ещё Redis считают однопоточной СУБД. На самом деле это не так: если запустить его более-менее современную версию и посмотреть на потоки, можно увидеть, что их больше одного. В Redis есть:
I/O-потоки, которые можно задать в конфиге.
Background I/O, например, механизмы синхронизации background AOF fsync или lazy free, позволяющий выполнять разные операции независимо от main-потока.
Jemalloc-bg-потоки, если вы собрали Redis с Jemalloc, как это по дефолту, например, в Linux.
Так что это — не однопоточная, а многопоточная СУБД, просто команды исполняются в один поток.
Кроме всего, Redis — одна из самых любимых и популярных СУБД среди профессиональных разработчиков. По результатам опроса Stack Overflow 2023, она входит в топ-6 баз данных.

Если внимательно посмотреть на рейтинг, первые четыре места занимают реляционные СУБД, на пятом вообще MongoDB — документная база данных. Среди main memory баз данных Redis лидирует, и рядом нет других СУБД этого же класса.
Весной 2024 года произошли изменения, и Redis сменили лицензию, перестав быть опенсорс-проектом. До этого он развивался по модели Open Core: в базовой версии были все ключевые возможности для работы как с key-value storage, а дополнительные расширения, такие как RediSearch и RedisJSON, распространялись под несвободной лицензией. Эти расширения можно было использовать бесплатно, но на их основе нельзя было развернуть DBaaS (Database-as-a-Service). Теперь для этого нельзя использовать даже сам Redis. Скорее всего, крупные вендоры перейдут на альтернативные решения.
Valkey — альтернатива для Redis
В разработке Redis участвовали не только инженеры компаний, которые напрямую связаны с его развитием, но и специалисты из Amazon, Google, Ericsson, Alibaba, Huawei и других. После смены лицензии основная команда контрибьюторов покинула официальный репозиторий и переключилась на форк Valkey, который теперь развивается отдельно. Он сохранил лицензию BSD 3-Clause, которая использовалась ранее в Redis.
Будучи форком Redis, Valkey унаследовал базовую архитектуру и механизмы работы. Поэтому сначала разберём, как работает система в Redis, чтобы затем понять, какие улучшения были внедрены в Valkey.
Релиз Valkey 8.0
В сентябре 2024 года вышел релиз Valkey 8.0, функционально отличающийся от оригинального Redis. До этого был малозаметный релиз 7.2, где, по сути, просто заменили название «Redis» на «Valkey» без значительных изменений. Однако нас он сейчас не интересует — сосредоточимся на версии 8.0.
Сравнение производительности: бенчмарки Valkey 8.0 vs Redis
Теперь о том, какой тест мы использовали для оценки производительности.
Методика тестирования
Для бенчмарка использовали YCSB (Yahoo! Cloud Serving Benchmark) без workload E. Этот инструмент эмулирует разные сценарии пользовательских нагрузок:
«a load» — первый ворксет, это 100% insert, где мы заливаем данные для workload.
50/50 read/write (50% запросов на чтение и 50% на запись) — но это скорее не очень реалистичный workload.
95/5 read/write — в основном чтение. Этот ворксет ближе к реальному сценарию использования подобных баз данных.
100% read — модель, схожая с работой кеша, где почти все запросы на чтение.
Insert + read inserted — вставляем новые данные и читаем их.
Read + modify — читаем данные с последующим их изменением. Такой подход ближе к сценарию, когда Valkey используется как основное хранилище.
Тестовое окружение и настройки
Бенчмарк запускался на процессорах Intel Ice Lake в Yandex Cloud с конфигурацией: две тестовые машины по 32 ядра, 64 GB RAM.
Чтобы исключить шум, использовали такие настройки:
cset shield + no save + CPU pin (server_cpulist 4-20:2), где
cset shield исключает всю «системную» нагрузку, распределяя её на отдельные ядра. В нашем случае — на четыре гипертрединговых ядра (первые два — физических).
no save отключает BgSave — периодический сброс данных базы на диск, чтобы фоновый процесс не влиял на тестирование.
CPU pin позволяет явно задать ядра, на которых будет выполняться workload. Мы пинним конкретные ядра, чтобы избежать конкуренции за ресурсы между I/O-потоками и main-потоком на одном физическом ядре и получить результаты без зависимости от конкретного запуска.
I/O threads 9 + io-threads-to-reads yes
Настройка I/O-потоков определяется общим количеством потоков, включая основной main — он всегда один. Например, если указано I/O threads = 9, это означает, что используется один main-поток и восемь I/O-потоков.
Примеры значений:
1 → только main-поток, I/O-потоки отсутствуют.
2 → один I/O-поток + main-поток.
9 → восемь I/O-потоков + main-поток.
Дефолтное значение 1 означает, что система работает только в одном потоке, без дополнительных потоков ввода-вывода.
No Redis/Valkey pipeline
Выключаем пайплайн, исходя из целесообразности. Несмотря на то, что включённый пайплайн позволяет добиться очень высоких значений RPS (миллион и больше), это редко встречается в реальных сценариях. В продакшн-среде приложения чаще всего работают без использования пайплайна. Кроме того, если используется cluster mode для горизонтального масштабирования, то пайплайн не получится применять эффективно.
Результаты тестирования
Давайте ещё раз взглянем на цифры на гистограмме: во всех сценариях Valkey 8.0 нигде не хуже Redis 6.2 и Redis 7.2. При этом Redis 7.2 часто уступает 6.2.

Почему Redis 7.2 показывает слабые результаты? Если судить по статистике Yandex Cloud, то самая популярная версия Redis — 6.2. Многие пользователи пытались перейти на 7.0 и 7.2, но сталкивались с серьёзными регрессиями. В Redis 7.0 регрессии были особенно заметными. В 7.2 часть проблем исправили, но в целом эта версия остаётся не самой удачной.
Эти выводы подтверждаются не только нашим бенчмарком, но и репозиторием Redis, где можно найти множество открытых issue с обсуждением проблем. Часть регрессий исправили в версии 7.2, но некоторые до сих пор не решены, и даже проявляются в Redis 8.0.
Но Redis 6.2 уже при смерти. Обычно поддерживаются три последние версии, а значит с восьмым релизом версия 6.2, скорее всего, потеряет поддержку, и останутся 7.2, 7.4 и 8.0.
Valkey 8.0 почему-то лучше, чем остальные представленные варианты — обходит и Redis 6.2, и Redis 7.2. Одно из возможных объяснений связано с работой I/O-потоков. Дальше расскажу, как это устроено.
Механика работы I/O-потоков в Redis
Начнём с того, как I/O-потоки работают в Redis 7.2.
Main-поток управляет обработкой запросов. В момент простоя (idle state), когда в системе нет активных запросов, а внутренний встроенный Cron — за скобками, процесс выглядит так:
Main-поток висит в epoll wait на некотором количестве сокетов, удерживая блокировки (locks).
I/O-потоки ожидают, пока main эти блокировки не отпустит.
В таком состоянии система ничего не делает и всё стабильно работает.

Несмотря на то, что I/O-потоки выполняют только чтение и запись, они всё же могут выполнять интенсивную CPU-нагрузку. Когда их вводили, идея заключалась в том, что они будут обрабатывать TLS-трафик. Их появление совпало с реализацией TLS в Redis.
Теперь посмотрим на сокеты. Всё как и было ранее:
Main-поток держит блокировки.
Система ждёт в epoll на нескольких сокетах.
Как только происходит событие, например, кто-то из клиентов начал писать в сокеты, main-поток фиксирует это благодаря epoll wait.

Дальше мы сформируем глобальную структуру данных clients_pending_read
. Разложим знания об этих клиентах в I/O-потоках, отдадим клиентов на чтение, указав, каких конкретно.

Затем отпустим блокировку, и I/O-потоки начнут читать.

После чтения main-поток захватывает блокировку обратно и обрабатывает данные.

Посылать нагрузку в I/O-потоки можно не всегда, поскольку их использование порождает дополнительные системные вызовы (syscalls).
Когда вы захватываете и опускаете блокировки, это делается через thread-мьютексы, и это тоже системные вызовы. В идеале, если в main-поток одно событие, его можно прочитать за один системный вызов, чтобы потом не заниматься координацией с I/O-потоками.

По этой причине в Redis есть очень неприятная проблема — поздняя активация I/O-потоков. Они активируются только в случае, если количество событий превышает удвоенное число их самих. Значит, в некоторых ситуациях увеличение количества I/O-потоков не только не ускоряет обработку, но, наоборот, может привести к деградации производительности.
При чтении процесс выглядит так: если в main-потоке накопилось более N событий, они разблокируются и начинают обрабатываться.

Кейс из практики: как оптимизация Redis привела к деградации
У нас во внутреннем облаке был клиент, который очень серьёзно занимался оптимизацией работы с Redis:
Они активно использовали pipelining и работали с Jumbo-фреймами (8-килобайтными пакетами).
В каждом пакете в Redis отправляли сотни запросов, при этом держа минимум соединений (потому что с pipelining много и не нужно).
У них был Resource Preset на 6 ядер, который увеличили до 8.
Результат: после увеличения числа ядер производительность резко упала. Вообще всё стало плохо работать. Они обратились с вопросом: «Ребята, что происходит? Мы увеличили ресурсы, а всё стало только хуже!»
Дело в том, что раньше их виртуалка выглядела так:

Под системные процессы было выделено одно ядро, его гипертредовая пара обслуживает бэкграундную активность. Main-поток и I/O-потоки делят физическое ядро, потому что одновременно никогда не работают. В старых версиях Redis так можно было делать.
Наша автоматика при увеличении пресета делала так:

Очевидно, что проблема была в слишком малом количестве соединений. Если коннектов меньше, чем 2 × I/O-потоки, то последние перестают работать, а вся нагрузка приходится на один main-поток. Ребята из примера так и просели.
Новый подход к работе с I/O-потоками в Valkey
Теперь подумаем, как это улучшить. Это можно сделать, фундаментально посмотрев на то, что происходит, когда I/O-тредов нет. В этом случае цикл main-потока выглядит примерно так:

Ожидание событий → чтение данных → выполнение команд → запись → и далее повтор цикла.
Если клиентов, выполняющих этот цикл, много, то можно группировать:
поступило много событий → читаем асинхронно;
что-то прочиталось → выполняем небольшими батчами;
то, что уже успешно выполнили, записываем в ответ.
Так система ходит по циклу, но уже обрабатывает запросы сразу для нескольких клиентов, а не для одного.
Когда мы вносим I/O треды, возникает проблема: мы в main-потоке начинаем ждать в тех местах, где I/O треды что-то читают или пишут.

Несмотря на то, что мы расставили задачи на чтение/запись, к следующему шагу перейти не можем, пока не дождёмся завершения работы I/O-потоков. Но они в это время простаивают и ждут main-поток, если последний занят поллингом или выполняет команды. В результате оба потока ждут друг друга.

В Valkey это исправлено с помощью схемы:
Main-поток не ждёт завершения I/O-потоков. Он отправляет запросы на чтение и запись и сразу идёт дальше, как если бы был один.
Если есть другие задачи, выполняет их.

Но чтобы это реализовать, нужно разделить глобальные структуры данных так, чтобы они не были общими между main- и I/O-потоками, либо сделать какую-то синхронизацию, например, использовать атомики (atomics). Разработчики Valkey так и сделали:
Каждому I/O thread добавили «очередь задач»
Для каждого потока создали ring buffer в памяти, куда записывается список задач. Поскольку теперь не возникает ситуации, когда можно только читать или только записывать, каждый поток получает конкретные команды: «читай вот это», «записывай это».
Используем атомики в структуре клиента
В Redis есть структура клиента — client struct, по сути это то, как представлен connect в рейсе. В неё добавили несколько атомиков.
В main-потоке мы проверяем atomic в структуре клиента, если с ней работает I/O thread, то не трогаем её.
У такого подхода два бонуса: во-первых, не нужно ждать, пока все потоки отработают — можно переходить на следующий шаг. Во-вторых, main-поток и I/O-потоки могут работать одновременно. Мы избавились от задержек, когда приходилось дожидаться окончания чтения перед записью.
Можно ли в потоках делать что-то кроме чтений и записи? Можно.
Теперь, когда потоки могут выполнять разные типы задач, мы можем расширить их функциональность:
Поллить
Пока main-поток выполняет команды, I/O-потоки могут поллить сокеты и потом сообщать, какие события в них произошли.
Парсить команды из словаря переименований команд
Это потребует некоторых изменений, например, нужно будет запретить просто так рехешировать словарь. Но в целом I/O-потоки могут разбирать команды и проверять их в словаре, пока main-поток выполняет другую работу.
Оптимизировать освобождение памяти
Так как предыдущий шаг аллоцирует память, её можно освобождать. В случае использования Jemalloc, есть бонус: если память освобождается в том же потоке, в котором она была выделена, то нет необходимости эту операцию синхронизировать с другими потоками.
Что ещё улучшилось в Valkey 8.0
Улучшение работы I/O-потоков — это только первый шаг. Следующим важным направлением оптимизации в Valkey стала работа с хеш-таблицами.
Как устроена хеш-таблица в Redis: основная структура данных внутри — используется closed addressing hash tables.

Вы вычисляете по ключу бакет, в который этот ключ попадает. Внутри бакета хранится связный список, состоящий из структур, которые представляют пары ключ/значение. Чтобы найти нужное значение ключа, приходится перемещаться по списку в памяти, делая несколько переходов. При последовательном выполнении команд (например, у первого клиента одна команда, у второго другая), происходит следующее:

Значения из памяти складываются в регистры → дальше они как-то обрабатываются → затем снова загружаются новые значения из памяти → так снова и снова, пока вы не дойдёте до выполнения команды первого клиента. Затем в той же последовательности выполняются команды второго клиента — вы просто достали значение и ещё ничего с ним не делали.
При этом мы знаем, что в среднем latency оперативной памяти примерно 100 ns. Получается, что ничего с данными не делая — мы просто вытаскивали из памяти значение в регистры процессора — потеряли уже 800 ns. В однопоточном Redis такие задержки очень значимы. Но от них можно избавиться, чтобы ускориться.
Современные процессоры поддерживают предвыборку данных (prefetch).
Если инструкции, загружающие данные из памяти в регистры, расположены близко друг к другу, процессор не будет выполнять их последовательно. Вместо этого он обработает их параллельно, одновременно выполняя весь блок инструкций.
Поскольку кэш процессора (L1, L2, L3) работают по схеме write-through, подсчитанные значения сразу попадут в L1-кэш.

Если мы посмотрим на latency кешей, то увидим, что доступ к данным в L1 занимает 1ns.

Если мы сделаем такое изменение, добравшись сначала до значений батчами и не выполняя никаких команд, то можем сильно сократить latency:

Ранее, при каждом обращении к памяти, задержка составляла 100 ns. Теперь, благодаря prefetch, доступ к данным занимает всего 8 ns, так как мы заранее загрузили данные, потратив на это 400 ns (в данном примере для двух команд). В результате, общая задержка уменьшилась вдвое.
Репликация и оптимизация работы с ключами
Хотя в восьмом релизе Valkey заметны улучшения производительности, изменения затронули и другие значимые вещи, такие как репликация и оптимизация работы с ключами.
Dual-channel replication
Когда в Redis создаётся новая реплика, процесс обычно выглядит так:
Fork primary → сброс RDB в сокет.
Вы форкаетесь, делаете Copy-on-Write Snapshot, таким образом получая консистентное состояние внутреннего словаря. Затем сериализуете его в RDB (всё это выполняется в потоке) и передаёте в сокет. На стороне реплики этот сокет читается, и данные либо загружаются в память, либо записываются на диск. Далее из него загружается и получается консистентное состояние.
Проблема в том, что пока вы заполняете эту реплику, на мастере происходят изменения. Поэтому вы дополнительно откладываете все события репликации в клиентский output-буфер. В старых версиях Redis (7.2 и ранее) есть недостаток: на мастере может закончиться память, если изменения достаточно интенсивные.
Dual-channel replication позволяет переместить этот буфер на реплику.
Вы не копите изменения в output буфере, а сразу отправляете их на реплику, в буфере которой они накапливаются. Так, если у вас интенсивная нагрузка, шансы получить Out Of Memory на primary снижаются на порядок.

Key embedding
Давайте ещё раз посмотрим, как выглядит словарь в Redis.
Он состоит из некоторого количества бакетов, в каждом из которых хранятся структуры. В этих структурах на ключ на самом деле хранится указатель. Если у вас ключ не очень большой, то размер ключа может быть близок к размеру указателя. Например, если у вас ключи типа int, то, скорее всего, размер указателя и ваши ключи примерно одинаковы, и никто не мешает сделать так:

Нет смысла хранить отдельный указатель, если нам этот ключ нужен практически всегда. Поэтому мы просто положим его внутрь структуры. Это позволяет экономить примерно 10–20% оперативной памяти, если ключи действительно короткие.
Reusable qbuf, Redis backport
Эта функциональность интересна, скорее, не тем, что она даёт, а тем, что Redis адаптировал её из другого проекта.
Reusable query buffer — то место, где происходит парсинг ваших команд. В Valkey в этом месте оптимизировали использование памяти, сделав буфер переиспользуемым между различными клиентами. Redis это перенял.
Больше метрик
Ещё ввели интересные метрики:
CPU usage — информация об использовании процессора;
key count — статистика по количеству ключей;
io metrics per slot — метрики ввода-вывода по каждому слоту кластера.
Это позволяет в шардированном Redis искать горячие слоты (с повышенной нагрузкой). Раньше сделать это было сложнее.
Итого
Valkey 8.0 обошёл по производительности как Redis 6.2, так и Redis 7.2, и теперь понятно, почему. Улучшенная работа с потоками ввода-вывода, оптимизированные структуры данных и новые подходы к хранению информации сделали его более быстрым.
Кроме того, после разделения проекта в Valkey пришло больше core-разработчиков, что означает более активное развитие и внедрение новых технологий.
Хайлайты того, что сейчас происходит:
Близится релиз Valkey 8.1
Valkey 8.1
На момент выступления с этой темой на конференции Highload++2024 релиз ещё не состоялся.
Переход на open addressing в хеш-таблицах
Выше мы рассматривали закрытое хеширование (closed addressing), но в Valkey активно работают над переходом на open addressing. Уже есть ветка, которая проходит тестирование, и, скорее всего, этот механизм появится в следующем мажорном релизе. Но это не значит, что на бенчмарках будет ×2 RPS — скорее, упор будет на другие части системы.
Если интересно подробнее разобраться в open addressing, есть отличный доклад с CppCon 2017, в котором разработчик из Google объясняет, как работает Flat HashMap в Abseil — ссылка.
Atomic Slot Migration — возможно, в будущем мажорном релизе (но не уверен).
Этот механизм особенно полезен для кластеров, где перераспределение данных (ребаланс) — известная боль. В текущих версиях в процессе ребаланса клиенты получают ошибку. Atomic Slot Migration должен это исправить.
LZ4 вместо LZF
Сейчас ведутся обсуждения о переходе с LZF на LZ4, что может значительно ускорить работу с RDB-файлами. Это даст сразу несколько преимуществ: меньший размер RDB и более быстрая сериализация. Valkey начнёт быстрее взлетать в случае выключения и репликация будет работать быстрее.
Оставаться на Redis 7.2 (последняя версия под BSD) — ошибка, на 6.2 наверное тоже.
Если вы работаете на Redis 7.2 или 6.2, стоит серьезно задуматься о переходе на Valkey. Это более перспективный путь с открытой лицензией и активной поддержкой сообщества.