Как я придумывал замену Redis и что из этого получилось

от автора

Преамбула

Некоторое время назад передо мной встала задача спроектировать высокопроизводительный балансировщик нагрузки для протокола Diameter. Через три месяца проект на стороне заказчика свернули — решение показалось слишком долгим и дорогим в разработке. Тем не менее, я довел дело до конца и собрал MVP, готовый к развертыванию как на «голом железе», так и в облачной инфраструктуре.

Продукт получился отличным: он с лихвой выигрывал у имевшегося в компании решения, превосходя его по производительности примерно в 6 раз, а по эффективности использования ресурсов — почти в 100 раз.

Но речь сейчас не о самом балансировщике, а об одной из его ключевых частей. Балансировщик был кластерным и хранил сессии в Redis. Система работала, функциональность была подтверждена, но меня категорически не устраивала производительность. Я столкнулся с удручающим барьером: по непонятным на первый взгляд причинам система упиралась в 5 000–7 000 Diameter Transactions Per Second (TPS).

Профилирование показало, что узким местом был именно Redis. Потратив некоторое время на оптимизацию взаимодействия, я сумел поднять производительность балансировщика до 20 000 TPS, но это решение заставило пойти на компромиссы и принесло новые проблемы.

Причины создания HurriCache

Проведя детальный анализ, я выяснил: без пайплайнинга Redis физически не способен отдавать данные быстрее 5 000–6 000 RPS на одно соединение/поток. Мой балансировщик аккуратно подтвердил эту цифру, упершись ровно в данный потолок.

Чтобы преодолеть ограничение, я перепробовал разные подходы: от нативного пайплайнинга до группировки запросов в батчи. Желанные 20 000 TPS для балансировщика в итоге были достигнуты, и формальная цель проекта была выполнена. Однако меня зацепил исследовательский вопрос: какова реальная (а не маркетинговая) пропускная способность Redis на одной ноде?

Серия синтетических тестов дала следующие результаты:

Тип операции

Без пайплайна (Single Request)

С пайплайном (Pipelined)

Чтение

5К RPS

60К RPS

Запись

3К RPS

20К RPS

Эта таблица наглядно объясняет, почему пробить потолок в 20 000 TPS на реальной бизнес-логике балансировщика оказалось настолько сложно.

В этот момент и родилась идея: а что если спроектировать собственное in-memory хранилище — похожее на Redis по возможностям, но избавленное от его фундаментальных сетевых и архитектурных узких мест?

Так началась разработка HurriCache.

Архитектурный фундамент HurriCache

Одна из главных проблем Redis — невозможность переиспользовать сетевое соединение до тех пор, пока из него не будут полностью вычитаны данные предыдущего ответа (отсутствие честного мультиплексирования). Из-за этого использовать Redis без пулов соединений (Connection Pools) в высоконагруженных системах практически бессмысленно. Пулы частично решают проблему, но создают оверхед на менеджмент соединений и контекст-свичи.

Я решил сразу строить транспортный слой на протоколе с нативной поддержкой мультиплексирования.

В качестве фундамента был выбран gRPC + Protocol Buffers. Это дало возможность гонять тысячи параллельных запросов через одно TCP-соединение без блокировок и оверхеда на парсинг текстового протокола RESP.

Далее я спроектировал Protobuf-интерфейс, который закрывает большинство привычных примитивов Redis и добавляет то, чего в нем исторически не хватало:

  • Basic Key-Value (строки, бинарные данные);

  • Lists (массивы и связные списки);

  • Queues (очереди с гарантией порядка);

  • HashSet / HashMap;

  • OrderedSet / OrderedMap;

  • Atomics (полный набор атомарных операций, включая Compare-And-Swap / CAS);

  • Locks (поддержка READ/WRITE/GLOBAL locks в зависимости от клиента)

  • Нативная кластеризация «из коробки».

Архитектурные делемы и интересные проблемы

В процессе разработки выяснилось множество интересных деталей о поведении стандартных C++ контейнеров под экстремальной нагрузкой.

Например, популярные решения std::unordered_map и absl::flat_hashmap на моих синтетических профилях вызовов показывали практически одинаковую производительность, упираясь в промахи по кэшу процессора.

После долгой борьбы с L1/L2/L3 cache misses и тонкой настройки процессорных инструкций префетчинга (prefetch), мне пришлось написать собственную реализацию flat_hashmap, оптимизированную под специфику аллокаций HurriCache.

(Детальный разбор реализации этой хэш-таблицы, борьбы с cache miss и работы с memory arenas я планирую вынести в отдельную техническую статью, иначе этот материал получится бесконечным).

Что получилось в итоге

На текущий момент HurriCache уверенно работает в кластерном режиме. Вот реальные показатели производительности на одну ноду без ухищрений с пайплайнингом:

Тип операции

HurriCache (Без пайплайнинга)

Redis (Без пайплайнинга)

Redis (С пайплайном)

Чтение (Read)

85 000+ RPS

~5 000 RPS

~60 000 RPS

Запись (Write)

77 000+ RPS

~3 000 RPS

~20 000 RPS

Примечание: Концепция классического пайплайнинга в HurriCache просто не требуется — мультиплексирование gRPC позволяет насыщать канал асинхронными запросами без искусственной задержки на сбор батча.

Заключение

Как первое приближение и MVP, HurriCache показал себя крайне интересным и жизнеспособным решением. Нам удалось не просто догнать Redis с пайплайнингом, а превзойти его показатели (особенно на операциях записи: 77K RPS против 20K RPS), сохранив при этом прозрачность синхронного кода без необходимости собирать ручные батчи на стороне клиента.

Сейчас проект активно развивается: впереди создание клиентов на разных языках программирования. На данный момент уже реализован клиент на Java. Благодаря выбору стек-технологий gRPC + Protocol Buffers, поддержка которых есть практически везде, реализация клиентов под другие языки не должна вызывать особых сложностей.

Буду рад ответить на вопросы по архитектуре в комментариях!

ссылка на оригинал статьи https://habr.com/ru/articles/1067130/