
От ClickHouse к RocksDB: как мы перестроили архивный слой Solana
Содержание
- Что такое архивный слой Solana и почему он важен
- ClickHouse: первый прагматичный выбор
- Где ClickHouse перестал справляться
- RocksDB — не база данных
- Как RocksDB устраняет недостатки ClickHouse
- Что мы построили поверх RocksDB
- Оптимизация RocksDB для больших масштабов
- Настраивайте каждый индекс, а не базу данных целиком
- Решите, действительно ли вам нужен WAL
- Подбирайте фильтры Блума с учетом соотношения попаданий и промахов
- Сжимайте то, что хорошо сжимается, а не то, что часто используется
- Рассмотрите прямой ввод-вывод
- Выберите кеш, устойчивый к конкуренции
- Выполняйте поиск по нескольким ключам параллельно, а не последовательно
- К чему мы стремимся
Когда мы рассказываем, что перенесли более 300 терабайт архивных данных Solana с ClickHouse на RocksDB, реакция почти всегда одинакова: Зачем вообще это делать?
ClickHouse — очевидный выбор для аналитических нагрузок петабайтного масштаба, а RocksDB — нет.
ClickHouse доверяют крупнейшие в мире потребители данных.
Например, Cloudflare использует более тысячи его реплик для обработки сотен миллионов вставок в секунду.
Anthropic использует собственное изолированное развертывание ClickHouse для системы наблюдаемости Claude. Uber, eBay и Bloomberg также годами используют его в продакшене.
В свою очередь, RocksDB — это встраиваемое хранилище «ключ — значение» со скудной документацией. Обычно его используют как движок внутри других баз данных (например, CockroachDB, TiKV и MyRocks), а не как непосредственную основу клиентского сервиса исторических данных.
Однако переход сократил объем сжатых данных с ~330 ТБ до ~190 ТБ, устранил длинный хвост самых медленных запросов (например, задержка p95 для вызовов getTransactionsForAddress снизилась с 350 мс до 30 мс) и позволил нам развивать слой чтения Solana. При требуемых Helius производительности и масштабе с ClickHouse это было бы невозможно.
В этой статье мы расскажем, почему перешли с ClickHouse, что узнали о нагрузке в процессе и как используем RocksDB в продакшене в большом масштабе.
Что такое архивный слой Solana и почему он важен
По своей сути Solana — это сеть нод, которые обмениваются данными, чтобы согласовывать новую информацию без необходимости доверять друг другу.
Нода — это компьютер в сети Solana, который запускает клиент (например, Agave или Firedancer). Клиент следует определенному набору правил и помогает согласовывать новую информацию.
Валидатор — это нода Solana, которая защищает сеть, создавая блоки (то есть добавляя группы транзакций в реестр Solana) и голосуя за достоверность других блоков.
RPC-ноды — это валидаторы, которые не участвуют в создании блоков и голосовании. Вместо этого они наблюдают за сетью и отслеживают всю новую информацию, которую она создает. RPC позволяют пользователям запрашивать конкретные данные сети с помощью спецификации JSON-RPC.
Однако эти ноды не хранят данные вечно.
Чтобы соответствовать требованиям Solana к оборудованию, старые блоки, транзакции и состояния аккаунтов удаляются. Поэтому ноды сохраняют только самое актуальное представление истории Solana.
Это создает проблемы, когда нужно найти подпись транзакции годичной давности, получить все подписи, когда-либо затрагивавшие определенный кошелек, или просканировать полную историю выполнения программы.
В широком смысле архивный слой — это весь уровень, который хранит данные Solana с момента создания генезис-блока. Он регистрирует и индексирует все, что создает блокчейн: каждый блок, транзакцию, взаимодействие с аккаунтом, межпрограммный вызов (CPI) и журнал транзакции. Эти данные остаются доступными для запросов за пределами стандартного окна краткосрочного хранения длительностью ~2 дня (то есть 1 эпоха).
Именно архивный слой делает возможными запросы исторических данных.
Стандартным решением для хранения архивных данных в Solana была Google BigTable. Anza поддерживает экземпляр BigTable, к которому поставщики RPC могут обращаться по запросу для выполнения исторических запросов.
Это работает, но BigTable обходится дорого, а стоимость исходящего трафика усложняет запуск собственной копии. Кроме того, со своей стороны почти невозможно ускорить систему инженерными средствами: вы зависите от хранилища Google, получаете всю его структуру затрат и никакого контроля.
Old Faithful — это набор инструментов, поддерживаемый Triton One. Он позволяет создавать архивы с адресацией по содержимому (CAR) из архивов реестра RocksDB и предоставлять их через стандартные интерфейсы RPC и gRPC Solana. Это важный шаг к децентрализации архивного слоя Solana и ценный источник резервной, проверяемой истории.
Однако компромиссы в удобстве разработки и производительности не делают его полноценной альтернативой для чувствительных к задержке нагрузок. Например, для начала работы нужны специальные инструменты вместо интерфейсов, на основе которых команды могли бы вести разработку, а сам проект оптимизирован для надежного архивирования, а не производительности и масштабирования.
Основная проблема архивного слоя — N петабайт необработанных данных. Как обеспечить поиск быстрее 10 мс при более чем 500 миллиардах транзакций, ~1,3 триллиона строк индексов «аккаунт — транзакция» и случайных шаблонах доступа? Как должны выглядеть сквозные запросы быстрее 50 мс? Ни Google BigTable, ни Old Faithful не решают эту задачу.
Кроме того, если вы захотите создать собственные функции фильтрации и сортировки или улучшить опыт разработчиков, предложив нечто большее, чем стандартные методы JSON-RPC, вам понадобится собственный архивный индекс.
Поэтому мы его создали.
ClickHouse: первый прагматичный выбор
ClickHouse был самым прагматичным вариантом для начала разработки новой архивной системы. Это был самый быстрый путь к выпуску продукта, который уже превосходил решение на базе BigTable.
ClickHouse — зрелая колоночная база данных с отличными инструментами и документацией. Она хорошо сжимает данные временных рядов, что особенно полезно для Solana, поскольку данные блоков изначально упорядочены по времени.
Создать базу данных, писать для нее SQL-запросы, дорабатывать схемы и оптимизировать время выхода на рынок довольно просто. Поэтому клиенты относительно быстро получают готовое решение.
ClickHouse обслуживал трафик не в одиночку. Он был нижним уровнем нашего стека хранения, организованного по давности данных.
Самые свежие данные — примерно за последнюю минуту или две либо несколько сотен слотов — находились в хранилище в памяти. Данные примерно за последние две недели хранились в Postgres. ClickHouse содержал остальную историю Solana.
Перед тремя источниками находился интеллектуальный маршрутизатор. Он направлял точечные запросы на самый горячий уровень с нужными данными, а диапазонные запросы разделял между уровнями и объединял ответы в единый результат.
ClickHouse довольно хорошо справлялся с нагрузками, для которых был разработан. Например: «выдай все блоки в этом диапазоне слотов» или «просканируй активность этого аккаунта за определенный период».
С запросами временных рядов проблем не было. Мы могли загружать исторические данные, выполнять разовые аналитические запросы и отвечать на большинство вопросов, традиционно возникавших у поставщиков RPC. Прием данных также не вызывал затруднений: через LaserStream мы записывали в индекс всего ~10 МБ/с.
Ошибка заключалась не в самом выборе ClickHouse. Мы ошибочно предположили, что наша нагрузка сохранит форму, с которой ClickHouse сможет справляться в большом масштабе.
Где ClickHouse перестал справляться
Не все интересующие нас методы для исторических данных Solana относятся к временным рядам. Подпись в Solana, то есть универсальный идентификатор транзакции, по сути представляет собой 64 байта случайных данных.
Когда кто-то вызывает getTransaction для определенной подписи, выполняется равномерно случайный точечный поиск. Здесь нельзя использовать локальность данных: нет ни слота, ни временного диапазона.
То же относится и к другим запросам, например «получить все подписи, затрагивавшие этот аккаунт», поскольку ключ аккаунта тоже фактически случаен. Эта проблема возникает всегда, когда первичный ключ — это UUID, хеш или другой идентификатор с высокой энтропией.
Колоночные движки не предназначены для решения этой задачи.
ClickHouse хранит данные в отсортированных частях и использует разреженные первичные индексы. Поэтому при поиске по случайному ключу отсеять почти ничего не удается. В итоге приходится читать больше гранул, чем хотелось бы. Поиск одной подписи мог затронуть 20 гранул — около 10 000 строк и ~60 дисковых операций ввода-вывода — еще до двоичного поиска и учета накладных расходов CPU.
Только операции ввода-вывода занимают около 5 мс на один поиск. Поскольку хранилище колоночное, для чтения одной транзакции нужно прочитать ~15 столбцов отдельными операциями ввода-вывода в грануле из 512 строк.
Это означает, что вызов getTransactionsForAddress, запрашивающий полные сведения о транзакциях, может разветвляться до 100 таких поисков. В результате минимальная задержка приближается к 100 мс еще до сериализации первого байта.
Большой пакет getTransaction, содержащий до 1 000 элементов, увеличивал этот минимум почти до секунды.
Абстракции, ускоряющие сканирование, например векторизованное выполнение и поздняя материализация, не решают проблему полностью. Поиски уже были оптимизированы практически до предела. Нам не недоставало индекса, и добавить подходящую проекцию было невозможно.
Проще говоря, фундаментальная структура данных не подходила для поставленной задачи.
Самые ресурсоемкие добавленные нами методы, например getTransactionsForAddress и getTransfersByAddress, использовали шаблоны случайных ключей, с которыми ClickHouse справлялся хуже всего.
Некоторые запросы занимали 2–3 секунды, хотя должны были выполняться за миллисекунды. Мы получали оповещения о снижении производительности нагрузок, которые принципиально не могли исправить.
Учитывая, что учреждения и корпоративные клиенты используют Helius в большом масштабе, в долгосрочной перспективе это совершенно неприемлемо.
Масштабирование ClickHouse для такой нагрузки требовало увеличивать количество серверов, а в нашем масштабе — многократно дублировать нагрузку. Одни лишь дополнительные расходы на оборудование не решили бы проблему.
Стек RPC Solana развивается. Экосистема достигла переломного момента, когда стандартных методов JSON-RPC уже недостаточно. Клиентам нужны широкие возможности для запросов исторических данных, и никто другой не собирался создавать их поверх BigTable.
Чтобы продвинуть эту границу, нам нужно было изменить слой хранения.
RocksDB — не база данных
Несмотря на название, RocksDB — не база данных, а библиотека.
В ней нет языка запросов, клиент-серверного протокола, движка SQL, объединений или индексов в традиционном смысле. Ее интерфейс сводится к следующему: «Вот ключ — набор байтов. Вот значение — тоже набор байтов. Сохрани их на диске, а позднее верни значение для этого ключа». И всё.
Это примитив. Возможно, главный примитив для создания движков хранения.
Всё, что ожидается от традиционной базы данных, например планировщик запросов, сетевой протокол, репликацию и наблюдаемость, нужно создавать самостоятельно.
Это кажется недостатком, пока не понимаешь, какие возможности он дает. Чистое хранилище «ключ — значение» на основе дискового дерева Log-Structured Merge (LSM) идеально подходит для высокопроизводительного поиска по равномерно случайным ключам. Здесь нет планировщика запросов с дополнительными накладными расходами, предположений колоночной структуры, с которыми нужно считаться, или SQL-интерфейса, на который нужно выделять ресурсы.
Вы сохраняете байты, считываете их обратно и размещаете фильтры Блума и кеши там, где они удешевляют случайное чтение.
Именно здесь представление такого подхода как антипаттерна перестает работать.
Мы не заменили ClickHouse на RocksDB. Мы заменили ClickHouse собственной базой данных, движком хранения которой служит RocksDB. Это существенно разные вещи.
Кроме того, по тому же принципу устроено большинство промышленных баз данных. Например, CockroachDB, TiKV, MyRocks и хранилища состояния Kafka Streams построены поверх RocksDB.
Разница в том, что они сначала оборачивают RocksDB в другую базу данных, а мы сами построили базу вокруг нее и оптимизировали под конкретные шаблоны доступа к архиву.
Как RocksDB устраняет недостатки ClickHouse
Как именно хранилище «ключ — значение» решает проблему случайных ключей, с которой не справился колоночный движок? Всё определяется способом хранения байтов на диске.
RocksDB использует уровневое уплотнение. Новые записи попадают на уровень 0 — в неотсортированную область, которая фактически соответствует ситуации в ClickHouse, где части раздела не отсортированы глобально.
Однако уровни с 1 по N представляют собой полностью отсортированные последовательности. После уплотнения данных метаданные min/max действительно сокращают область поиска даже для равномерно случайных ключей, поскольку весь уровень упорядочен глобально. В определенном смысле всё в ClickHouse ведет себя как уровень 0 RocksDB.
Мы используем это при загрузке исторических данных.
При создании индекса подписей мы заранее сортируем всю историю и загружаем ее непосредственно на нижний уровень. После этого на уровень 0 попадают только свежие данные, которые быстро сливаются с нижними уровнями.
В результате почти каждый поиск читает одну отсортированную последовательность. Фактически мы отсортировали случайные данные с помощью RocksDB.
Что мы построили поверх RocksDB
ClickHouse предлагает многое из коробки: движок запросов, сетевой протокол, клиент и механизм предоставления данных. В RocksDB ничего этого нет — она лишь сохраняет байты. Всё остальное нам пришлось создать самостоятельно.
Так мы построили поверх RocksDB собственную базу данных, специализированную для шаблонов архивного доступа Solana.
Сегодня в RocksDB находятся два индекса:
- Подпись -> Расположение (то есть индекс подписей, сопоставляющий 64-байтовую подпись транзакции со слотом и позицией внутри блока).
- Слот -> Блок (то есть индекс соответствия слотов блокам, сопоставляющий указанное выше расположение с данными транзакции).
Важно, что прямого пути от подписи к данным транзакции нет. Именно для обхода этого ограничения и существуют два индекса.
Подпись по сути представляет собой случайный 64-байтовый идентификатор. Фактическое расположение транзакции определяют ее слот и индекс внутри блока. Поэтому для получения транзакции нужны два перехода: сначала от подписи к расположению, а затем от расположения к сведениям о транзакции. Для получения блока достаточно одного перехода, поскольку вызывающая сторона уже передает слот.
Вместе эти индексы обеспечивают работу некоторых самых ресурсоемких методов Helius, например getBlock, getTransaction и getTransactionsForAddress, особенно когда details имеют значение full.
Путь чтения во многом выглядит так же, как в ClickHouse. Поступает запрос, наш внутренний клиент обращается к базе данных и получает результат. Меняется лишь нижележащий движок. Каждый метод использует жестко заданный и вручную оптимизированный путь. Когда пользователь запрашивает транзакцию, специализированный путь getTransaction ведет прямо к байтам.
Этот путь работает быстро, потому что мы контролируем каждый его уровень. Мы используем io_uring для файлового и сетевого ввода-вывода при потоковой передаче данных из RocksDB. Если данные хранятся на диске без сжатия, мы копируем их прямо с диска на сетевую карту, не проводя через пространство пользователя.
Методы заданы жестко, ввод-вывод оптимизирован от начала до конца, а между ними нет ничего лишнего.
Результат хорошо виден в продакшене.
Недавно мы пять-шесть часов подряд поддерживали трафик getBlock на уровне 150 Гбит/с, почти полностью загружая большинство сетевых карт. Никаких проблем и оповещений — только стабильная производительность.
По всем показателям:
- Объем сжатых данных сократился почти вдвое: с ~330 ТБ до ~190 ТБ
- Задержка p95 для вызовов
getTransactionснизилась с 7 мс до 1 мс - Задержка p95 для вызовов
getTransactionsForAddressснизилась с 350 мс до 30 мс - Задержка p95 для вызовов
getBlockснизилась с 50 мс до 35 мс
Как и ожидалось, вызовы getBlock ускорились меньше всего. ClickHouse уже хранит транзакции блоками по 512 строк, что амортизирует издержки колоночного формата при чтении данных размером с блок. Значительная часть сквозного времени getBlock уходит на кодирование Base58 и JSON, а также на повторную сборку блока. Поэтому замена движка хранения не ускорит этот процесс.
Более глубокая история о том, как мы заставили io_uring, асинхронный Rust и синхронную библиотеку RocksDB работать вместе при высокопроизводительной сетевой нагрузке, заслуживает отдельной будущей статьи.
Однако в следующем разделе мы рассмотрим несколько оптимизаций, которые оказались полезными при работе с RocksDB.
Оптимизация RocksDB для больших масштабов
Не существует единственной «быстрой» конфигурации RocksDB. Правильные настройки полностью зависят от шаблона доступа оптимизируемого индекса.
Наш индекс «подпись -> расположение» и индекс «слот -> блок» работают в одном процессе на одном оборудовании, но требуют почти диаметрально противоположной настройки.
Поэтому вместо файла конфигурации с параметрами, которые всё равно могут не подойти для вашей нагрузки, в этом разделе мы расскажем, как анализируем универсальные компромиссы.
Настраивайте каждый индекс, а не базу данных целиком
Каждый наш индекс представляет собой отдельное семейство столбцов со своими параметрами. Один выполняет равномерно случайные точечные поиски, другой последовательно извлекает большие сжимаемые значения. Одинаковый подход к ним лишил бы нас множества возможностей оптимизации. Почти каждое приведенное ниже решение следует читать так: «для этого шаблона доступа сделайте X».
Решите, действительно ли вам нужен WAL
Архивные данные можно восстановить. Они поступают потоком из LaserStream и формируются на основе самого блокчейна.
Мы выполняем запись с отключенным журналом упреждающей записи (WAL), чтобы не платить за ненужную нам надежность журнала.
Тонкость в том, что отключение WAL также отключает стандартную согласованность RocksDB между семействами столбцов при сбое. Поэтому согласованность нужно восстановить другим способом. Главный вывод: настройки надежности должны соответствовать возможности восстановления данных. Данные, которые можно восстановить из вышестоящего источника, требуют совсем иного подхода, чем система учета.
Подбирайте фильтры Блума с учетом соотношения попаданий и промахов
Фильтры Блума оправдывают занимаемую память, позволяя дешево определить отсутствие ключа при поиске. То есть они помогают только при промахах.
Поиск подписи почти всегда заканчивается попаданием, поскольку у вызывающей стороны есть подпись и ей нужны соответствующие данные транзакции.
На самом нижнем уровне LSM хранится подавляющее большинство наших данных. Если основная масса данных находится на одном уровне, а в нагрузке преобладают попадания, фильтры этого уровня потребляют больше всего памяти и выполняют меньше всего полезной работы. В такой ситуации стоит задуматься, нужны ли они там вообще.
Сжимайте то, что хорошо сжимается, а не то, что часто используется
Решение о сжатии принимается отдельно для каждого индекса. Данные с высокой энтропией, например 64-байтовая подпись, не сжимаются с коэффициентом ниже 1,0. Поэтому сжатие лишь добавило бы затраты на распаковку в горячий путь каждого поиска. Вот почему мы задаем для сжатия значение None.
Данные блоков, напротив, хорошо сжимаются, поскольку они объемнее и содержат больше повторений. Поэтому наш индекс «слот -> блок» использует zstd. Оба индекса находятся в одной базе данных, но выигрывают от разных решений. Выбор полностью определяется тем, поддаются ли байты сжатию и что ограничивает индекс — задержка или объем хранилища.
Такое разделение по индексам во многом позволило нам сократить объем данных с ~330 ТБ до ~190 ТБ без ухудшения задержки точечного поиска.
Рассмотрите прямой ввод-вывод
Мы используем прямой ввод-вывод для чтения, сброса и уплотнения. В таком масштабе кеш страниц ОС конкурирует с нашим собственным блочным кешем за одну и ту же оперативную память. Для случайных точечных поисков такое двойное кеширование в основном бесполезно. Мы предпочитаем один большой блочный кеш, который сами контролируем и который обеспечивает предсказуемую задержку.
Учтите, что всё зависит от нагрузки:
Прямой ввод-вывод может снизить производительность конфигураций с интенсивным сканированием или недостатком ресурсов. Поэтому его стоит тестировать методом A/B, а не внедрять вслепую.
Выберите кеш, устойчивый к конкуренции
При высокой параллельной нагрузке на горячие ключи стандартный общий кеш LRU становится узким местом из-за конкуренции за блокировки. Для горячих индексов мы используем HyperClockCache из RocksDB. Он гораздо лучше справляется с ситуациями, когда множество потоков одновременно обращаются к одним и тем же популярным записям.
Выполняйте поиск по нескольким ключам параллельно, а не последовательно
Вместо последовательного выполнения N операций чтения RocksDB может через io_uring одновременно запускать множество операций ввода-вывода и завершать их параллельно. Для такого метода, как getTransactionsForAddress, который разветвляется на множество точечных поисков, это определяет, будет ли задержка расти с количеством ключей или останется примерно на одном уровне.
RocksDB предоставляет все возможности, но не принимает решений за вас. Наши улучшения основаны на понимании шаблонов доступа и адаптации каждого индекса к его особенностям, а не на поиске единой глобальной конфигурации, одинаково хорошей для всего.
К чему мы стремимся
Сегодня архивный слой работает в наших крупных регионах, таких как EWR, FRA и Tokyo. Теперь движок хранения выполняет поиски за микросекунды и полностью загружает сетевые карты. База данных больше не будит нас по ночам, но устранение одного узкого места неизбежно выявляет следующее.
Когда поиск становится практически бесплатным, основные затраты смещаются с программного обеспечения на расстояние между пользователем и сервером. Запросу всё равно нужно добраться от пользователя до нашего бэкенда и вернуться обратно. Здесь приходится бороться с законами физики.
Именно эту проблему решает Gatekeeper.
Gatekeeper — наш собственный пограничный шлюз, написанный на Rust поверх Hyper. Он завершает соединения рядом с пользователями и направляет каждый запрос к нашему бэкенду по кратчайшему доступному пути. Теперь борьба за минимальную задержку идет именно здесь: пулы соединений, настройка TLS и сокетов, маршрутизация с учетом близости и состояния, а также развертывания без простоя по всей глобальной инфраструктуре. Всё это сокращает на миллисекунды путь к байтам, которые архивный слой уже предоставляет за микросекунды.
Ускорить один запрос — задача базы данных. Ускорить каждый запрос из любой точки мира без окон обслуживания и разрывов соединений — совершенно другая задача. Именно на ней мы сосредоточимся дальше.
Именно это мы понимаем под развитием слоя чтения Solana: нужно правильно реализовать каждый уровень — от движка хранения, отвечающего на запросы, до пограничного шлюза, определяющего, как быстро ответ достигнет конечного пользователя.
В конечном счете архивный слой сводится к точечному поиску по случайным ключам. Именно этот шаблон доступа по структурным причинам выводит колоночный движок из строя. Дело не в настройке, шардинге или сортировке представлений. Ограничения ClickHouse, с которыми мы столкнулись, присущи самому выбору технологии, а не конкретной конфигурации. Нагрузку исторических данных Solana определяет самый сложный шаблон доступа, а не самый простой.
В масштабе сети, которая стремится стать расчетным слоем мировой финансовой системы, слой чтения не может быть просто лучше настроенной версией решения, которое мы уже переросли. Учреждениям и приложениям, зависящим от расширенных запросов исторических данных, нужны методы, охват и задержки, недоступные стандартному стеку. Слой чтения Solana с самого начала должен строиться на фундаменте, подходящем для самого сложного сценария. Именно такую ставку мы сделали на RocksDB и по этому стандарту оцениваем всё остальное.
Если вы хотите создавать будущее финансов в большом масштабе, присоединяйтесь к нам. Solana стремится стать расчетным слоем мировой финансовой системы, а архивный слой — лишь одна часть гораздо более масштабной задачи. Если вас интересуют уплотнение LSM, индивидуальная настройка индексов или решение сложных системных задач на одном из лучших доступных аппаратных комплексов, вам у нас понравится.
Мы расширяем всю инженерную команду. Все открытые вакансии доступны на helius.dev/careers.
Похожие статьи
Подпишитесь на Helius
Следите за новостями разработки на Solana и получайте уведомления о наших публикациях


