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