新着:HeliusがLight Protocolを買収
ClickHouseからRocksDBへ:Solanaのアーカイブ層を再構築した方法
ブログ/エンジニアリング

ClickHouseからRocksDBへ:Solanaのアーカイブ層を再構築した方法

Developer Experience EngineerXの0xIchigoLinkedInの0xIchigoGitHubの0xIchigo
ソフトウェアエンジニアLinkedInのConnor Peticca
読了時間:19分

300テラバイトを超えるSolanaのアーカイブデータをClickHouseからRocksDBへ移行したと話すと、ほぼ必ず同じ反応が返ってきます。なぜそんなことをするのですか?

ペタバイト規模の分析ワークロードなら、ClickHouseが当然の選択肢であり、RocksDBはそうではありません。

ClickHouseは、世界最大級のデータ利用企業から信頼されています。 

たとえば、Cloudflareは毎秒数億件の挿入を処理するため、1,000を超えるレプリカでClickHouseを運用しています。

Anthropicは、Claudeのオブザーバビリティを支えるために、カスタムのエアギャップ構成でClickHouseを運用しています。Uber、eBay、Bloombergも、長年にわたって本番環境で利用しています。

一方、RocksDBはドキュメントが少ない組み込み型キーバリューストアで、顧客向け履歴データサービスの直接的な基盤ではなく、主に他のデータベース(CockroachDB、TiKV、MyRocksなど)の内部エンジンとして使われています。

しかし、この移行により圧縮後のストレージ使用量は~330TBから~190TBに減少し、最も遅いクエリのロングテールも解消しました(たとえば、getTransactionsForAddress呼び出しのp95レイテンシは350msから30msに低下しました)。さらに、Heliusに求められる性能と規模ではClickHouseで実現できなかった、Solanaの読み取り層を革新できる体制が整いました。

この記事では、ClickHouseから移行した理由、その過程でワークロードについて学んだこと、そしてRocksDBを大規模な本番環境でどのように使用しているかを解説します。 

Solanaアーカイブとは何か、なぜ重要なのか

Solanaの本質は、互いを信頼することなく通信し、新しい情報について合意するノードのネットワークです。

ノードとは、特定のルールに従うクライアント(Agave、Firedancerなど)を実行し、新しい情報に関する合意の形成を支援するSolana上のコンピューターです。 

バリデータは、ブロックを生成し(つまり、トランザクションのグループをSolanaの台帳に追加し)、他のブロックの有効性に投票することでネットワークを保護するSolanaノードです。 

RPCノードは、ブロック生成や投票に参加しないバリデータです。代わりにネットワークを監視し、生成されるすべての新しい情報を追跡します。RPCを利用すると、ユーザーはJSON-RPC仕様に基づいてネットワーク上の特定のデータを照会できます。 

ただし、これらのノードがデータを永続的に保持するわけではありません。 

Solanaのハードウェア要件内に収めるため、古いブロック、トランザクション、アカウント状態は削除され、ノードにはSolanaの履歴のうち最新の状態だけが残ります。 

そのため、1年前のトランザクション署名を検索したり、特定のウォレットにこれまで関係したすべての署名を取得したり、プログラムの全実行履歴をスキャンしたりする際に問題が生じます。

アーカイブとは広義には、ジェネシス以降のSolanaデータを保存する層全体を指します。チェーンが生成するすべてのブロック、トランザクション、アカウント操作、Cross Program Invocation(CPI)、トランザクションログを記録してインデックス化し、標準の短期保存期間である~2日間(つまり1エポック)を超えて照会できる状態に保ちます。 

履歴クエリを可能にするのがアーカイブです。

Solanaでアーカイブデータを保存する標準的な方法は、Google BigTableでした。Anzaは、RPCプロバイダーが必要に応じてアクセスし、履歴クエリを処理できるBigTableインスタンスを維持しています。

機能はしますが、BigTableは高額です。エグレスコストのため独自コピーの運用も負担が大きく、こちらで高速化するためにできるエンジニアリング上の工夫もほとんどありません。Googleのストレージに依存し、そのコスト構造をすべて受け入れる一方で、制御権は得られません。 

Old FaithfulはTriton Oneが管理するツール群で、台帳のRocksDBアーカイブからContent Addressable Archives(CAR)を生成し、Solanaの標準RPCおよびgRPCインターフェース経由で提供できます。冗長で検証可能な履歴を提供し、Solanaのアーカイブ層を分散化するうえで重要な一歩となっています。

しかし、開発者体験と性能に関するトレードオフがあります。たとえば、利用開始にはチームがそのまま開発に使えるインターフェースではなくカスタムツールが必要であり、性能やスケールよりも永続的なアーカイブに最適化されています。そのため、レイテンシが重視されるワークロードの有力な代替手段にはなりません。 

アーカイブの根本的な問題は、Nペタバイトもの生データが存在することです。5,000億件を超えるトランザクション、~1.3兆行のアカウント対トランザクションインデックス、ランダムアクセスパターンがある中で、どうすれば10ms未満のルックアップを実現できるのでしょうか。エンドツーエンドで50ms未満のクエリとはどのようなものでしょうか。Google BigTableもOld Faithfulも、この問題に対する解決策を提供できていません。

さらに、カスタムのフィルタリングや並べ替えオプションを構築したり、標準のJSON-RPCメソッドより高度な機能を提供して開発者体験を改善したりするには、独自のアーカイブインデックスが必要です。 

そこで、私たちは独自に構築しました。

ClickHouse:現実的な最初の選択

新しいアーカイブシステムの構築を始めるうえで、ClickHouseは最も現実的な選択肢でした。BigTable上に構築するより優れたプロダクトを、最短でリリースできる方法だったからです。

ClickHouseは、優れたツールとドキュメントを備えた成熟したカラム指向データベースです。時系列データの圧縮に優れており、ブロックデータが本質的に時系列順であるSolanaには特に有用です。

データベースの作成、SQLの記述、スキーマの反復改善が容易で、市場投入までの時間を最適化できるため、比較的短期間で顧客にプロダクトを提供できます。

ClickHouseだけですべてのトラフィックを処理していたわけではありません。データの新しさに応じて構成したストレージスタックの最下層として機能していました。 

最新のデータ(おおよそ直近1〜2分、または数百スロット)はインメモリストアに格納していました。直近約2週間分のデータはPostgresに、それ以前のSolanaの履歴はClickHouseに格納していました。

3つのデータソースの前段にはスマートルーターを配置し、ポイントルックアップは対象データを持つ最もホットな層に送り、範囲クエリは複数の層に分割したうえで単一の結果に統合していました。

ClickHouseは、設計対象となったワークロードでは比較的良好に機能しました。たとえば、「このスロット範囲内の全ブロックを取得する」や「このアカウントのアクティビティを時系列でスキャンする」といった処理です。 

時系列クエリには問題がありませんでした。バックフィルやアドホック分析を実行でき、RPCプロバイダーが従来必要としてきた質問の大半に答えられました。取り込み側にもまったく負荷はかかっていませんでした。LaserStream経由でインデックスに書き込んでいたのは、わずか~10MB/sだったためです。

ClickHouseを選んだこと自体が誤りだったわけではありません。誤りは、ワークロードがClickHouseで大規模に処理できる形のまま推移すると想定したことでした。 

ClickHouseが限界に達した理由

私たちが重視するSolanaの履歴メソッドは、すべてが時系列ではありません。Solanaの署名(つまり、汎用的なトランザクション識別子)は、実質的に64バイトのランダムデータです。 

特定の署名に対してgetTransactionが呼び出されると、均一にランダムなポイントルックアップになります。活用できるスロット、時間範囲、局所性はありません。 

「このアカウントに関係したすべての署名を取得する」といった他のクエリも同様です。アカウントキーも事実上ランダムだからです。この問題は、主キーがUUID、ハッシュ、またはその他の高エントロピー識別子である場合に必ず発生します。

カラム指向エンジンは、この問題を解決するために設計されていません。 

ClickHouseはデータをソート済みパーツに保存し、疎なプライマリインデックスを使用します。そのため、ランダムキーのルックアップでは枝刈りできる部分がほとんどありません。最終的には、望ましい数より多くのグラニュールを読み取ることになります。1回の署名ルックアップで、バイナリ検索やCPUのオーバーヘッドが発生する前に、20個のグラニュール、つまり約10,000行と~60回のディスクI/Oにアクセスする可能性があります。 

I/Oだけでも、1回のルックアップに約5msかかります。カラム指向ストアなので、1件のトランザクションを読み取るには、512行のグラニュール全体にわたり~15列を個別のI/Oとして読み取る必要があります。

つまり、たとえば完全なトランザクション詳細を要求するgetTransactionsForAddress呼び出しは、このルックアップを最大100件に展開します。その結果、1バイトもシリアライズする前から、レイテンシの下限は100ms近くになります。 

大規模なgetTransactionバッチ(最大1,000件)では、この下限が1秒近くまで上昇しました。

スキャンを高速化する抽象化(ベクトル化実行、遅延マテリアライゼーションなど)でも、この問題を完全には解決できません。ルックアップはすでに限界まで最適化されていました。インデックスが不足していたわけでも、追加できるプロジェクションがあったわけでもありません。 

端的に言えば、要求していた処理に対して、根本的なデータレイアウトが不適切だったのです。

追加したメソッドのうち最もコストが高いもの(getTransactionsForAddress、getTransfersByAddressなど)は、ClickHouseが最も不得意とするランダムキーパターンでした。 

本来なら数ミリ秒で完了すべきクエリの一部に、2〜3秒かかっていました。根本的に修正できないワークロードの性能低下によって、呼び出しを受ける状態になっていました。 

機関やエンタープライズ顧客がHeliusを大規模に利用していることを考えると、長期的には到底容認できません。

このワークロードに対応するためClickHouseをスケールするには、サーバー台数を増やす必要がありました。私たちの規模では、ワークロードも数倍に増幅されます。ハードウェアに資金を投入するだけでは、問題を解決できませんでした。

SolanaのRPCスタックは成熟しつつあります。エコシステムは、標準のJSON-RPCメソッドだけでは不十分となる転換点に達しました。顧客は高度な履歴クエリを求めていますが、BigTable上にそれを構築しようとする企業はほかにありませんでした。 

この最前線を押し広げるには、ストレージ層を変える必要がありました。 

RocksDB:データベースではないもの

名前に反して、RocksDBはデータベースではありません。ライブラリです。

従来のデータベースにあるようなクエリ言語、クライアント/サーバープロトコル、SQLエンジン、結合、インデックスはありません。「これがキー(バイト列)、これが値(同じくバイト列)なので、ディスクに永続化し、後でこのキーに対応する値を返す」というインターフェースです。それだけです。

これはプリミティブです。ストレージエンジンを構築するための、まさに究極のプリミティブだとも言えます。 

従来のデータベースに期待されるすべての機能(クエリプランナー、ワイヤープロトコル、レプリケーション、オブザーバビリティなど)を構築する必要があります。

一見すると欠点ですが、それによって得られるものを理解すれば見方が変わります。ディスク上のLog-Structured Merge(LSM)ツリーに支えられた純粋なキーバリューストアは、高スループットで均一にランダムなキールックアップを処理するのに最適な構造です。オーバーヘッドを加えるクエリプランナーも、調整が必要なカラム指向レイアウトの前提も、コストを見込むべきSQLフロントエンドもありません。

バイト列を永続化して読み戻し、ブルームフィルターとキャッシュを適切な場所に配置することで、ランダム読み取りのコストを抑えられます。

まさにここで、アンチパターンという捉え方が崩れます。 

ClickHouseをRocksDBに置き換えたのではありません。ClickHouseを、RocksDBをストレージエンジンとするカスタムデータベースに置き換えたのです。この違いには大きな意味があります。

これは、多くの本番用データベースが内部で採用しているパターンでもあります。たとえばCockroachDB、TiKV、MyRocks、Kafka Streamsのステートストアは、すべてRocksDB上に構築されています。

違いは、これらがまず別のデータベースでRocksDBをラップするのに対し、私たちはRocksDBを中心にデータベースを自ら構築し、アーカイブで必要となる正確なアクセスパターンに合わせて調整したことです。

RocksDBがClickHouseの欠点を解消する仕組み

では、カラム指向エンジンでは解決できなかったランダムキーの問題を、キーバリューストアは具体的にどのように解決するのでしょうか。鍵となるのは、バイト列をディスク上に保存する方法です。

RocksDBはレベル型コンパクションを使用します。新しい書き込みはレベル0に格納されます。ここは未ソートの一時置き場であり、パーティション内のパーツが全体としてソートされていないという点では、実質的にClickHouseと同じ状態です。 

しかし、レベル1からNまでは完全にソートされたランです。データがコンパクションされると、レベル全体が順序付けられるため、均一にランダムなキーであっても、最小値/最大値のメタデータによって検索範囲を実際に枝刈りできます。ある意味、ClickHouse内のすべてがRocksDBのレベル0のように動作します。

私たちはバックフィルでこの特性を活用しています。 

署名インデックスを構築する際は、履歴全体を事前にソートし、最下位レベルへ直接読み込みます。それ以降は新しいデータだけがレベル0に格納され、すぐに下位レベルへマージされます。 

その結果、ほぼすべてのルックアップが単一のソート済みランから読み取られます。RocksDBによってランダムデータを実質的にソートしたことになります。

その上に構築したもの

ClickHouseは、クエリエンジン、ワイヤープロトコル、クライアント、データ提供機能など、多くの機能を標準で備えています。RocksDBにはこれらがなく、単にバイト列を永続化するだけです。それ以外はすべて、私たちが構築する必要がありました。

そこで、Solanaアーカイブのアクセスパターンに特化した独自データベースをRocksDB上に構築しました。

現在、RocksDBには2つのインデックスがあります。

  1. 署名 -> 場所(署名インデックス。トランザクションの64バイト署名を、そのトランザクションがブロック内に存在するスロットと位置にマッピングします)。
  2. スロット -> ブロック(スロット対ブロックインデックス。上記の場所をトランザクションデータにマッピングします)。

重要なのは、署名からトランザクションデータへ直接たどる経路がないことです。この制約に対応するために、2つのインデックスが存在します。 

署名は、実質的にランダムな64バイトの識別子です。トランザクションの実際の場所を示すのは、そのスロットとブロック内のインデックスです。そのため、トランザクションを取得するには2段階のアクセスが必要です。1回目で署名から場所を特定し、2回目でその場所からトランザクションの詳細を取得します。一方、ブロックの取得は、呼び出し元がすでにスロットを指定しているため、1段階だけで済みます。

これらのインデックスは連携して、Heliusが提供する中でも特に負荷の高いメソッドを支えています。たとえば、getBlock、getTransaction、getTransactionsForAddressなどです。特にdetailsがfullに設定されている場合です。

読み取りパスは、ClickHouseのときとほぼ同じです。リクエストを受信すると、内部クライアントがデータベースを呼び出し、結果が返されます。変わるのは、その下で動くエンジンです。各メソッドには、ハードコードされ、個別に調整されたパスがあります。ユーザーがトランザクションを照会すると、専用のgetTransactionパスがバイト列へ直接アクセスします。

すべての層を制御しているため、このパスは高速です。ファイルおよびネットワークI/Oにはio_uringを使用し、RocksDBからデータをストリーミングします。データがディスク上に非圧縮で保存されている場合は、ユーザー空間を経由せず、ディスクからネットワークカードへ直接コピーします。

メソッドはハードコードされ、I/Oはエンドツーエンドで調整されており、その間に不要な要素は一切ありません。

その効果は本番環境で明確に表れています。 

最近では、ネットワークカードの大半を飽和させながら、150 Gbit/sのgetBlockトラフィックを5〜6時間連続で処理しました。問題も呼び出しも一切なく、同じ生の性能を維持しました。 

全体では、次の成果が得られました。

  • 圧縮後のストレージを~330TBから~190TBへ、約半分に削減
  • getTransaction呼び出しのP95レイテンシを7msから1msへ短縮
  • getTransactionsForAddress呼び出しのP95レイテンシを350msから30msへ短縮
  • getBlock呼び出しのP95レイテンシを50msから35msへ短縮

予想どおり、getBlock呼び出しの改善幅が最も小さくなりました。ClickHouseはすでにトランザクションを512行のチャンクで保存しており、ブロックサイズの読み取りではカラム指向によるペナルティが分散されます。getBlockのエンドツーエンド時間の多くは、Base58とJSONのエンコード、およびブロックの再構築に費やされるため、ストレージエンジンを交換してもその処理は高速化されません。

io_uring、非同期Rust、RocksDBのような同期ライブラリを、高スループットのネットワークワークロード下でどのように連携させたかという、より深い話については、今後の記事で詳しく取り上げる価値があります。

ただし、次のセクションでは、RocksDBを扱う際に有用だったいくつかの最適化について解説します。 

RocksDBを大規模運用向けに最適化する

唯一の「高速な」RocksDB構成というものはありません。適切な設定は、調整するインデックスのアクセスパターンに全面的に依存します。

署名 -> 場所インデックスとスロット -> ブロックインデックスは、同じハードウェア上の同じプロセスで動作していますが、調整方針はほぼ正反対です。

そこで、ワークロードにそのまま適用できるとは限らない設定ファイルを提示するのではなく、このセクションでは、ほかの環境にも応用できるトレードオフをどのように検討するかを説明します。

データベース単位ではなく、インデックス単位で調整する

各インデックスは、それぞれ独自のオプションを持つ個別のカラムファミリーです。一方は均一にランダムなポイントルックアップで、もう一方は順番に取得される大きく圧縮可能な値です。両者を同じように扱えば、多くの性能最適化の機会を逃していたでしょう。以下で説明するほぼすべての判断は、「このアクセスパターンにはXを行う」と捉えるべきです。

WALが本当に必要かを判断する

アーカイブデータは再構築できます。つまり、LaserStreamからストリーミングされ、チェーン自体から導出されます。

私たちはWrite Ahead Log(WAL)を無効にして書き込んでいるため、不要な先行書き込みログの耐久性にコストを払いません。 

注意すべき点は、WALを無効にすると、カラムファミリー間におけるRocksDBのデフォルトのクラッシュ整合性も無効になるため、別の方法で整合性を回復する必要があることです。ここでの教訓は、耐久性の設定をデータの復元可能性に合わせるべきだということです。上流ソースから再構築できるデータと、信頼できる唯一の記録となるシステムでは、求められる基準が大きく異なります。

ヒット率とミス率にブルームフィルターを合わせる

ブルームフィルターは、ルックアップ対象のキーが存在しないかどうかを低コストで判定するためにメモリを使います。つまり、役立つのはミスの場合だけです。 

呼び出し元は署名を持っており、対応するトランザクションデータを求めているため、署名のルックアップは事実上常にヒットします。

最下位のLSMレベルには、データの圧倒的大部分が格納されています。データの大半が1つのレベルにあり、ワークロードがヒット中心の場合、そのレベルのフィルターは最も多くのメモリを消費しながら、最も少ない仕事しかしません。この状況では、そのレベルにフィルターが本当に必要かを検討する価値があります。

ホットなデータではなく、圧縮可能なデータを圧縮する

圧縮はインデックスごとに判断します。64バイトの署名のような高エントロピーデータは、圧縮率が1.0を下回りません。つまり、圧縮しても、すべてのルックアップのホットパスに解凍コストが加わるだけです。そのため、圧縮をNoneに設定しています。

一方、ブロックデータはサイズが大きく反復も多いため、効率よく圧縮できます。そのため、スロット -> ブロックインデックスではzstdを使用しています。2つのインデックスは同じデータベースを使用しながら、異なる選択による恩恵を受けています。これは、バイト列が圧縮可能か、またインデックスの制約がレイテンシとストレージのどちらにあるかによって完全に決まります。

インデックスごとに設定を分けたことが、ポイントルックアップのレイテンシを悪化させずに、使用量を~330TBから~190TBへ削減できた大きな理由です。

ダイレクトI/Oを検討する

読み取りにはダイレクトI/Oを使用し、フラッシュとコンパクションにも利用しています。この規模では、OSのページキャッシュと独自のブロックキャッシュが同じRAMを奪い合います。ランダムなポイントルックアップでは、この二重キャッシュの大半が無駄になります。それよりも、予測可能なレイテンシを実現する単一の大規模なブロックキャッシュを独自に持つ方が適しています。

これはワークロードに依存する点に注意してください。

ダイレクトI/Oは、スキャン中心の構成やリソース不足の構成では性能を悪化させる可能性があります。無条件に採用するのではなく、A/Bテストを行う価値があります。 

競合に耐えられるキャッシュを選ぶ

ホットキーに対する高い同時負荷の下では、標準の共有LRUキャッシュがロック競合のボトルネックになります。ホットなインデックスにはRocksDBのHyperClockCacheを使用しています。多数のスレッドが同じ人気エントリへ同時にアクセスしても、はるかに安定して動作します。

複数キーのルックアップを直列化せず並列化する

N回の読み取りを直列化する代わりに、RocksDBはio_uringを通じて多数のI/Oを同時に発行し、並列に完了させられます。多数のポイントルックアップへ展開されるgetTransactionsForAddressのようなメソッドでは、キー数に応じて増加するレイテンシと、ほぼ一定に保たれるレイテンシの差を生みます。

RocksDBはあらゆる選択肢を提供しますが、データに対する方針は示しません。私たちが成果を得られたのは、あらゆる用途に適した単一のグローバル構成を探すのではなく、アクセスパターンを理解し、各インデックスをそれぞれの特性に合わせて調整したからです。

私たちが目指しているもの

現在、アーカイブはEWR、FRA、東京などの大規模リージョンで稼働しています。ストレージエンジンがマイクロ秒単位でルックアップ結果を返し、ネットワークカードを飽和させるようになった今、データベースの問題で夜中に起こされることはなくなりました。しかし、1つのボトルネックを解消すると、次のボトルネックが見えてきます。

ルックアップが実質的に無料になると、主要なコストはソフトウェアからユーザーとマシンの間の距離へ移ります。リクエストは、ユーザーがいる場所からバックエンドがある場所まで移動し、再び戻る必要があります。その段階では、物理法則との戦いになります。

その問題に取り組むのがGatekeeperです。

Gatekeeperは、Hyper上にRustで構築した社内製エッジゲートウェイです。ユーザーの近くで接続を終端し、利用可能な最短経路で各リクエストをバックエンドへルーティングします。現在、レイテンシ改善の主戦場はここにあります。コネクションプーリング、TLSとソケットの調整、近接性と正常性を考慮したルーティング、グローバルなサーバー群へのゼロダウンタイムデプロイなど、すべてはアーカイブがすでにマイクロ秒で提供するバイト列までの経路から、さらに数ミリ秒を削るために行っています。

1件のリクエストを高速化するのはデータベースの問題です。メンテナンス時間も接続の切断もなく、地球上のどこからでも、すべてのリクエストを高速化するのはまったく別の問題です。そして、それが私たちが次に注力する課題です。 

これが、Solanaの読み取り層を革新するという意味です。ルックアップに応答するストレージエンジンから、その応答がエンドユーザーへ届く速度を決めるエッジゲートウェイまで、すべての層を正しく構築します。

結局、アーカイブはランダムキーのポイントルックアップに集約されます。これは、構造上の理由からカラム指向エンジンを破綻させるアクセスパターンです。調整、シャーディング、ビューの並べ替え方の問題ではありません。ClickHouseで直面した制約は、この選択に内在するものであり、構成に偶発的に生じたものではありません。Solanaの履歴ワークロードを定義するのは、最も簡単なアクセスパターンではなく、最も困難なアクセスパターンです。 

グローバル金融の決済層を目指すネットワークの規模では、読み取り層を、すでに限界を超えた既存システムの調整を改善しただけのものにすることはできません。高度な履歴クエリに依存する機関やアプリケーションには、デフォルトのスタックでは提供できないメソッド、カバレッジ、レイテンシが必要です。Solanaの読み取り層は、最初から困難なケースに適合する基盤上に構築しなければなりません。それがRocksDBに託した賭けであり、ほかのすべてにも適用している基準です。

大規模な未来の金融を構築することに興味がある方は、ぜひ私たちと一緒に取り組みましょう。Solanaはグローバル金融の決済層になることを目指して急速に進化しており、アーカイブははるかに大きなパズルの一部にすぎません。LSMコンパクションやインデックスごとの調整に関心がある方、または入手できる最高水準のハードウェアを使って難しいシステム問題を解決したい方にとって、ここは最適な環境です。

エンジニアリングチームのさまざまな職種で採用を行っています。募集中の全ポジションはhelius.dev/careersでご覧ください。

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像