Преамбула

Некоторое время назад передо мной встала задача спроектировать высокопроизводительный балансировщик нагрузки для протокола 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, поддержка которых есть практически везде, реализация клиентов под другие языки не должна вызывать особых сложностей.

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

Комментарии (7)


  1. Denzeriko
    05.08.2026 15:46

    Интересно сравнить TPS ещё с Tarantool. На данный момент мне кажется он прямой конкурент redis.


  1. lazarus_net
    05.08.2026 15:46


    Интересный разбор, но стоит отметить: проблему single-threaded Redis + RESP-протокола индустрия уже решала несколькими путями, и ни один не потребовал ухода на gRPC.

    Dragonfly — полный C++ рерайт (shared-nothing, файберы, SIMD), держит RESP как есть, масштабируется через многопоточность сервера: заявляют ~3.8М ops/sec на 8 ядрах против ~150K у Redis.

    KeyDB — форк Redis с многопоточным исполнением команд, MVCC для неблокирующих чтений и per-key локами вместо глобального — свыше 1М ops/sec без переписывания движка с нуля.

    Garnet (Microsoft) — тоже RESP-совместим, показывает лучшую масштабируемость по числу клиентских сессий, чем Redis/KeyDB.

    Все они атакуют именно однопоточность сервера, а не транспортный протокол. Переход на gRPC решает другую грань — клиентский connection management (не нужен пул), но добавляет свою цену: сериализация protobuf, HTTP/2 framing, per-stream overhead, потенциальный head-of-line blocking на TCP. Было бы честнее сравнить HurriCache не с голым Redis, а с Dragonfly/KeyDB — иначе неясно, выигрыш от архитектуры или просто от того, что baseline (Redis без io-threads) был не оптимально настроен.


    1. AlexB2012 Автор
      05.08.2026 15:46

      Сравнение абсолютно валидное, но здесь важно разграничить две разные проблемы: производительность самого in-memory движка и масштабирование сетевого транспорта на клиенте и сервере.

      Зачем именно gRPC / HTTP/2?

      Переход на gRPC был продиктован не попыткой обогнать RESP на одном сокете, а необходимостью иметь мультиплексирование. В микросервисных архитектурах при росте числа клиентов (когда тысячам подов/потоков нужно параллельно ходить в кэш) традиционная модель RESP заставляет создавать либо огромные пулы соединений, либо упираться в блокировки. Оверхед от поддержания и переключения десятков тысяч TCP-соединений в Redis/Dragonfly на практике может оказывается трагичным для общей latency системы. HTTP/2 закрывает эту проблему: мы гоняем тысячи параллельных RPC-стримов через один-два TCP-сокета без необходимости держать пулы.

      Бенчмарки в реальных условиях (Non-pipelined workload)

      Заявления в миллионы ops/sec у Dragonfly или KeyDB почти всегда строятся на идеальном pipelining (когда сотни команд пакуются в один TCP-пакет) и огромном количестве сокетов. Однако в реальных сервисах приложения часто делают одиночные неблокирующие запросы (GET/SET) из разных потоков.

      Я ради интереса замерил Dragonfly в честном режиме без пайплайнинга на типичных операциях (32 потока, 100K пар ключей размером около 100 байт - стандартный тест производительности HurriCache). Результат — ~13K RPS. Он действительно ощутимо быстрее классического Redis, но на таком типе нагрузки оказался медленнее HurriCache.

      Архитектура исполнения

      Что касается внутрисерверного масштабирования (shared-nothing, lock-free/per-key locks), HurriCache использует подходы, во многом схожие с теми же KeyDB/Dragonfly (шардирование, локальность данных по ядрам) но как всегда есть свои нюансы.

      gRPC действительно добавляет свою цену на Protobuf и HTTP/2 framing — но в данном случае она оказалась полностью оправданной.


  1. Tyiler
    05.08.2026 15:46

    Приветствую.
    Нагрузка на CPU будет у вас высокая из-за мультиплр-я и grpc, на полезную работу может проца не хватать, по памяти тоже с запасом всегда будете потреблять (но на таких объемах rpc может и ладно).
    Спросил у гугл-ии.

    если посмотреть загрузку cpu и выделение памяти, что увидим?

    Если мы запустим профилировщик (например, perf для CPU и je_prof / valgrind massif для памяти) под максимальной нагрузкой в 85 000+ RPS, то увидим очень специфическую картину. Она наглядно покажет, какую «цену» HurriCache платит за мультиплексирование gRPC.

    Вот детальный расклад того, что отобразится на графиках и в FlameGraph.

    1. Профиль загрузки CPU (Куда уходят такты процессора)

    В отличие от классического Redis, где 80-90% CPU уходит на логику базы данных и простую обработку TCP-сокетов, в HurriCache картина разделится примерно 50/50 или даже 60/40 в пользу сетевого оверхеда.

    Что покажет FlameGraph (топ горячих функций):

    • ~45–55% CPU — Экосистема gRPC и HTTP/2:

      • grpc_core::HeaderTable и функции парсинга HPACK (сжатие/декомпрессия заголовков HTTP/2).

      • grpc_core::chttp2::ParsingState — нарезка потока на фреймы, разбор 9-байтовых заголовков и валидация Stream ID.

      • grpc::protobuf::Parser — десериализация бинарного потока Protobuf в C++ структуры сообщений.

      • Функции управления окнами (Flow Control) и отправки WINDOW_UPDATE.

    • ~30–35% CPU — Кастомная хэш-таблица (Бизнес-логика):

      • Вычисление хэш-функций (например, MurmurHash или CityHash).

      • Сравнение ключей в памяти.

      • Примечание: Строки, связанные с ожиданием памяти (инструкции prefetch), будут занимать минимум времени процессора, так как CPU не уходит в Stall (простой), а молотит полезную работу.

    • ~10–15% CPU — Системные вызовы ядра Linux:

      • epoll_wait, recvmsg, sendmsg — чтение и запись в сетевые сокеты.

    Общий вывод по CPU: Процессор будет загружен «под полку» (близко к 100% на задействованных ядрах). При этом gRPC будет буквально пожирать такты на обслуживание структуры стримов и демультиплексирование, подтверждая ваши опасения.

    2. Выделение и освобождение памяти (Аллокации)

    Если бы автор HurriCache использовал стандартный gRPC и стандартный std::allocator «из коробки», база данных захлебнулась бы в аллокациях (Memory Churn). На каждый из 85 000 запросов в секунду выделялись бы десятки объектов.

    Но поскольку это MVP высоконагруженной системы, профилировщик памяти покажет работу арен памяти (Memory Arenas), о которых автор вскользь упомянул в статье.

    Что покажет профиль аллокаций (malloc / free в секунду):

    • Почти нуль (0) реальных системных вызовов malloc / free во время работы. Если база делает malloc на каждый запрос при 85K RPS — она ложится. Вместо этого мы увидим, что память выделяется крупными блоками (например, по 64 МБ) при старте, а затем переиспользуется.

    • Поведение на уровне gRPC: gRPC под капотом использует механизм grpc_slice. Память под входящие фреймы берется из внутреннего пула буферов и туда же возвращается. Мы увидим интенсивный инкремент/декремент счетчиков ссылок (Reference Counting) на куски памяти, но не реальное выделение у ОС.

    • Поведение на уровне Protobuf: Современный gRPC использует google::protobuf::Arena. Объект запроса создается внутри заранее выделенного куска памяти (арены). После отправки ответа вся арена сбрасывается одной инструкцией (сдвигом указателя) без вызова деструкторов для каждого поля.

    • Кастомная хэш-таблица: На графике Live Memory мы увидим стабильную ровную линию. Так как это flat_hashmap, данные лежат плотно в больших массивах. Перевыделение памяти (rehash) будет происходить только при скачкообразном росте количества ключей.

    Резюме: Что мы увидим в итоге?

    Мы увидим классический паттерн CPU-bound системы.

    1. По памяти: Идеальная «пила» или ровная полка благодаря аренам. Никакого фрагментирования RAM, минимум работы с кучей (Heap).

    2. По процессору: Жуткий оверхед на «сериализацию/демультиплексирование» gRPC, который база может себе позволить только благодаря тому, что сама хэш-таблица за счет prefetch практически не тратит процессорное время на ожидание RAM.

    Если бы мы убрали gRPC и заменили его на простой бинарный протокол поверх TCP без мультиплексирования (но с пайплайном), производительность этой же базы на чтение легко улетела бы за 200 000–300 000+ RPS на одном ядре, так как освободилось бы больше половины CPU.

    gRPC предлагает заменить на свой бинарный протокол, но тогда и мультиплексир самому придется делать, хотя может быть есть уже готовые инструменты для упаковки-распаковки пакетов (но вам надо не просто их рядом друг за другом, а с прореживанием).


    1. AlexB2012 Автор
      05.08.2026 15:46

      Приветствую.

      При нагрузке 85К+ HurriCache потребляет около 20-30% CPU. Я не буду углубляться в архитектуру HurriCache. То что вы описали при помощи гугл ИИ не соответствует действительности и очень далеко от нее.


      1. Tyiler
        05.08.2026 15:46

        ну дай бог. лень самому проверять. а кстати, ссылки не вижу на ваш проект, или код не хотите открывать?


        1. AlexB2012 Автор
          05.08.2026 15:46

          Пока не хочу публиковать серверный код. Клиентским кодом поделиться могу + могу собрать демо докер если заинтересовало