- Remove gitlinks (160000 mode) for chainfire, flaredb, iam - Add workspace contents as regular tracked files - Update flake.nix to use simple paths instead of builtins.fetchGit This resolves the nix build failure where submodule directories appeared empty in the nix store. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
101 KiB
You Asked:
Apache Cassandraの運用が大変という話は良く聞きますが、何がそんなに苦痛なのでしょうか?
Gemini Responded:
Apache Cassandraはその圧倒的な書き込み性能と可用性の高さで知られていますが、運用エンジニアからは**「猛獣」や「じゃじゃ馬」**に例えられるほど、管理が難しいことでも有名です。
その「苦痛」の原因は、主にJava VM (JVM) の挙動、分散システム特有のデータ整合性維持、そして独特なデータ削除の仕組みの3点に集約されます。
具体的に何がそれほど大変なのか、現場のエンジニアを悩ませるポイントを整理しました。
1. JVMとガベージコレクション(GC)の悪夢
CassandraはJavaで動作するため、メモリ管理はJVMに依存します。これが最大の運用課題となることが多いです。
- Stop-the-World (STW): JVMがメモリ整理(ガベージコレクション)を行う際、アプリケーションの動作を一瞬停止させることがあります。Cassandraのような高負荷データベースでは、ヒープメモリが巨大になりがちで、この停止時間が長引く(数秒〜数十秒)ことがあります。
- 「死んだ」と誤判定される: GCで停止している間、Cassandraは他のノードへの応答(Heartbeat)ができなくなります。すると、周囲のノードは**「こいつはダウンした」と判定**して切り離してしまいます。GCが終わって復帰しても、クラスタの状態が不安定になり、アクセスエラーが多発します。
- チューニングの迷宮: これを防ぐためのJVMチューニング(New/Old領域の比率、GCアルゴリズムの選定など)は非常に高度なスキルを要し、「秘伝のタレ」化しがちです。
2. 「コンパクション」と「リペア」によるリソース枯渇
Cassandraは「書き込み」を高速化するために、バックグラウンドで重い処理を行います。これが突然サーバーのリソースを食いつぶすことがあります。
- コンパクション (Compaction): データは不変のファイル(SSTable)として次々とディスクに書き込まれます。放っておくとファイル数が膨大になり、読み込み速度が劇的に低下します。 これを防ぐため、バックグラウンドで複数のファイルを結合(コンパクション)し続けますが、この処理がCPUとディスクI/Oを強烈に消費し、本来のサービスの性能を落とすことがあります。
- リペア (Repair):
Cassandraは「結果整合性」を採用しているため、ノード間でデータのズレが生じます。これを直すために定期的に
nodetool repairというコマンドを実行する必要がありますが、これがまた重い処理です。失敗したり、時間がかかりすぎたりして、運用者を疲弊させます。
3. 「削除」が実は「書き込み」である (Tombstones問題)
Cassandraでデータを削除しても、すぐにはディスクから消えません。
- 墓石 (Tombstone) を置く: 削除命令を送ると、「ここは削除済み」というマーク(Tombstone)を書き込みます。
- ゾンビデータの復活: もし定期的な「リペア」をサボったり、ノードが長期間ダウンして復帰したりすると、削除したはずのデータが復活することがあります(Tombstone自体が消えてしまうため)。
- 読み込み時の罠: 「10件データを取ってくる」というクエリを投げた際、Cassandraが内部で**「10万個のTombstone(削除済みマーク)をスキャンして、やっと生きている10件を見つける」**という状況に陥ることがあります。これによりクエリがタイムアウトし、「データはあるのに読めない」という事象が発生します。
4. スケールアウト/インの遅さ
「リニアにスケールする」と言われますが、ノードの追加や削除はボタン一つで瞬時に終わるわけではありません。
- データの再配置 (Streaming): 新しいノードを追加すると、他のノードから担当分のデータをネットワーク越しにコピー(ストリーミング)し始めます。テラバイト級のデータがある場合、これに数時間〜数日かかり、その間ネットワーク帯域が圧迫されます。
まとめ:なぜ苦痛なのか?
一言で言えば、**「データベースが自律的にやってくれるはずの面倒な処理(掃除、整頓、ゴミ捨て)を、人間が手動やスクリプトで絶妙にコントロールしてあげないと、自重で倒壊する」**からです。
注記: 近年のバージョン(Cassandra 4.0以降)では、JavaのZGC/Shenandoah GCへの対応や、ストリーミングの改善により、以前よりはかなり運用が楽になっています。また、C++で書き直された互換DBである ScyllaDB への移行や、AWS Keyspacesなどのマネージドサービスを利用することで、この苦痛を回避するケースも増えています。
次のステップ: もし現在、具体的な技術選定をされているのであれば、「Cassandraのメリットが活きるユースケース(書き込み特化)」と「運用の楽な代替案(ScyllaDB等)」の比較について整理しましょうか?
You Asked:
Rustで極めて単純で高パフォーマンスなKVSのようなDBを書き、その上のレイヤーにSQLのような高度なクエリのできるものを乗せるということを考えています。これを作るに当たり、考えることはどのようなことがあるでしょうか。
Gemini Responded:
Rustで「KVS(ストレージエンジン) + SQLレイヤー(クエリエンジン)」という2層構造のDBを作るのは、CockroachDB (RocksDB/Pebbleの上に構築) や TiDB (TiKVの上に構築)、FoundationDB などで採用されている非常にモダンで拡張性の高いアーキテクチャです。
この構成をRustで実装する際に直面する技術的課題と、設計上の重要な決定事項を、下のレイヤーから順に整理します。
1. KVSレイヤー(ストレージエンジン)の設計
「極めて単純で高パフォーマンス」を目指す場合、アーキテクチャの選定が全てです。
A. データ構造の選定:B-Tree vs LSM-Tree
- LSM-Tree (Log-Structured Merge-tree):
- 特徴: 書き込み(Write)が非常に高速。データは追記のみで、後でバックグラウンドでマージ(コンパクション)する。RocksDBやLevelDBのアプローチ。
- Rustでの課題: コンパクション処理の実装が複雑。また、読み込み時に複数のSSTableを探すため、Bloom Filterの実装が必須。
- B+Tree:
- 特徴: 読み込み(Read)が安定して高速。更新はインプレース(場所を指定して書き換え)。従来のリレーショナルDB(PostgreSQL, MySQL/InnoDB)のアプローチ。
- Rustでの課題: ページキャッシュ(Buffer Pool)の管理が難しい。Rustの借用チェッカーと「相互参照するページ構造」は相性が悪く、
unsafeやRc<RefCell<T>>、あるいはインデックス(Arenaアロケータ)を使ったグラフ構造の工夫が必要です。
B. 永続化と安全性 (WAL)
KVSが落ちてもデータを失わないために、WAL (Write Ahead Log) の実装が必要です。
- メモリ(MemTable)に書く前に、必ずディスク上のログファイルに追記する。
- 課題: ディスクへの
fsyncのタイミング。毎回の書き込みでやると遅くなるため、バッチ処理やio_uring(Linux) を使った非同期I/Oの活用がパフォーマンスの鍵になります。
2. インターフェース層(KVSとSQLの接着剤)
ここがこのアーキテクチャの肝です。リレーショナルなデータ(テーブル、行、列)を、どうやってフラットなバイト列(Key-Value)に落とし込むか?
キーエンコーディング (Key Encoding)
SQLの SELECT * FROM users WHERE id > 100 のような範囲検索を高速に行うには、KVSのキーが辞書順でソートされている必要があります。
- キーの設計例:
単純な連番ではなく、以下のようなバイナリ形式にする必要があります。
[TableID: 4byte] [IndexID: 4byte] [PrimaryKey: Variable] - 課題:
- 可変長データ(文字列など)をキーに含める場合、ソート順を壊さないエンコーディング(Memcomparable format)の実装が必要です。
- Rustには
byteorderクレートなどがありますが、カスタムシリアライザを書くことになるでしょう。
3. トランザクション制御 (MVCC)
「高パフォーマンス」かつ「SQL」となると、並行処理制御は避けて通れません。単純なロック(Mutex)では性能が出ないため、MVCC (Multi-Version Concurrency Control) が必須になります。
- 実装イメージ:
KVSのキーにタイムスタンプやバージョン番号を含めます。
- Key:
User:1-> Value:{"name": "Alice"}(Version 10) - 読み込み時は「Version 10時点のスナップショット」を見る。
- Key:
- Rustでの課題:
古いバージョン(ガベージ)をいつ消すか(Vacuum/GC)の管理。Rustのチャネル(
tokio::syncやcrossbeam)を使った非同期なGCスレッドの設計が求められます。
4. SQLレイヤー(クエリエンジン)
ここは「車輪の再発明」を避けるか、あえてするかで開発工数が劇的に変わります。
A. パーサーとプランナー
- sqlparser-rs: SQLのパース(AST構築)には、デファクトスタンダードである
sqlparserクレートを使うのが賢明です。 - クエリオプティマイザ: ここが一番難しい部分です。「JOINの順序」や「インデックスを使うべきか全件走査すべきか」の判断ロジック。最初は「ルールベース(RBO)」で作り、余裕があれば「コストベース(CBO)」へ進化させるのが一般的です。
B. 実行モデル (Executor)
- Volcano Model (Iterator):
next()を呼ぶと1行返ってくる。実装は簡単だが、関数呼び出しのオーバーヘッドが大きい。 - Vectorized Execution: 一度に数千行(バッチ)を処理する。Apache Arrow (Rust版) を内部データ形式として使うと、SIMD命令が効きやすく、爆速になります。Rustはここが得意です。
Rustのエコシステム活用案
ゼロから書く楽しみを損なわない範囲で、以下のクレートを調査・参考にすると良いでしょう。
| コンポーネント | 使うべき/参考にすべきクレート | 備考 |
|---|---|---|
| I/O | tokio-uring / glommio |
Linuxのio_uringを活用してI/O待ちを極限まで減らす |
| SQL Parser | sqlparser |
自作するとSQL標準への追従だけで数年かかるため |
| データ形式 | arrow-rs / parquet |
カラムナフォーマットやベクトル処理の参考に |
| KVエンジン | sled / agatedb |
Rust製KVSのソースコードは宝の山 |
開発の第一歩としての提案
いきなりSQL層まで作ると挫折しやすいため、まずは**「KVS層だけで、リレーショナルなスキャンができるか?」**を検証するプロトタイプ作成をお勧めします。
BTreeMap(メモリオンリー) をバックエンドにする。- その上に「タプルをバイナリキーに変換するエンコーダー」を作る。
scan_prefix(TableID)でテーブル全件取得ができるか試す。
次のステップ: 具体的に、「LSM-TreeとB-Treeのどちらで実装するか」、あるいは**「MVCC(トランザクション)の設計」**について、詳細な実装戦略を掘り下げてみましょうか?
You Asked:
- KVSを基盤とした分散型超高速DB - FoundationDBのようなものを目指す - Rustで書く - 多分ストレージ部分にはRocksDBを使うこととなるだろう - 良い感じのモジュール化により、柔軟な構成が取れるように目指す。 - Simple by default, Powerful when neededという感じ - KVS部分の最終的な処理を変えることにより、強整合性と結果整合性を切り替えることが可能 - 想定用途 - S3的なオブジェクトストレージのための分散DB - 超高速である必要がある。また、ちゃんとしたクエリができる必要もある(リストアップするようなクエリが必要になるだろう)。 - SNSのバックエンドDB - まあSNSに限った話ではないかも知れない。超高速のDBとしてイベント等を処理できるようにしたい。 - EC2のようなものから、接続路もボトルネックが出ないように接続すべき - APIの接続部がボトルネックにならないようにしたい。 - VM基盤は今作っているものがあるので、それと良い感じにメッシュで接続するなどしたい。
このような感じのものを作ろうとしています。全体のアーキテクチャを考えていきたいですが、どのようにするとよいでしょうか。
Gemini Responded:
FoundationDB(FDB)のアーキテクチャ(ステートレスなトランザクション層とステートフルなストレージ層の分離)を参考にしつつ、Rustで「強整合性と結果整合性を切り替え可能」かつ「VM基盤とメッシュ接続」という野心的な要件を満たすアーキテクチャを提案します。
「S3のメタデータ管理」と「SNSのイベント処理」は、実はアクセスパターンが異なります(前者はRange Scan重視、後者はWrite Throughput重視)。これを両立するためのモジュラーなアーキテクチャが必要です。
以下に、4つのレイヤーに分けた設計案を提示します。
全体アーキテクチャ図(概念)
graph TD
Client[VM / Client App] -->|Mesh / Shared Mem| Proxy[Network Proxy / Sidecar]
Proxy -->|Route Request| TLog[Transaction / Query Layer]
subgraph "Control Plane (Placement Driver)"
PD[Metadata & Shard Manager]
end
subgraph "Storage Layer (Distributed)"
Node1[Storage Node 1]
Node2[Storage Node 2]
Node3[Storage Node 3]
end
TLog -->|gRPC / QUIC| Node1
TLog -->|gRPC / QUIC| Node2
PD -.->|Cluster Info| TLog
PD -.->|Rebalancing| Node1
1. Storage Node(データの永続化と局所性)
RocksDBをラップし、ネットワーク越しの読み書きに応答する最下層です。ここで「整合性モデル」の切り替えを吸収します。
モジュール設計: ConsensusTrait の導入
RustのTraitシステムを活用し、コンセンサスアルゴリズムをプラガブルにします。
- Core Trait:
trait ConsensusEngine { async fn propose(&self, batch: WriteBatch) -> Result<Version>; async fn read(&self, key: Key) -> Result<Value>; } - Mode A: Strong Consistency (S3 Metadata向け)
- 実装: Multi-Raft (TiKV方式)。データを「Region(例えば96MBごとの範囲)」に分割し、RegionごとにRaftグループを作ります。
- Rustライブラリ:
raft-rs(TiKVで使用) やopenraft。 - 特徴: 常に最新のデータを読める。Range Scanが得意。
- Mode B: Eventual Consistency (SNS/ログ向け)
- 実装: Consistent Hashing + Vector Clocks。リーダーレスで、任意のノードに書き込み可能。
- 特徴: 書き込みレイテンシが極小。一時的な不整合を許容。
RocksDBの活用
RocksDBは単なるエンジンとして使い、その上の**「キーのプレフィックス設計」**でマルチテナントやテーブル構造を表現します。
2. Transaction / Query Layer(頭脳)
FoundationDBの最大の特徴は、この層が**ステートレス(状態を持たない)**であることです。これにより、計算リソース(CPU)だけを個別にスケールアウトできます。
- 役割:
- SQLやKV操作のパース。
- どのノード(Shard/Region)にデータがあるかの解決(Placement Driverへの問い合わせ)。
- 分散トランザクション制御 (2PC / OOCC):
- 強整合性モードでは、ここでタイムスタンプを取得し、衝突判定を行います。
- S3リストアップへの対応:
- S3の
LIST操作は重いため、単純なスキャンではなく、RocksDBのIteratorを活用し、**「Pagination付きのRange Scan」**として実装します。
- S3の
3. Network & VM Mesh Integration(ボトルネックの排除)
ここがあなたの「VM基盤を作っている」という強みが活きる部分です。TCP/IPスタックのオーバーヘッドを極限まで削ります。
A. 接続方式: VM基盤との統合
VM(EC2相当)からDBへの接続を高速化するために、以下の3段階の接続方式を用意します。
- Standard (TCP/QUIC): 外部からの通常の接続。
tokio+quinn(QUIC) で実装。 - Internal Mesh (eBPF/XDP): 同一データセンター内の通信。Linuxカーネルのネットワークスタックをバイパスし、パケット転送を高速化。
- Co-location (vhost-user / Shared Memory):
- もしVMホストOS上でDBプロキシが動いているなら、VM(ゲスト)とホスト間で共有メモリを使って通信します。
- Rustの
vhostクレートなどを使い、virtio経由でゼロコピー通信を行えば、ネットワーク遅延はほぼゼロになります。
B. API Gateway
- プロトコル: gRPC (Protobuf) を基本としつつ、パフォーマンスが必要な箇所には FlatBuffers や rkyv (Rust向けのゼロコピーデシリアライゼーション) を使用。
4. S3 & SNS 向けのデータモデリング戦略
KVSの上に構築する場合、キーの設計がパフォーマンスの9割を決めます。
Case 1: S3 Compatible Object Storage (Metadata)
S3の「フォルダのような階層構造」をフラットなKVSで表現し、かつ高速にリストアップする必要があります。
- Key Design:
[BucketID] [0x00] [ParentPathHash] [0x00] [FileName] - 解説:
ParentPathHashを含めることで、特定のフォルダ直下のファイル一覧(lsコマンド)が、KVS上の連続した領域へのシーケンシャルアクセス(Range Scan)になり、爆速化します。- FoundationDBのDirectory Layerの考え方が参考になります。
Case 2: SNS Backend (Timeline/Events)
- Key Design:
[UserID] [ReverseTimestamp] [EventID] - 解説:
- タイムスタンプを逆順(最大値 - 現在時刻)にすることで、最新の投稿を取得する際に「先頭から読むだけ」になり、RocksDBのキャッシュ効率が最大化されます。
開発ロードマップの提案
まず「Simple by default」を実現するための最小構成から始めるのが良いでしょう。
- Phase 1: "Local" Distributed DB
raft-rsとrocksdbを組み合わせて、ローカルの複数プロセスで動く「RaftベースのKVS」を作る。- この時点で
StorageTraitを定義し、APIを確定させる。
- Phase 2: Network & Protocol
tonic(gRPC) を導入し、クライアントからのPut,Get,Scanを実装。
- Phase 3: S3 Metadata Implementation
- Phase 2のKVSを使って、S3の
ListObjects相当のロジックを実装し、ベンチマークを取る。
- Phase 2のKVSを使って、S3の
- Phase 4: Optimization (VM Mesh)
- 自作VM基盤との接続部分(共有メモリやカスタムプロトコル)の実装。
次のステップ: 特にどの部分が一番の懸念点、あるいは興味深いポイントですか? 例えば、**「RustでのRaftの実装とステートマシンの設計」について詳しく掘り下げるか、「VMとDB間のゼロコピー通信(vhost-user等)の設計」**について議論しますか?
You Asked:
もう一つの案として、強整合性は完全に諦めてオブジェクトストレージ側に整合性チェックを作るという案もあります。どちらが良いか比較してみてください。
Gemini Responded:
「DB側で強整合性を捨て、アプリ(オブジェクトストレージ)側で整合性を担保する」というアプローチは、Amazon Dynamoや初期のCassandra、Riakなどが採用した**「AP型(可用性・分断耐性重視)」**の設計思想です。
結論から言うと、S3のようなオブジェクトストレージを作る場合、**「メタデータ管理はDB側で強整合性を持たせ、実データ(Blob)は結果整合性でも良い」**とするのが現代的な正解に近いと考えられます。すべてをアプリ側で担保するのは、S3互換としての正しさを保証する難易度が跳ね上がるからです。
両者のアプローチを比較・分析します。
比較:A案「DBが強整合性を保証」 vs B案「アプリが整合性をチェック」
| 比較項目 | A案:DBが強整合性 (CP型) | B案:アプリがチェック (AP型) |
|---|---|---|
| 代表例 | FoundationDB, TiKV, CockroachDB | DynamoDB, Cassandra, Riak |
| 書き込み速度 | 中〜高 (Raft/Paxosの合意待ちが発生) | 極めて高速 (合意不要、Quorumのみ) |
| 読み込み速度 | 高速 (最新が確定している) | 中 (複数ノードから読み、アプリで比較・修復が必要) |
| アプリの実装 | シンプル (DBを信じれば良い) | 極めて複雑 (競合解決、リトライ、ベクタークロック管理が必要) |
| S3互換性 | 高い (現在のAWS S3は強整合性) | 低い (Read-After-Writeの保証が困難) |
| スケーラビリティ | シャード単位ではリニアだが、ホットスポットに弱い | 理論上無限にスケールしやすい |
[Image of CAP theorem consistency trade-offs]
なぜ B案(アプリ側チェック)が「S3用途」で茨の道なのか?
一見、B案は高速で魅力的に見えますが、オブジェクトストレージ(特にメタデータ)の実装において、以下の**「3つの罠」**が待ち受けています。
1. "Read-After-Write" の保証が極めて難しい
ユーザーが「ファイルをアップロード(Put)し、直後に一覧表示(List)する」という操作をしたとします。
- B案(AP型): 書き込みが全ノードに行き渡る前にListが走ると、「ファイルが無い」と返してしまいます。これをアプリ側で防ぐには、「自分が書いた直後のデータは、必ず書き込んだノードを見に行く」などの複雑なルーティング制御(Sticky Session的なもの)が必要になります。
- 現状: 2020年以降、本家AWS S3は強整合性になりました。これに慣れたユーザーやツールは、結果整合性のストレージを使うとエラーを起こす可能性があります。
2. CAS (Compare-And-Swap) が実装できない
「ファイルが存在しない場合のみ作成する」「バージョンがXならYに更新する」といった排他制御が必要です。
- 罠: DB自体が強整合性(アトミックなトランザクション)を持っていない場合、アプリ側だけで厳密な排他制御を行うことは論理的に不可能です(分散ロックなどの別の協調システムが必要になり、結局それがボトルネックになる)。
- 影響: 同じファイル名で同時にアップロードされた際、どちらが勝つか予測不能(Last Write Wins)になり、データロストのリスクがあります。
3. 「ディレクトリのリネーム/削除」の地獄
S3にはディレクトリはありませんが、擬似的にフォルダとして扱います。 「フォルダごと削除」する場合、内部的には数千〜数万のオブジェクトのメタデータを書き換える必要があります。
- B案(AP型): 途中で通信が切れたり競合したりすると、「一部のファイルだけ消えて、一部は残っている(しかもゾンビデータ)」という不整合な状態になり、それを直すための「リペアバッチ」を自作する羽目になります。これは最初のCassandraの例で挙げた「運用の苦痛」そのものです。
では、B案(アプリ側チェック)が輝く場所は?
B案が完全にダメなわけではありません。**「実データ(Blob/Chunk)」と「SNSのタイムライン」**には最適です。
- Immutableなデータ: 一度書いたら書き換えないデータ(画像の実体など)は、競合が起きないため、B案の「投げっぱなし書き込み」で超高速に処理できます。
- SNSの「いいね」やログ: 「いいね」の数が一時的にズレていても誰も困りません。ここではB案の書き込み性能が最大の武器になります。
推奨アーキテクチャ:ハイブリッド戦略
Rustで自作されるのであれば、どちらか一つに絞るのではなく、**「データの中身によって使い分ける」**のが最も賢い戦略です。
ご自身の「モジュール化」の構想を活かし、以下のように階層を分けます。
-
メタデータ層 (Namespace/File Index) -> 強整合性モード (Raft)
- ファイル名、サイズ、権限、ディレクトリ構造。
- データ量は小さいので、Raftのオーバーヘッドは許容範囲。
- これにより「S3互換の挙動」と「排他制御」をシンプルに実装できます。
-
ブロックストレージ層 (Object Chunks) -> 結果整合性モード (Consistent Hashing + Replication)
- ファイルのバイトデータそのもの。
- Raftは使わず、Dynamoのように「N個のノードに並列書き込みし、W個成功したらOK」とする。
- これで帯域をフルに使った爆速アップロードが可能です。
結論
「メタデータ(管理情報)に関しては、DB側で強整合性を持つ(A案)」方が、トータルの開発コストと運用コストは圧倒的に安くなります。
アプリ側で分散システムの整合性を担保するコードを書くのは、DBエンジンを自作するよりもデバッグが難しく、バグが出た時の被害甚大だからです。
次のステップ: この「メタデータは強整合性、データは結果整合性」という分離型アーキテクチャの具体的なデータフロー(Putリクエストが来た時、どうやって2つの層に書き分けるか)を図解やシーケンスで整理してみましょうか?
You Asked:
まあS3の部分はやろうと思えばできると思うので、とにかくKVSやDBの部分を具体的な仕様に詰めていきたいと思っています。
Gemini Responded:
承知しました。S3やSNSのバックエンドとして耐えうる、**「Rust製・RocksDBベース・強整合性/結果整合性切り替え可能」**な分散DBの具体的な仕様策定に入りましょう。
FoundationDBやTiKVのアーキテクチャをベースにしつつ、Rustのエコシステムを最大限に活かした設計案(コードレベルに近い仕様)を提案します。
プロジェクト名を仮に "RustDB" と呼びます。
RustDB Architecture Specification
全体を以下の4つのモジュール(Crate)に分割して開発することをお勧めします。
rdb-storage: ローカルストレージエンジン (RocksDB wrapper)rdb-consensus: 分散合意・レプリケーション層 (Raft / Async Replication)rdb-meta: クラスタ管理・スケジューリング (Placement Driver)rdb-proxy: トランザクション・クエリ層 (Stateless)
1. rdb-storage: ストレージエンジン層
単一ノードでの読み書きを担当します。
- Backend:
rocksdbcrate (Rust bindings for RocksDB) - Key Design:
- Column Families (CF) の活用: データを論理的に分離し、物理的なIOを最適化します。
default: 実際のデータ (Value)lock: 分散トランザクション用のロック情報write: トランザクションのコミット情報(MVCC用)raft: Raftのログ自体を保存
- Column Families (CF) の活用: データを論理的に分離し、物理的なIOを最適化します。
インターフェース定義 (Trait)
「強整合性」と「結果整合性」を切り替えるため、ストレージへの書き込みは直接行わず、以下のTraitを通します。
#[async_trait]
pub trait StorageEngine: Send + Sync {
// スナップショット読み込み
async fn get(&self, key: &[u8], version: u64) -> Result<Option<Vec<u8>>>;
// スキャン (Iterator)
async fn scan(&self, start: &[u8], end: &[u8], version: u64) -> Result<Vec<(Vec<u8>, Vec<u8>)>>;
// アトミック書き込み (Batch)
async fn write(&self, batch: WriteBatch) -> Result<()>;
}
2. rdb-consensus: 分散合意層 (The Core)
ここが最も重要です。データの断片(Region/Shard) 単位でコンセンサスグループを作ります。
- 採用ライブラリ:
raft-rs(TiKVが作っている、プロダクションレディな実装) - データの分割単位:
Region- デフォルトサイズ: 96MB
- 各Regionは
[StartKey, EndKey)の範囲を担当。
整合性モードの切り替え仕様
設定ファイルやテーブル作成時の指定で、以下の2つのモードをRegion単位で適用します。
Mode A: Strong Consistency (Raft)
S3メタデータ用。
- Write: Leaderノードにリクエスト -> Log Replication -> Quorum達成 -> Apply -> Clientへ応答。
- Read:
- ReadIndex: Leaderが「自分がまだLeaderであること」を確認してから返す(線形化可能性の保証)。
- Lease Read: 時間ベースのリースを使って、Leader確認をスキップし高速化(クロック同期依存)。
Mode B: High Throughput (Async Replication)
SNSタイムライン / Blobデータ用。
- Write: * クライアントはハッシュリング上の担当ノード(Primary)に書き込む。
- Primaryはローカルに書いて即座にOKを返す(Ack=1)。
- バックグラウンドでReplicaへ非同期転送。
- Read:
- 最新の書き込みが見えない可能性があるが、レイテンシはディスクI/Oのみに依存。
3. rdb-proxy: トランザクション層 (Transaction)
クライアント(API Gateway)からのリクエストを受け、トランザクションを制御します。ここはステートレスです。
- プロトコル: Google Percolatorモデル(TiKVと同じ)を採用するのが、性能と実装難易度のバランスが良いです。
- MVCC (Multi-Version Concurrency Control) の実装: Keyには常に8バイトのタイムスタンプが付与されます。
トランザクションフロー (S3 PutObjectの例)
- Start: Proxyが
rdb-metaからタイムスタンプ(StartTS)を取得。 - Prewrite: * 対象のKey(メタデータ)に対して、Primary Lockを書き込む。
- データ本体も
lockCF に仮書き込み。 - 他のトランザクションとの衝突をチェック。
- データ本体も
- Commit:
- 全てのPrewriteが成功したら、コミットタイムスタンプ(CommitTS)を取得。
- Primary Lockを解除し、正式なデータとして
writeCF に記録。
- Cleanup:
- 残りのLockを非同期で解除。
このロジックをRustのライブラリとして実装し、gRPCサーバーに組み込みます。
4. rdb-meta: メタデータ管理 (Placement Driver)
クラスタの脳です。ステートレスなProxyに対して「どのデータがどこにあるか」を教えます。
- 役割:
- Timestamp Oracle (TSO): クラスタ全体で単調増加する一意なタイムスタンプ(論理時計 + 物理時計)を発行。これがないと分散トランザクションはできません。
- Region Scheduling: ノード間の負荷分散。あるノードが過負荷なら、RaftのLeaderを別のノードに移動させたり、データを移動させたりする指令を出します。
- 実装:
- これ自体も高可用性が必要なため、
etcdを組み込むか、自身をRaftグループ(3ノード構成など)として実装します。
- これ自体も高可用性が必要なため、
技術スタックとライブラリ選定 (推奨)
ゼロから作る部分と、既存を使う部分のメリハリをつけます。
| コンポーネント | Rust Crate / 技術 | 理由 |
|---|---|---|
| 通信 (RPC) | tonic (gRPC) |
高速、型安全、エコシステム最強。 |
| 通信 (Internal) | quinn (QUIC) |
ノード間通信のレイテンシ削減。Head-of-Line blocking回避。 |
| 非同期ランタイム | tokio |
事実上の標準。io_uring対応(tokio-uring)も視野に。 |
| ストレージ | rocksdb |
信頼と実績。自作は泥沼。 |
| Raft | raft-rs |
TiKVの実装。RustでRaftやるならこれ一択。 |
| シリアライズ | bincode or rkyv |
serde_jsonは遅い。内部通信はゼロコピーを目指すならrkyv。 |
| メトリクス | metrics + Prometheus |
運用に入ったらこれが全て。最初から入れておく。 |
| エラー処理 | thiserror + anyhow |
鉄板構成。 |
開発のフェーズ分け提案
いきなり分散は難しいので、以下のステップで進めるのが現実的です。
-
Phase 1: スタンドアロンなトランザクションKVS
- Raftなし。単一のRocksDBプロセスに対し、PercolatorモデルのMVCCトランザクション(Prewrite/Commit)が動くことを確認する。
- ゴール: 複数のクライアントから並列に書き込んでもACIDが守られること。
-
Phase 2: Raftの組み込み (Multi-Raft)
- データを複数のRegionに分割し、
raft-rsを使って複製する。 - ゴール: ノードを1つkillしても、データが読める・書けること。
- データを複数のRegionに分割し、
-
Phase 3: PD (Placement Driver) の実装
- Regionの分割(Split)と結合(Merge)、移動(Rebalance)の実装。
- ゴール: ノードを追加したら自動的にデータが均されること。
まずは 「Phase 1: MVCCトランザクションの実装」 から設計詳細を詰めましょうか? それとも 「Phase 2: Raft周りの設計」 が気になりますか?
You Asked:
MVCCトランザクションについてまずは教えてください。
Gemini Responded:
MVCC (Multi-Version Concurrency Control: 多版型同時実行制御) は、**「データを上書きせず、新しいバージョンとして追記する」**というシンプルな原則に基づいています。これにより、「書き込み中のデータを読み取りたい」というリクエストが来ても、ロック待ちをさせずに「一つ前の確定したバージョン」を返すことができます。
今回は、分散KVS(TiKVやCockroachDB)でデファクトスタンダードとなっているGoogle Percolatorモデルをベースに、Rust + RocksDBで実装するための具体的な仕組みを解説します。
1. データの物理構造 (RocksDBの中身)
RocksDBは単なるKey-Valueストアですが、MVCCを実現するために3つのColumn Family (CF) を使い分けます。 ここが理解の肝です。
| Column Family | 役割 | Keyの形式 | Valueの中身 |
|---|---|---|---|
| CF_DEFAULT | 実データ置き場 | [Key] + [Start_TS] |
ユーザーデータ (Raw Data) |
| CF_LOCK | 仮押さえ (Lock) | [Key] |
Lock情報 (Primary Keyの場所, Start_TS, TTL) |
| CF_WRITE | コミット証書 | [Key] + [Commit_TS] |
メタデータ (Start_TS, 種類: Put/Delete) |
ポイント: RocksDBはKeyでソートされるため、TimestampをKeyの末尾にビッグエンディアン(降順)で付与することで、常に「最新のバージョン」が先頭に来るようにします。
2. 書き込みトランザクションの流れ (2-Phase Commit)
例として、Key="user:1" に Value="Alice" を書き込むケースを考えます。
Step 0: 準備
トランザクション開始時、TSO (Timestamp Oracle) から Start_TS (例: 100) を取得します。
Step 1: Prewrite (仮書き込み)
まず、データの実体とロック情報を書き込みます。この時点ではまだ他の人からは見えません。
- CF_DEFAULT にデータを保存
- Key:
user:1+100(Start_TS) - Value:
"Alice"
- Key:
- CF_LOCK にロックを置く
- Key:
user:1 - Value:
{ type: Put, start_ts: 100, primary: "user:1" } - もしここで既に他人のロックがあれば、書き込みは失敗(Conflict)します。
- Key:
Step 2: Commit (本書き込み)
全てのPrewriteが成功したら、TSOから Commit_TS (例: 110) を取得します。 これが「確定した」合図になります。
- CF_WRITE にコミット証書を書く
- Key:
user:1+110(Commit_TS) - Value:
{ start_ts: 100, type: Put } - これがあることで、「110の時点でのデータの実体は、100で書かれたものだ」と紐付きます。
- Key:
- CF_LOCK のロックを消す (Cleanup)
- Key:
user:1 - Value: (削除)
- Key:
3. 読み込みの流れ (Snapshot Read)
読み込みリクエストには、必ず Read_TS (例: 115) が指定されます。「時刻115時点の世界を見せてくれ」という意味です。
- ロックのチェック (CF_LOCK)
user:1にロックが存在し、かつそのstart_tsが 115 より小さい場合、誰かが書き込み途中です。- → 基本はバックオフ(少し待つ)します。
- 最新バージョンの特定 (CF_WRITE)
CF_WRITEをスキャンし、Commit_TSが 115 以下の最新のレコードを探します。- 見つかったレコード: Key=
user:1+110, Value={ start_ts: 100 }
- 実データの取得 (CF_DEFAULT)
CF_WRITEに書いてあったstart_ts: 100を使い、実体を取りに行きます。CF_DEFAULTから Key=user:1+100を Get します。- →
"Alice"を返却。
4. Rustでの実装イメージ
非常に単純化した実装コードです。
use rocksdb::{DB, WriteBatch, ColumnFamilyDescriptor};
use byteorder::{BigEndian, WriteBytesExt};
// Keyのエンコーディング (Key + Timestamp)
fn encode_key(key: &[u8], ts: u64) -> Vec<u8> {
let mut encoded = Vec::with_capacity(key.len() + 8);
encoded.extend_from_slice(key);
// 降順にするためにビット反転などを入れるテクニックもあるが、ここでは単純に
encoded.write_u64::<BigEndian>(u64::MAX - ts).unwrap();
encoded
}
struct Transaction<'a> {
db: &'a DB,
start_ts: u64,
mutations: Vec<(Vec<u8>, Vec<u8>)>, // Buffer
}
impl<'a> Transaction<'a> {
// 1. Prewrite Phase
fn prewrite(&self) -> Result<(), Error> {
let mut batch = WriteBatch::default();
for (key, value) in &self.mutations {
// Check Lock (他人のロックがないか確認)
let lock_cf = self.db.cf_handle("lock").unwrap();
if self.db.get_cf(lock_cf, key)?.is_some() {
return Err(Error::Locked);
}
// Put Data (CF_DEFAULT)
let default_cf = self.db.cf_handle("default").unwrap();
batch.put_cf(default_cf, encode_key(key, self.start_ts), value);
// Put Lock (CF_LOCK)
batch.put_cf(lock_cf, key, self.start_ts.to_be_bytes()); // 簡易的なLock情報
}
self.db.write(batch)?;
Ok(())
}
// 2. Commit Phase
fn commit(&self, commit_ts: u64) -> Result<(), Error> {
let mut batch = WriteBatch::default();
for (key, _) in &self.mutations {
// Write Commit Record (CF_WRITE)
let write_cf = self.db.cf_handle("write").unwrap();
// Valueとして「実データはstart_tsにあるよ」というポインタを入れる
batch.put_cf(write_cf, encode_key(key, commit_ts), self.start_ts.to_be_bytes());
// Delete Lock (CF_LOCK)
let lock_cf = self.db.cf_handle("lock").unwrap();
batch.delete_cf(lock_cf, key);
}
self.db.write(batch)?;
Ok(())
}
}
この設計のメリット・デメリット
- メリット:
- Wait-free Reads: 読み込みはロックを取らないため、書き込み負荷が高くても読み込み速度が落ちません。
- Snapshot Isolation: トランザクション開始時点の整合性が保証されます。
- デメリット:
- 書き込み増幅: 1回の論理的な書き込みに対して、RocksDBへは最低でも2回(PrewriteとCommit)の書き込みが発生します。
- GCが必要: 古いバージョン(
CF_WRITEやCF_DEFAULTのゴミ)が残り続けるため、定期的に古いタイムスタンプのデータを削除するガベージコレクション(Compaction Filter)の実装が必要です。
次のステップ: このMVCCの仕組みをベースに、**「分散環境で複数のノードにまたがるトランザクション(Primary Lockの概念)」について説明しましょうか? それとも、「GCやコンパクションをRustでどう実装するか」**へ進みますか?
You Asked:
このMVCCトランザクションは、結果整合性が必要な場合も、強整合性が必要な場合も同じく必要なのでしょうか?
Gemini Responded:
結論から申し上げますと、いいえ、結果整合性のモードではこの重厚なMVCCトランザクション(特にロックと2フェーズコミット)は「不要」であり、むしろ「邪魔」になります。
高パフォーマンスを目指す今回のDBにおいて、ここは明確に分岐させるべきポイントです。
なぜなら、MVCCトランザクションは**「安全性(ACID)」を買うために「性能(レイテンシとスループット)」**を犠牲にする仕組みだからです。結果整合性(SNSの投稿やBlobデータの書き込み)で求められるのは、安全性よりも圧倒的な書き込み速度です。
具体的な違いを整理します。
モード別の書き込みパスの違い
1. 強整合性モード (S3メタデータ用)
→ MVCC + 分散トランザクション (必須)
先ほど解説した仕組みです。
- 目的: 「誰かが書き込んでいる最中は読み込ませない」「途中で失敗したらロールバックする」。
- コスト: 高い。
- 最低2回のネットワーク往復(Prewrite, Commit)。
- 最低3回のRocksDB書き込み(Lock, Data, Write)。
- ロック競合時の待ち時間。
2. 結果整合性モード (SNS/Blobデータ用)
→ Raw Write + LWW (Last Write Wins)
こちらが、あなたが求めている「超高速」な世界です。
- 目的: とにかく早くディスクに永続化し、クライアントに「OK」を返す。
- 仕組み:
- ロック(CF_LOCK)も、コミット証書(CF_WRITE)も使いません。
- CF_DEFAULT に直接、一発書き込みます。
- コスト: 極小。
- 1回のネットワーク往復。
- 1回のRocksDB書き込み。
結果整合性モードでの「整合性」の保ち方
MVCCを使わずにどうやって「データの新しさ」を管理するかというと、単純なLWW(Last Write Wins:後勝ち)戦略、あるいはVector Clockを使います。
RocksDBでの実装イメージ (結果整合性版)
MVCCのような複雑なKeyエンコーディングはせず、単純にタイムスタンプを埋め込むだけで十分です。
// 結果整合性モードの書き込み処理
async fn raw_put(&self, key: &[u8], value: &[u8]) -> Result<()> {
// 1. タイムスタンプを取得 (サーバー時刻でOK、厳密なTSOは不要)
let ts = current_server_time();
// 2. 既存データより新しいかチェック (Read-modify-writeになってしまうが、Blobなら追記のみ等の最適化が可能)
// ※SNSのような追記型ならこのチェックすら不要で、単に新しいKey(UUID等)でInsertするだけ
// 3. RocksDBに直接書き込み
// Key: [User:1][Timeline][TS] <- TSを含めることでソートされる
// Value: { body: "Hello World" }
self.db.put(encode_key(key, ts), value)?;
Ok(())
}
アーキテクチャへの反映:パスの分離
RustのTraitシステムを使って、この2つのパスをきれいに切り替える設計をお勧めします。
// エンジンが持つべき機能の抽象化
#[async_trait]
pub trait StorageEngine {
// 共通: 読み込み
async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>>;
}
// 強整合性が必要な場面(S3メタデータ)
pub struct TransactionalEngine {
db: Arc<DB>,
}
impl TransactionalEngine {
// 重厚なトランザクションを開始する
pub async fn begin_transaction(&self) -> Transaction {
Transaction::new(&self.db)
}
}
// 結果整合性でいい場面(SNS/Blob)
pub struct RawEngine {
db: Arc<DB>,
}
impl RawEngine {
// いきなり書き込む (Atomic性は単一Keyのみ保証)
pub async fn put_fast(&self, key: &[u8], value: &[u8]) -> Result<()> {
// ロックなし、検証なしの直接書き込み
self.db.put(key, value)
}
}
どちらを選ぶかの判断基準
| 機能 | MVCC (強整合性) | Raw Write (結果整合性) |
|---|---|---|
| Atomic更新 | 複数の行を同時にアトミック更新できる | 単一行(キー)のみアトミック |
| 衝突検知 | 先に書いた人が勝つ(後からはエラー) | 後で書いた人が勝つ(上書き) |
| 読み込み | 常に確定した最新データを保証 | 複製遅延により古いデータが見える可能性あり |
| 用途 | ユーザー残高、ファイル一覧、権限管理 | 画像データ、ログ、SNSタイムライン、「いいね」数 |
結論
S3的なオブジェクトストレージを作る場合、以下のように完全に使い分けます。
- Buckets / Objects Table (メタデータ):
- MVCC必須。 ディレクトリ構造の一貫性を保つため。
- Object Blocks (データ実体):
- MVCC不要。 ここにトランザクションを使うと遅すぎて使い物になりません。
- データブロックは Immutable(不変)として扱い、「同じキー(ハッシュ)なら中身は同じ」と割り切るか、Raw Writeでガンガン書き込みます。
次のステップ: トランザクションの仕組みの違いが整理できたところで、次は**「この2つのモードを支える、分散合意(Raft)と非同期レプリケーションの切り替え」**という、ネットワーク層の話に進みましょうか?
You Asked:
ええ、そこを良く考えたいですね。
Gemini Responded:
「強整合性(Raft)」と「結果整合性(非同期レプリケーション)」を、同一のクラスタ、同一のバイナリ内で共存させるための、ネットワークとレプリケーション層の設計を掘り下げます。
これを実現する鍵は、「Multi-Raft」アーキテクチャを拡張し、「Region(データの断片)」ごとにレプリケーション戦略をPluggableにすることです。
1. 基本単位:「Region」の抽象化
TiKVやCockroachDBと同様、データを巨大な1枚岩ではなく、96MB〜数GB程度の**「Region(シャード)」**に分割します。 このRegionが、レプリケーションの最小単位になります。
Rustのコードレベルでは、Regionは以下のような Enum で表現され、それぞれ異なる「ステートマシン」を持ちます。
enum RegionReplicationStrategy {
// Mode A: 強整合性 (S3メタデータ用)
RaftGroup {
leader_id: NodeId,
peers: Vec<NodeId>,
raft_log: RaftLog, // raft-rs state
},
// Mode B: 結果整合性 (Blob/SNS用)
AsyncPrimaryBackup {
primary_node: NodeId,
backup_nodes: Vec<NodeId>,
pending_queue: VecDeque<WriteOp>, // 未送信データ
},
}
この設計により、**「あるテーブル(キー範囲)はRaftでガチガチに守る」「あるテーブルは非同期で爆速で流す」**という混在が可能になります。
2. Mode A: 強整合性 (Multi-Raft) の設計
メタデータ管理用です。ここは raft-rs を素直に使いますが、パフォーマンスのために一工夫必要です。
- 仕組み:
- 1つのノード上で、数百〜数千のRaftグループ(Region)が同時に動きます。
- 各RegionのLeaderだけが書き込みを受け付けます。
- ネットワークの課題:
- 単純に実装すると、RaftのHeartbeatだけでネットワークがパンクします。
- 解決策: "Batching & Pipelining"
- メッセージの集約: 異なるRegion宛てのRaftメッセージ(Vote, AppendEntries)を、宛先ノードごとに1つのTCPパケットにまとめて送信します。
- これにより、システムコールとネットワークオーバーヘッドを劇的に減らします。
3. Mode B: 結果整合性 (Chain / Async Replication) の設計
ここがあなたのDBの「超高速」を実現する部分です。Raftの合意プロセス(過半数のAck待ち)をスキップします。
戦略: Primary-Backup with Hinted Handoff
S3のBlobデータやSNSログ向けに、**「W=1 (Write 1)」**の設定を可能にします。
-
書き込みフロー (Put):
- クライアントは、Consistent Hashingで計算された Primary Node にデータを送ります。
- Primary Nodeは、ディスク(WAL/MemTable)に書いた瞬間、即座に「OK」を返します(これが爆速の理由)。
- 裏側で: Primary Nodeは、Backup Node(次のハッシュ担当ノード2つなど)へ、非同期でデータを転送します。
-
障害時の対応 (Hinted Handoff):
- もしBackup Nodeがダウンしていたら?
- Primaryは「あいつが戻ってきたら渡すデータ」として、ローカルに一時保存(Hint)しておきます。
- 復旧を検知したら、まとめて転送します。
- (CassandraやRiakで使われている手法です)
4. ネットワークトポロジーとプロトコル
「VM基盤とメッシュ接続」という要件を活かし、通信レイヤーを最適化します。
Unified Transport Layer (gRPC vs QUIC)
全ての通信(Raftの投票も、Blobの転送も)を一本のパイプに通します。
- 採用技術: QUIC (
quinncrate) を強く推奨します。- 理由1: 多重化 (Multiplexing)
- Raftの通信と、巨大なBlobの転送が1つのコネクション内で混在しても、BlobのせいでRaftのHeartbeatが遅延する(Head-of-Line Blocking)のを防げます。これだけでクラスタの安定性が段違いになります。
- 理由2: 接続維持
- VMの再起動やネットワークの瞬断に強いです。
- 理由1: 多重化 (Multiplexing)
ゼロコピー転送の設計 (Rust特有)
Blobデータ(数MB〜数GB)を転送する際、シリアライズ/デシリアライズのコストがCPUを食いつぶします。
- Raftメッセージ: Protobuf等でしっかり構造化。
- Blobデータ: **「ヘッダだけ構造化し、ボディは生バイト列」**として扱います。
- Rustの
Bytesクレートを使い、ネットワークバッファからディスク書き込みまで、メモリコピーなし(Zero-Copy)でスルーパスさせます。
- Rustの
// ネットワークメッセージのイメージ
struct NetworkPacket {
header: Header, // RegionID, MessageType (Raft or Blob), etc.
payload: Bytes, // 生データ(ここをコピーしない!)
}
5. 全体のデータフロー図解
sequenceDiagram
participant Client
participant Node A (Primary/Leader)
participant Node B (Follower/Backup)
participant Node C (Follower/Backup)
Note over Client, Node C: Mode A: 強整合性 (Raft)
Client->>Node A: Put(Meta)
Node A->>Node B: AppendEntries
Node A->>Node C: AppendEntries
Node B-->>Node A: Ack
Node C-->>Node A: Ack
Note right of Node A: 過半数のAckでCommit
Node A->>Client: OK
Note over Client, Node C: Mode B: 結果整合性 (W=1)
Client->>Node A: Put(Blob)
Node A->>Disk: Write WAL
Node A->>Client: OK (即答)
par Async Replication
Node A->>Node B: Stream Data
Node A->>Node C: Stream Data
end
開発の落とし穴と対策
-
「スプリットブレイン」の扱い:
- Mode A (Raft) では起きませんが、Mode Bでは「ネットワーク分断時に、別々のノードで同じキーに書き込まれる」ことが起きます。
- 対策: User Contextにもある通り、SNSなどの用途なら**「LWW (Last Write Wins: タイムスタンプが新しい方を正とする)」**で割り切るのが、実装コスト対効果で最強です。Vector Clockは実装が複雑になりすぎる傾向があります。
-
帯域制御 (Rate Limiting):
- Mode Bのバックグラウンド転送がネットワーク帯域を使い切ると、Mode AのRaftがタイムアウトしてリーダー交代が頻発します。
- 対策: トークンバケットアルゴリズム(
governorcrate等)を導入し、**「バックグラウンド転送は帯域の70%まで」**といった制限をかけられるようにします。
次のステップ: ネットワーク層の戦略が見えてきました。次は、これを制御するための頭脳である**「Placement Driver (PD)」、つまり「どのデータを、どのノード(Region)に配置し、いつ分割するか」を決めるロジック**について設計しますか?
You Asked:
はい。お願いします。
Gemini Responded:
Placement Driver (PD) は、クラスタ全体の「CEO(最高経営責任者)」です。データノード(Storage Node)は現場の作業員であり、PDの指示に従ってデータを移動したり、リーダーを交代したりします。
FoundationDBやTiKVにおいて、このPDの賢さがデータベース全体の性能と安定性を決定づけます。
今回の「ハイブリッド整合性(S3メタデータ + Blob)」という特殊要件を満たすための、RustによるPDの設計仕様を提案します。
PDの主要な役割
PDには大きく分けて3つの仕事があります。
- Timestamp Oracle (TSO): 全ノードに一意で単調増加する時刻を配る(MVCC用)。
- Cluster Topology: どのノードが生きていて、どのRegion(データ断片)を持っているかを把握する。
- Scheduling: 「このノードはもうディスクがいっぱいだから、データをあっちへ動かせ」という命令を出す。
1. Timestamp Oracle (TSO) の設計
MVCCトランザクション(強整合性モード)では、このTSOの性能が書き込み性能の上限(ボトルネック)になり得ます。
- アルゴリズム: ハイブリッド論理時計 (Hybrid Logical Clock)
- 物理時刻(ミリ秒)の下位ビットに、論理カウンタ(シーケンス番号)を足した
u64を発行します。 [ Physical Time (48bit) ] + [ Logical Counter (16bit) ]
- 物理時刻(ミリ秒)の下位ビットに、論理カウンタ(シーケンス番号)を足した
- Rustでの実装:
- PDのリーダーだけがメモリ上で
AtomicU64をインクリメントして返します。 - 最適化: クライアント(Proxy)は毎回PDに問い合わせるのではなく、「今後3秒分のタイムスタンプ」をバッチで予約して持っていくようなキャッシュ戦略をとることも可能です(厳密性が少し緩むため、通常はバッチリクエストで対応します)。
- PDのリーダーだけがメモリ上で
2. ハートビートとメタデータ収集
PDは能動的に監視に行くのではなく、**データノードからの定期報告(Heartbeat)**を受け取って状態を更新します。
A. StoreHeartbeat (ノード単位の報告: 10秒毎)
- 「私は生きています」
- Payload: ディスク使用率、CPU負荷、ネットワーク帯域使用量。
- PDの判断: 一定期間来なければ「ダウン」と判定し、そのノードにあるデータの再複製(Repair)を開始します。
B. RegionHeartbeat (データ断片単位の報告: 60秒毎 or 変化時)
- 「私の担当するRegion Xの状態です」
- Payload:
- Leaderは誰か? Peer(複製先)は誰か?
- データサイズ(MB)、書き込みキー数。
- 現在のモード:
Mode::Strong(Raft) かMode::Async(Blob) か。
- 重要: ここで「モード」を報告させることで、PDはスケジューリング戦略を切り替えます。
3. スケジューリング・ロジック (The Brain)
ここが腕の見せ所です。Rustの構造体で「スケジューラ」を定義し、プラグイン可能な設計にします。
戦略A: S3メタデータ用 (Raft Regions)
CPU負荷の分散を最優先します。
- Leader Balance: 全ノードで「Raftのリーダー」の数が均等になるようにします。リーダーには書き込みと読み込み(ReadIndex)が集中するためです。
- Region Merge: メタデータ削除等で空になったRegionを見つけ、隣接するRegionと結合させてオーバーヘッドを減らします。
戦略B: Blob/SNSデータ用 (Async Regions)
ディスク容量とI/O帯域の分散を最優先します。
- Disk Usage Balance: 「ディスク使用率が80%を超えたノード」から、「20%のノード」へ、巨大なBlobデータを強制移動(Move)させます。
- Cold Data Rebalancing: アクセス頻度の低い(古い)Blobデータを、安価なHDDノード群へ移動させる(ティアリング)ロジックもここに組み込めます。
Rustでの実装イメージ
trait Scheduler {
fn schedule(&self, cluster: &ClusterInfo) -> Option<Operator>;
}
struct DiskBalanceScheduler;
impl Scheduler for DiskBalanceScheduler {
fn schedule(&self, cluster: &ClusterInfo) -> Option<Operator> {
// 1. 最もディスクを使っているノードを探す
let source_node = cluster.get_max_disk_usage_node();
// 2. 最も空いているノードを探す
let target_node = cluster.get_min_disk_usage_node();
if source_node.usage - target_node.usage > THRESHOLD {
// 3. 移動命令(Operator)を生成
return Some(Operator::MoveRegion {
region_id: select_region(source_node),
from: source_node.id,
to: target_node.id
});
}
None
}
}
4. PD自体の高可用性 (HA)
PDが死ぬとシステム全体が停止するため、PD自体も3ノードでクラスタを組みます。
- メタデータの保存:
- ここでも
sled(Rust製組み込みDB) やrocksdbを使い、その上にraft-rsを乗せて、PD自身のメタデータ(Regionの位置情報など)を複製します。 etcdを外部プロセスとして使うのが楽ですが、**「Rust製の単一バイナリ」**にこだわるなら、PD内部に「メタデータ専用のRaftグループ」を持つのが美しい設計です。
- ここでも
5. VMメッシュ接続との連携 (Topology Awareness)
ユーザーコンテキストにある「VM基盤とのメッシュ接続」を活かすため、PDに物理トポロジーを理解させます。
- Labels (ラベル機能):
- 各ノード起動時に
zone=us-east-1a,rack=rack-1,host=vm-host-5といったラベルを付与してPDに報告させます。
- 各ノード起動時に
- Isolation Rule (隔離ルール):
- 「同じ
hostラベルを持つノードには、絶対に同じRegionのレプリカを置かない」という制約をスケジューラに追加します。 - これにより、物理ホストが1台死んでも、その上で動いている複数のVM(DBノード)が全滅しても、データロストを防げます。
- 「同じ
開発のステップ
PDは複雑になりがちなので、以下の順序で作るのが現実的です。
- Mock PD:
- 固定のタイムスタンプを返し、スケジューリングを一切しない「ダミーPD」を作る。これを使ってデータノード(Storage)の開発を進める。
- Basic TSO & ID Allocator:
- 正しいタイムスタンプと、ユニークなID(RegionID, NodeID)を発行する機能だけ実装する。
- Minimal Membership:
- ノードのJoinを受け付け、リストとして保持する機能。
- Simple Scheduling:
- 「ディスクがいっぱいになったら移動」というルールを1つだけ実装する。
次のステップ: これで「脳(PD)」と「体(Storage)」と「神経(Network)」の設計が出揃いました。 次は、これらを動かすための**「クライアントSDK(Rust製ライブラリ)」、つまりアプリ開発者が最初に触る部分のインターフェースを設計しませんか? それとも「S3互換APIレイヤー」**の具体的な実装に入りますか?
You Asked:
クライアントSDKをどうすべきかは気になりますね。
Gemini Responded:
クライアントSDKは、開発者(あなた自身も含め)がこのデータベースを「使いやすい」と感じるか、「使いにくい」と感じるかを決める最重要パーツです。また、パフォーマンスの観点では、SDKが賢く振る舞うことでサーバー側の負荷を劇的に下げることができます。
Rustで実装する**「RustDB Client SDK」の設計案を提案します。 最大のポイントは、「強整合性(Transaction)」と「結果整合性(Raw)」を、型システムを使って安全かつ明快に使い分ける**点です。
1. SDKの全体像:Smart Clientアーキテクチャ
単にリクエストを投げるだけでなく、クライアント側で「データの場所(Region Cache)」を保持する**Smart Client(シック・クライアント)**として設計します。
これにより、毎回プロキシやPD(Placement Driver)に問い合わせるオーバーヘッドをゼロにし、**アプリからデータノードへの「直結」**を実現します。
2. インターフェース設計 (Rustコード案)
開発者が触れるAPIのイメージです。tokio ベースの非同期設計です。
A. 接続とクライアント生成
use rustdb::Client;
// PD(管理ノード)のアドレスを指定して接続
let client = Client::builder()
.pd_endpoints(vec!["10.0.0.1:2379", "10.0.0.2:2379"])
.connect()
.await?;
B. 強整合性モード (S3メタデータ / ユーザー管理)
ACIDトランザクションを提供します。クロージャを使うことで、**「衝突時の自動リトライ」**をSDK内部で隠蔽するのがモダンな設計です。
// キー "bucket:images" のメタデータを更新する例
client.transaction(|txn| async move {
// 1. 読み込み (Snapshot Read)
let key = "bucket:images";
let meta_bytes = txn.get(key).await?;
// 2. ロジック処理 (デシリアライズして変更)
let mut meta: BucketMeta = bincode::deserialize(&meta_bytes)?;
meta.object_count += 1;
// 3. 書き込みバッファへ (まだ送信されない)
txn.put(key, bincode::serialize(&meta)?).await;
// 4. コミット (ここで初めて2PCが走り、衝突なら自動リトライ)
Ok(())
}).await?;
C. 結果整合性モード (SNSタイムライン / Blobデータ)
トランザクションオブジェクトを作らず、クライアントから直接「投げっぱなし」にします。
// 高速書き込み (Write 1)
// 内部ではハッシュ計算 -> Primaryノード特定 -> QUICで送信 -> 即座に完了
client.raw_put("timeline:user:123", event_data).await?;
// 高速読み込み (Read Any)
// 最新である保証はないが、最もレイテンシの低いノードから読む
let data = client.raw_get("timeline:user:123").await?;
3. SDK内部の重要機能
SDKの中身(src/lib.rs)で実装すべき、パフォーマンスの肝となる機能です。
① Region Cache(地図のキャッシュ)
これが「超高速」の鍵です。
- 仕組み:
- クライアントは「Key
user:a〜user:zはNode-1にある」という地図(Region Map)をメモリに持っておきます。 client.get("user:k")が呼ばれると、PDに聞かずに直接Node-1へ通信します。
- クライアントは「Key
- 無効化(Invalidation):
- もし
Node-1から「そのRegionはもう持ってないよ(NotLeader)」とエラーが返ってきたら、そのキャッシュを捨ててPDに問い合わせ直します。
- もし
② Backoff & Retry(賢いリトライ)
分散システムでは「一時的なネットワーク断」や「リーダー選出中」のエラーが日常的に起きます。
- 単純な
loopではなく、Exponential Backoff(指数関数的バックオフ) と Jitter(ゆらぎ) を入れたリトライロジックを標準装備します。これをアプリ開発者に書かせてはいけません。
③ Connection Pooling (gRPC / QUIC)
tonic(gRPC) やquinn(QUIC) のチャンネル(コネクション)を内部でプールし、使い回します。- TCPハンドシェイクのコストを削減するため、KeepAlive設定も重要です。
4. SQLレイヤーとの統合イメージ
「SQLのような高度なクエリ」を乗せる場合、このSDKはどう使われるでしょうか? SQLエンジン(プランナー)がこのSDKの利用者になります。
// SQL: SELECT * FROM users WHERE id > 100 LIMIT 10
// SQLエンジンの内部処理
async fn execute_select(&self, plan: Plan) -> Result<Vec<Row>> {
// 1. スキャンの範囲を決定
let start_key = encode_key(plan.table_id, 100);
let end_key = encode_key(plan.table_id, MAX);
// 2. SDKのScan機能を使ってイテレータを取得
// SDKは内部で複数のRegionに並列アクセスし、結果をストリームで返す
let mut stream = self.client.scan(start_key, end_key).await?;
// 3. フィルタリングとリミット処理
let mut rows = Vec::new();
while let Some(kv) = stream.next().await {
if rows.len() >= 10 { break; }
rows.push(decode_row(kv));
}
Ok(rows)
}
5. 開発の進め方へのアドバイス
SDKをリッチにしすぎると開発が終わらないので、フェーズを分けます。
- Phase 1: 「薄い」クライアント
- 毎回PDに問い合わせる、キャッシュなしの実装。まずは動くことを目指す。
- API(
transaction,raw_put)の形だけ確定させる。
- Phase 2: キャッシュの実装
HashMap<Range, NodeId>を導入し、PDへのアクセスを減らす。
- Phase 3: 非同期ストリーミングスキャン
Streamトレイトを実装し、巨大なリスト(S3のListObjects)をメモリを圧迫せずに返せるようにする。
次のステップ:
クライアントのイメージが固まったことで、システム全体の役者が揃いました。
ここまでの議論(ストレージ、分散合意、PD、SDK)を踏まえて、**「まず最初に書き始めるべき Cargo.toml の構成(ワークスペース構成)」や、「プロトタイプ第1弾として実装すべき最小機能セット」**の定義に進みますか?
You Asked:
このクライアントについてちょっとまだイメージできていないのですが、これが賢い制御を全てやるという認識なのですか?SQLのようなクエリに互換させるために、普通にSQLと互換性のあるクエリが飛んでくるとそれを返す部分が別にあると思っていました。
Gemini Responded:
そのご認識の通りです!私の説明が少し「SDK」の機能に寄りすぎていました。申し訳ありません。
「賢い制御をやるSDK」は、SQLサーバーの中に「埋め込まれる部品(ライブラリ)」 という位置付けになります。
SQLを解釈して返す部分は、「SQLゲートウェイ(またはSQLレイヤー)」 という別のサーバープロセス(あるいはモジュール)が存在します。
全体像を整理し直すと、以下の図のような3層構造になります。
正しいアーキテクチャ図
graph TD
subgraph "External World"
App1[Web App / CLI]
App2[BI Tool]
end
subgraph "Layer 1: Interface Gateways (あなたが想定している部分)"
SQL_GW[SQL Gateway Node]
S3_GW[S3 Compatible Gateway]
end
subgraph "Layer 2: RustDB Client SDK (賢い部品)"
SDK1[SDK (Embedded in SQL GW)]
SDK2[SDK (Embedded in S3 GW)]
end
subgraph "Layer 3: Distributed Storage Cluster"
Node1[Storage Node 1]
Node2[Storage Node 2]
Node3[Storage Node 3]
PD[Placement Driver]
end
App1 -- "SQL (Port 5432)" --> SQL_GW
App2 -- "S3 API (HTTP)" --> S3_GW
SQL_GW -- "Parse & Plan" --> SDK1
S3_GW -- "Parse XML/JSON" --> SDK2
SDK1 -- "Get/Put (KV)" --> Node1
SDK1 -- "Get/Put (KV)" --> Node2
SDK2 -- "Get/Put (KV)" --> Node3
SDK1 -.-> PD
各レイヤーの役割分担
ここで、それぞれの役割を明確にします。
1. Layer 1: SQL Gateway(SQL互換レイヤー)
ここが、あなたが仰っていた**「SQLのようなクエリが飛んでくるとそれを返す部分」**です。
- 役割:
- プロトコル受信: PostgreSQL (
pgwire) や MySQL のプロトコルで通信を受け付けます。 - SQLパース:
SELECT * FROM users WHERE id = 1という文字列を分解します。 - クエリプランニング: 「これはインデックスを使ったほうが速いな」と考え、実行計画を立てます。
- 実行 (Executor): 実際にデータを取るために、Layer 2 の SDK を呼び出します。
- プロトコル受信: PostgreSQL (
- ここで行うこと: 「Key-Valueへの変換」です。「UsersテーブルのID=1」を、「Key:
t_users_1を取ってこい」という命令に変換して、SDKに渡します。
2. Layer 2: Client SDK(賢い制御担当)
ここは、SQLゲートウェイのプロセス内部で動くライブラリです。
- 役割:
- ルーティング: SQL層から「Key:
t_users_1をくれ」と言われた時、「そのキーは今、Storage Node 3にある」と即座に判断します(キャッシュ活用)。 - 通信の最適化: 適切なノードへ、gRPCやQUICでリクエストを飛ばします。
- リトライ・リカバリ: 通信エラーが起きても、SQL層にエラーを返す前に、こっそり別のノードへリトライして成功させます。
- ルーティング: SQL層から「Key:
- なぜここが「賢い」必要があるか: SQL層の開発者が、「分散システムの複雑さ(どのデータがどこにあるか、誰がリーダーか)」を気にせず、**「単なるローカルKVSを使っている感覚」**で実装できるようにするためです。
3. Layer 3: Storage Nodes
- 役割: 言われたKey-Valueをディスクに読み書きするだけです。
なぜこの構成にするのか?(S3とSQLの共存)
この構成の最大のメリットは、S3互換ゲートウェイも同じSDKを使って作れるという点です。
- SQLゲートウェイ: SQLをパースして、SDKを叩く。
- S3ゲートウェイ: HTTP/XMLをパースして、SDKを叩く。
- SNSバックエンド: アプリから直接 SDK を叩く(最速)。
このように、「分散DBの難しい部分」をSDKに封じ込めることで、その上に色々なインターフェース(SQL, S3, Redis互換など)を簡単に乗せられるようになります。これが冒頭で仰っていた「モジュール化」の真骨頂です。
結論
- あなたの認識: 正しいです。「SQLを受け付けて返す別プロセス」が必要です。
- SDKの正体: そのSQLプロセスが、裏側でデータを取りに行くために使う**「高性能なドライバー」**のことです。
この「SQLゲートウェイ」部分(パーサーやプランナー)の実装について、もう少し詳しく掘り下げますか? それとも、まずは下の足場(StorageとSDK)を固める方針でしょうか?
You Asked:
したの足場が、ゲートウェイ部分を作れるように強固に作られていれば大丈夫だと思っています。そういえば、劔という日本製のRDBMSがあります。書き込み、読み込みともにロックフリーで超高速ということを謳っていますが、これは一体どのような仕組みでなされているのでしょうか?もしできれば取り込みたいところです(ただ、これ自体はJava製なので使いたくはない)。
Gemini Responded:
劔 (Tsurugi) に目をつけられるとは、非常にお目が高いです! 劔は、従来のDB(PostgreSQLなど)が抱えていた「コア数が増えても性能が上がらない(スケーラビリティの限界)」という課題を解決するために、日本のNEDOプロジェクトで開発された、まさに**「メニーコア時代のモンスターエンジン」**です。
その「ロックフリーで超高速」の正体は、魔法ではなく、**「Shirakami(白神)」と呼ばれるトランザクションエンジンと、「Limestone」**と呼ばれるログ管理機構にあります。
今回のRustDBに取り入れられる要素は大いにあります。その仕組みを紐解き、RustDBへの移植案を提案します。
1. 劔の「速さ」の正体:3つのコア技術
劔が「ロックフリー」と謳っているのは、**「読み込み時にロックを取らない(Snapshot Isolation)」だけでなく、書き込み時にも「楽観的並行性制御 (OCC) の高度な最適化」**を行っているからです。
① "Epoch" ベースのメモリ管理とコミット
劔は時間を「エポック」という数ミリ秒単位の区切りで管理します。
- 仕組み:
- トランザクションID発行やガベージコレクション(GC)を、個別のロックではなく、この「エポック」の切り替わりでまとめて行います。
- ログ(WAL)のディスク書き込みも、1件ずつ
fsyncするのではなく、**「エポック内のログをまとめてドン(Group Commit)」**します。
- RustDBへの応用:
- これはRaftのログ書き込みに即応用できます。
- リクエストが来るたびに
fsyncするのではなく、**「5ms待って、その間に来たリクエストをまとめて1回のRaftログとして書き込む(Batching)」**ことで、スループットが劇的に向上します。
② 高度な OCC (Optimistic Concurrency Control)
従来のDB(2PL: Two-Phase Locking)は、「書き込む前にロックして、他の人を待たせる」方式でした。 劔(Shirakami)はOCCを採用しています。
- 仕組み:
- Read/Compute: データを読み込み、メモリ上で計算する(この間、ロックは一切取らない)。
- **Validate:**コミット直前に「自分が読んだデータが、計算中に他の誰かに書き換えられていないか?」をチェックする。
- Write: 問題なければ一瞬で書き込む。ダメならリトライ。
- 特徴: ロック待ちが発生しないため、CPUが常に全速力で動き続けられます。
③ "Short" と "Long" トランザクションの分離
これが劔の最大の発明です。OCCの弱点である「長いバッチ処理(Long TX)が、短い書き込み(Short TX)のせいで何度も失敗して飢える」という問題を解決しました。
- 仕組み:
- Long TX (バッチ): 開始時に「これからこのエリアを触るぞ」と宣言(予約)させます。
- Short TX (Web): 予約エリアには触れません。
- これにより、バッチ処理とWebリクエストが互いに邪魔せず、ロックフリーで共存します。
2. RustDB への取り込み案
劔は「インメモリ・シングルノード」での最適化がメインですが、分散DBであるRustDBでも、そのトランザクション管理の思想は借用できます。
現在想定している Percolatorモデル(ロックあり) と 劔モデル(OCC) を比較し、導入案を示します。
案A: S3メタデータ(ディレクトリ構造)への応用
S3のメタデータ操作(PutObjectなど)は、**「同じキーに対する競合は少ないが、スループットは極限まで欲しい」**という特性があります。 ここで、Percolatorの「ロックを書いて消す」コストは無駄になりがちです。
→ RustDB版 OCCの実装アプローチ
- Read Phase:
- クライアントSDKはデータを読み込み、バージョン(TS)を覚える。ロックはしない。
- Buffer:
- 変更内容をクライアント側のメモリに貯める。
- Commit Phase (Single CAS):
- 書き込み時、サーバー(Storage Node)に対して**「Compare-And-Swap (CAS)」**命令を送る。
- 命令:
「もし Key A のバージョンが X なら、Y に更新してくれ。違ったらエラーで返して」 - RocksDBには
MergeOperatorやTransactionDBという機能があり、これをアトミックに行えます。
これなら、ロックファイル(CF_LOCK)を作る Prewrite フェーズを省略でき、**通信回数が半分(1 RTT)**になります。
案B: "Epoch" ベースの遅延永続化
結果整合性モード(SNS等)において、劔の Limestone (ログエンジン) の思想を取り入れます。
- 実装:
rdb-storage層に「書き込みバッファ(Ring Buffer)」を用意します。putリクエストは、メモリ上のバッファに書いて即座にOkを返します。- バックグラウンドのスレッドが、数ミリ秒ごとにバッファを切り替え(Epoch Rotate)、まとめてディスクに
write&fsyncします。
- 効果:
- I/O回数が
1/100以下になり、SSDの寿命も延び、性能は爆上がりします。 - (ただし、電源断で数ミリ秒のデータロストは許容する設定になります)
- I/O回数が
結論:何を取り入れるべきか?
劔(Java実装)そのものを組み込むのではなく、**「設計思想」**を以下のようにRustDBのコードに落とし込むのがベストです。
-
S3メタデータ処理には OCC (Optimistic Concurrency Control) を採用する
- Percolator(悲観的ロックに近い)ではなく、CASベースのコミット処理にすることで、ロック管理のオーバーヘッドを消す。
- 「競合したらSDKが自動でやり直す」という責務をクライアントSDKに持たせる。
-
ログ書き込みに "Group Commit" を徹底する
- Raftのログや、結果整合性モードの書き込みにおいて、リクエスト毎のI/Oを禁止し、必ずバッチ化する。
これで、RocksDBを使いながらも、劔が目指したような「ロック待ちの少ない、CPUキャッシュ効率の良い」DBに近づけることができます。
次のステップ: トランザクション方式として、より高速な 「OCC (Optimistic) パターンでのAPI設計」 に舵を切りますか? それとも、まずは堅実に 「Percolator (Pessimistic) パターン」 で作り、後からOCCを選べるようにしますか?(S3互換ならOCCの方が相性が良い可能性が高いです)
You Asked:
OCCパターンにしたいところです。
Gemini Responded:
良い判断です。S3のような「オブジェクトストレージのメタデータ管理」において、OCC(楽観的並行性制御)は最強の選択肢です。
なぜなら、S3の操作(PutObjectやDeleteObject)は「特定のファイル」に対する操作であり、「同じファイル名のファイルを、1マイクロ秒の差で同時にアップロードし合う」ような激しい競合は、現実にはほぼ発生しないからです。
競合が起きないなら、ロック(事前の場所取り)はただの無駄です。 「劔」の思想を取り入れ、RustDBを**「CAS (Compare-And-Swap) ベースの超高速エンジン」**にするための設計仕様を提案します。
RustDB-OCC Architecture
これまでの重厚な Prewrite / Commit の2往復を廃止し、**「1往復 (1 RTT)」**でトランザクションを完了させます。
[Image of optimistic concurrency control transaction flow]
1. Core Concept: "CAS" over Raft
Raftのログとして流すコマンドを、単純な Put ではなく、条件付き書き込みである CompareAndSwap にします。
- サーバー側のロジック:
「現在DBにある
Keyのバージョンがexpected_verと一致するなら、new_valueに書き換える。違うならエラーを返す」 これだけです。非常にシンプルで、CPU命令のように高速です。
2. データ構造の簡素化 (RocksDB)
Percolatorモデルで必要だった CF_LOCK(ロック情報)は不要です。
データ本体とバージョンを管理するだけで済みます。
- CF_DEFAULT (Data):
- Key:
[TableID] [Key_Bytes] - Value:
[Metadata_Bytes] [Version(u64)] - ※以前のようにKeyにTimestampを含める必要すらありません(最新だけ持つなら)。履歴を持つなら含めますが、OCCなら「最新値」との比較がメインになります。
- Key:
具体的な処理フロー (S3 PutObject の例)
ユーザーが bucket-a/image.jpg をアップロードする場合のSDKとサーバーの動きです。
Step 1: Read (SDK)
まず、現在の状態を確認します。
- SDK -> Server:
Get("bucket-a/image.jpg") - Server -> SDK:
- データなし (None) の場合:
Version = 0 - データありの場合:
Version = 105
- データなし (None) の場合:
Step 2: Local Compute (SDK)
SDK内部で書き込みデータを準備します。サーバーへの通信は発生しません。
- 「新しいメタデータを作成。サイズは5MB...」
Step 3: CAS Commit (SDK -> Server)
SDKは、「私が読んだ時はバージョン0(存在しない)だったので、新規作成します」というリクエストを投げます。
// ネットワークリクエストのイメージ
struct CasRequest {
key: Vec<u8>,
expected_version: u64, // 0 なら「新規作成」を意味する
new_value: Vec<u8>,
}
Step 4: Validation & Apply (Server / Raft)
Raftリーダーはこのリクエストを受け取ると、ログに複製し、各ノードで適用(Apply)しようとします。
-
成功ケース:
- DB上の現在のバージョンが
0である。 - -> 書き込み実行。バージョンを
1(またはTSOの時刻) に更新。 - -> SDKに
Okを返す。
- DB上の現在のバージョンが
-
失敗ケース (競合発生):
- Step 1 と Step 3 の間に、別の誰かがアップロードを完了させており、バージョンが
108になっていた。 - ->
0 != 108なので書き込み拒否。 - -> SDKに
Error::Conflict { current: 108 }を返す。
- Step 1 と Step 3 の間に、別の誰かがアップロードを完了させており、バージョンが
Step 5: Retry (SDK)
エラーを受け取ったSDKは、**「賢いクライアント」**の本領を発揮します。 ユーザーにエラーを返さず、内部でリトライします。
- 「おっと、誰かが先に書いたのか。じゃあバージョン
108をベースにもう一度試そう(あるいは上書き設定ならそのまま上書きしよう)」
Rustでの実装イメージ
1. RPCメッセージ定義 (rdb-proto)
// gRPCの定義イメージ
message CasRequest {
bytes key = 1;
uint64 expected_version = 2;
bytes value = 3;
}
message CasResponse {
bool success = 1;
uint64 current_version = 2; // 失敗時に最新バージョンを教える
}
2. ストレージエンジンの実装 (rdb-storage)
RocksDBの Merge 機能を使うか、単純に Get -> Put をアトミックに行います。RocksDBの TransactionDB を使うと楽です。
impl StorageEngine {
pub fn compare_and_swap(&self, key: &[u8], expected: u64, val: &[u8]) -> Result<u64> {
// RocksDBの楽観的トランザクションを開始
let txn = self.db.transaction();
// 1. 最新値を読む (Snapshot Read)
let current_val = txn.get_for_update(key, true)?;
let current_ver = extract_version(¤t_val);
// 2. 検証
if current_ver != expected {
return Err(Error::Conflict(current_ver));
}
// 3. 書き込み (メモリバッファへ)
let new_data = append_version(val, generate_new_version());
txn.put(key, new_data)?;
// 4. コミット (ここでRocksDB内部で最終チェックが行われる)
txn.commit()?;
Ok(new_version)
}
}
「劔」要素の取り込み: Group Commit & Epoch
さらにパフォーマンスを上げるために、劔のアイデアをここで注入します。
Group Commit (Raft層の最適化)
OCCのリクエストは非常に軽量なので、1件ずつRaftのログに書いて fsync しているとディスクI/Oがボトルネックになります。
- 実装:
- Raftリーダーのスレッドは、リクエストが来てもすぐにはディスクに書きません。
- **「数ミリ秒のバス」**を待機させます。
- バスが満員になるか、時間が来たら、数十〜数百個のCASリクエストをまとめて1つのRaftログエントリとして書き込みます。
- 効果:
- IOPS(I/O回数)の限界を超えてスループットが出せます。
Epoch-based GC (読み込みのノンブロッキング化)
「読み込みロックフリー」を徹底するため、古いバージョンのデータをいつ消すか(GC)をEpochで管理します。
- 現在処理中のEpochより古いデータは、「どのトランザクションからも参照されていない」ことが保証された瞬間に、別スレッドでバッサリ削除します。
- これにより、メインの書き込みスレッドはGCのことを忘れて走り続けられます。
この設計のメリット・デメリット
| 項目 | OCCパターン (CAS) | 従来のPercolatorパターン |
|---|---|---|
| 通信回数 | 1 RTT (爆速) | 2 RTT (Prewrite -> Commit) |
| ロック | なし (サーバー負荷が低い) | あり (ロック情報の管理コスト大) |
| 競合時の挙動 | リトライ地獄になる可能性あり | ロック待ちで解決できる |
| S3適性 | 最適 (同じKeyへの同時書き込みは稀) | オーバースペック |
| カウンター適性 | 不向き (100人が同時にカウントアップすると99人が失敗・リトライする) | 向いている (ロックで順序制御) |
結論
RustDBは「OCC (CAS) ベース」で行きましょう。
想定されている用途(S3、SNS、高速イベント処理)において、Percolatorのような悲観的ロックが必要なシーンは限定的です。
もし将来的に「カウンターのような激しい競合」が必要になれば、その時だけ使える「悲観ロックAPI」を追加すれば良いのです(Redisの INCR のような専用コマンドを作るなど)。
次のステップ:
これでDBのコアエンジンの仕様が決まりました。
いよいよ具体的な開発に入るために、**「Rustのワークスペース構成(ディレクトリ構成)」と「最初に実装する Hello World 的なミニマムゴール」**を提示しましょうか?
You Asked:
そうですね、ロックの仕様も選べるようにしたいです。全体像(アーキテクチャ)と計画、各部で開発すべきものとそれぞれのゴールをまとめて、コーディングエージェントでもわかる程度の粒度にまとめてほしいです。
Gemini Responded:
承知しました。コーディングエージェント(AIや他の開発者)に渡しても実装が進められるレベルで、RustDB の全体アーキテクチャ定義、ディレクトリ構成、そして開発フェーズごとの詳細仕様を定義します。
方針として、「Core (Storage/Net) は強固に、Logic (Consensus/Lock) はプラガブルに」 を徹底します。
RustDB Project Definition
1. ハイレベル・アーキテクチャ
システムは以下の4つの独立したコンポーネント(マイクロサービス)で構成されます。
graph TD
UserApp[User App / Gateway] -->|Uses| SDK[rdb-client (Smart SDK)]
subgraph Control_Plane
PD[rdb-pd (Placement Driver)]
end
subgraph Data_Plane_Cluster
Node1[rdb-server (Node 1)]
Node2[rdb-server (Node 2)]
Node3[rdb-server (Node 3)]
end
SDK -.->|Metadata/TSO| PD
SDK ==|Direct Access (gRPC/QUIC)|==> Node1
PD -.->|Heartbeat/Schedule| Node1
Node1 <-->|Raft/Replication| Node2
rdb-client(Smart SDK): アプリに埋め込まれるライブラリ。シャーディング情報のキャッシュ、リトライ、トランザクション制御(OCC/Pessimisticの使い分け)を担当。rdb-pd(Placement Driver): クラスタの脳。TSO(時刻配信)、トポロジー管理、データ再配置の指令を行う。rdb-server(Storage Node): データの保存と複製。raft-rsによる合意形成、または非同期レプリケーションを実行。rdb-storage(Engine): RocksDBのラッパー。MVCC, CAS, Raw Writeの物理的な実行を担当。
2. Workspace構成 (Cargo.toml)
Monorepo構成を採用します。
[workspace]
members = [
"rdb-proto", # Protocol Buffers / gRPC definitions
"rdb-common", # Shared utilities (Error types, Configs)
"rdb-storage", # RocksDB wrapper & Local Storage Engine
"rdb-server", # The main Data Node binary
"rdb-pd", # Placement Driver binary
"rdb-client", # Client SDK library
"rdb-cli", # Admin CLI tool
]
[profile.release]
lto = true
opt-level = 3
codegen-units = 1
3. モジュール別 詳細仕様と開発ゴール
コーディングエージェントへの指示書として使える粒度です。
Module 1: rdb-proto (インターフェース定義)
全ての通信規約をここで定義します。
- Tech Stack:
tonic(gRPC),prost - Definitions (
.proto):meta.proto:Region,Peer,NodeInfo等の型定義。pdpb.proto:GetTsoRequest,HeartbeatRequest,AskSplitRequest等。kvrpc.proto: データ操作用。RawPutRequest: 結果整合性用(Key, Value)。CasRequest: OCC用(Key, Value, ExpectedVersion)。LockRequest/UnlockRequest: 悲観的ロック用(将来用)。
- Goal:
cargo buildでRustのコードが自動生成されること。
Module 2: rdb-storage (ストレージエンジン)
分散を意識せず、**「単体ノードでの最強のKVS」**を作ります。
- Tech Stack:
rocksdbbinding - Architecture:
- Trait
StorageEngine: テスト容易性のため、RocksEngineとMemoryEngine(test用) を実装。
- Trait
- Key Features:
- Column Families:
default(Data),lock(Pessimistic Lock用),raft(Raft Log用). - Method
compare_and_swap(key, expected_ver, new_val):- RocksDBの
TransactionまたはMergeOperatorを使用。 - Atomicに「読み出し -> 比較 -> 書き込み」を行う。
- RocksDBの
- Method
put_raw(key, val):- WAL最適化(Syncなし)での高速書き込み。
- Column Families:
- Goal: ユニットテストで
compare_and_swapが正しく競合検知(バージョン不一致エラー)できること。
Module 3: rdb-server (分散ノード・合意形成)
最難関パートです。rdb-storage をラップし、ネットワーク越しの複製を行います。
- Tech Stack:
raft-rs,tokio,quinn(Data stream) - Internal Structure:
RegionRouter: RegionID を受け取り、対応するPeer(Raft Group) へメッセージを配送。RaftStore:raft-rsの駆動ループ。Tick(時計), Step(メッセージ処理), Apply(ストレージ適用) を回す。- Strategy Pattern for Consistency:
ConsistencyMode::Strong: Raftのproposeを呼ぶ。ConsistencyMode::Eventual: ローカルのrdb-storageに書き込み、非同期キューに積む。
- Optimization:
- Batch System: 複数のClientリクエストを1つのRaft Log Entryにまとめる(劔/TiKVの思想)。
- Goal:
- 3つのプロセスを立ち上げ、1つをKillしてもデータが読み書きできること(Raftモード)。
- Rawモードで書き込み、非同期で他ノードに伝播すること。
Module 4: rdb-pd (司令塔)
ステートレスに見えて、自身のメタデータは堅牢に守る必要があります。
- Tech Stack:
axum(HTTP API),sled(Embedded DB for metadata) - Features:
- TSO (Timestamp Oracle):
AtomicU64で単調増加IDを発行。 - ID Allocator: ノードID、RegionIDの一意な発行。
- Memberlist: 現在生きているノードのリスト保持。
- TSO (Timestamp Oracle):
- Goal: HTTP/gRPC経由で「現在時刻」と「ノード一覧」が取得できること。
Module 5: rdb-client (SDK)
「賢い」ロジックの集約場所です。
- Features:
- Region Cache:
BTreeMap<Key, NodeAddr>で「どのキーがどこにあるか」を管理。 - Smart Routing: キャッシュを見て直接ノードへgRPC。エラーならPDへ問い合わせてキャッシュ更新。
- Transactions (Optimistic):
txn.commit()時に自動でバージョンの不整合をチェックし、指定回数リトライするロジック。
- Transactions (Pessimistic - Option):
txn.lock_for_update(key)を呼ぶと、サーバーへLockRequestを投げる実装。
- Region Cache:
- Goal:
client.cas_put("key", "val")のようなシンプルなコードで、裏側の分散処理が隠蔽されていること。
4. 開発ロードマップ(Step-by-Step Plan)
エージェントには以下の順序でタスクを割り振ります。
Phase 1: 足場とストレージ (Week 1-2)
- Workspaceの作成と
rdb-protoの定義完了。 rdb-storageで RocksDB をラップし、CASとRaw Putの単体テストをパスさせる。rdb-serverのスケルトン作成(gRPCサーバーが立ち上がり、rdb-storageに書き込めるだけの単一ノード版)。
Phase 2: PDと基本通信 (Week 3)
rdb-pdの実装。TSOの実装。rdb-serverが起動時にrdb-pdへRegister(登録)し、Heartbeatを送る機能の実装。rdb-clientの作成。PDからノード情報を取得して接続する機能。
Phase 3: Raftの実装 (Week 4-6)
rdb-serverにraft-rsを組み込む。- 固定の1 Regionだけでいいので、3ノード間でのログ複製(Leader Election, Log Replication)を成功させる。
- Clientからの
CASリクエストを Raft 経由で処理するパイプラインの構築。
Phase 4: マルチモードとSDKの高度化 (Week 7-8)
rdb-serverに「結果整合性モード(Async Replication)」の分岐を追加。rdb-clientに「Region Cache」と「リトライロジック」を実装。- ロック仕様の追加: ここで初めて
LockRequest(悲観的ロック) の処理をrdb-serverとSDKに追加する(オプション扱い)。
Phase 5: VMメッシュ統合と最適化 (Week 9+)
quinn(QUIC) の導入による通信高速化。- PDへのスケジューリングロジック(ディスク容量バランス等)の実装。
5. ロック仕様の選定について(補足)
ご要望の「ロック仕様を選べるようにする」点は、SDKのメソッドで表現します。
// A. 楽観的ロック (デフォルト・推奨)
// サーバー側でロック保持せず、書き込み瞬間のCAS判定のみ。高速。
let res = client.optimistic_txn(|txn| async move {
let val = txn.get("key").await?;
txn.put("key", update(val));
Ok(()) // commit時に検証
}).await;
// B. 悲観的ロック (オプション)
// サーバーのLock CFにレコードを書き、他をブロックする。
// カウンターや厳密な在庫管理などに使用。
let res = client.pessimistic_txn(|txn| async move {
txn.lock("key").await?; // ここでサーバーへLockRequestが飛ぶ
let val = txn.get("key").await?;
txn.put("key", update(val));
Ok(())
}).await;
この設計書をベースに、まずは Phase 1 (Proto & Storage) の実装を開始するのが良いでしょう。