このブログは、siddontangのHow We Optimize RocksDB in TiKV — Write Batch Optimizationの抄訳です。翻訳はAIによる翻訳をベースに、@bohnenが担当しました。
Write Batchの処理は、RocksDBの書き込みパスの中核を担っています。分散トランザクションをサポートするKey-ValueストアであるTiKVは、予測可能で低レイテンシな書き込み性能に依存しています。しかし残念ながら、RocksDBのデフォルトの書き込み調整モデルは、並行処理において深刻なパフォーマンス問題を引き起こします。
本記事では、その理由と、TiKVのマルチバッチ書き込み最適化がどのようにこの問題を解決するかを説明します。
デフォルトの調整書き込みモデル
歴史的に、RocksDBはモノリシックでシリアライズ(逐次実行)な書き込みモデルを使用していました:
- 1つのスレッドがリーダーとなる
- 他のすべての書き込みスレッドはフォロワーとなる
- リーダーが書き込みグループ全体を代表して、すべての重要な作業を実行する
- グローバルDB mutexがプロセス全体を保護する
これにより正確性はシンプルになりますが、スケーラビリティが大幅に制限されます。
デフォルト調整書き込みのフェーズ
- グローバルDB Mutexの取得: リーダーがDB全体のロックを取得し、他のすべての書き込みグループやメタデータ更新をブロックします。
- シーケンス番号の割り当て: リーダーが_マージされたWriteBatch全体_に対して連続したシーケンス番号の範囲を割り当てます。
- WALへの書き込み: リーダーがグループ全体のバッチをWALファイルにシリアライズします。
- MemTableへの書き込み: リーダーがすべてのキー・バリューペアをMemTableに1つずつ挿入します。
- 可視シーケンス番号の更新: mutexを保持したまま、リーダーがlast_visible_seqを更新し、グループ全体をアトミックに可視化します。
- Mutexの解放とフォロワーへの通知: フォロワーが最終的に成功を返します。
このアプローチはシンプルで正確、かつ決定的です。しかし、高い並行性の下では極めて遅くなります。主に以下のようなボトルネックがあります:
1. Head-of-Line (HOL) ブロッキング
書き込みグループに以下が含まれる場合:
- Batch A → 50,000キー
- Batch B → 5キー
両方のバッチが、Batch Aの高コストなMemTable書き込みが完了するまで待機する必要があります。
1つの遅い書き込みが_すべての_書き込みをブロックします。
2. 重度のMutex競合
グローバルDB mutexは以下をシリアライズします:
- 書き込み調整
- MemTableの切り替え
- バージョニングメタデータ
- コンパクションのスケジューリング
負荷がかかると、スレッドは書き込みよりも_待機_に多くの時間を費やします。
3. 書き込みストールの増幅
バックプレッシャーが書き込みストールを引き起こした場合:ストールはリーダーだけでなく、_書き込みグループ全体_に影響します。
これにより、テールレイテンシが劇的に増加します。
これらの問題は、元のRocksDBアーキテクチャの根本的なものです。
そこで、RocksDBはこれらを緩和するためのいくつかの新しいオプションを導入しました。
allow_concurrent_memtable_write: 部分的な修正
このオプションを有効にすると、フォロワーはリーダーを待つ代わりに、自分のバッチを並行してMemTableに書き込むことができます。
これはステップ4(「MemTableへの書き込み」)を並列化しますが:
- WAL書き込みは依然としてシリアライズされる
- リーダーは依然として_すべての_スレッドが完了するまで待機する
- HOLブロッキングは軽減されるが、完全には解消されない
これは改善ですが、TiKVのような高並行システムには十分ではありません。
enable_pipelined_write: 真のアーキテクチャシフト
このオプションは、RocksDBの書き込みパスを根本的に変更します。
モノリシックな書き込みグループの代わりに、2段階のパイプラインを導入します:
ステージ1 — WAL書き込み
リーダーがグループ全体をWALに書き込みます。
この後、スレッドはWALステージから解放されます。
ステージ2 — MemTable書き込み
別の「MemTableライター」のセットがバッチをMemTableに書き込みます。
重要なのは:
グループAがMemTableに書き込んでいる間、
グループBはすでにWALに書き込むことができる。
これにより、WAL I/OとMemTable書き込みを分離することで、スループットが劇的に向上します。
しかし、これは新たな問題を生み出します — パイプラインバブル問題(負荷の不均衡)です。
WAL書き込みとMemTable書き込みが並行して実行されるようになると、新しいクラスのパフォーマンス異常が発生します:
シナリオ:
- Batch A(50,000キー)がパイプラインに入る → seq 100–50,099が割り当てられる
- Batch B(5キー)がパイプラインに入る → seq 50,100–50,104が割り当てられる
- 両方のバッチが**ステージ1(WAL)**を完了
- ステージ2(MemTable): Batch Bは_すぐに_完了、Batch Aははるかに長くかかる
- コミットパイプラインはlast_visible_seqを50,104に更新したい
- しかしできない、なぜならBatch A(より早いシーケンス番号を持つ)が完了していないから
結果:
- Batch BはMemTableに書き込まれているが可視化されていない
- 可視シーケンスがスタックしている
- すべての後続バッチがストールする
- システムは「忙しいが進行していない」ように見える
- 一部の操作が極端なテールレイテンシを経験する
これが「パイプラインバブル」問題です:
速いバッチが遅いバッチの後ろで待たされる — WALパイプラインが完全に並列であるにもかかわらず。
TiKVにとって、これは壊滅的です。
なぜこれがTiKVにとって重要なのか
TiKVはRocksDBを使用して、Raftステートマシン内にMVCCデータを保存します。
Raftの「Apply」スレッドは:
- コミットされたログエントリをRocksDBに適用する
- 迅速かつ予測可能に実行される必要がある
- そうでないと、Raftのコミットレイテンシが増加する
- それが_すべての_分散トランザクションを遅くする
RocksDB内のパイプラインストール → Raftのストール → TiKVのストール。
したがって:
TiKVにとって、パイプラインバブルの解消はオプションではありません。
これは正確性とパフォーマンスの根本です。
enable_unordered_write: TiKVにとっての選択肢ではない
このオプションは、正確性を犠牲にして大量のスループットを提供します:
- アトミックな可視性なし
- 完全に一貫したスナップショットなし
- 「自分が書き込んだものを読む」のみが保持される
これはTiKVのMVCCとスナップショット分離モデルと互換性がありません。
そのため、TiKVは常にenable_unordered_write = falseを設定します
つまり:
TiKVは正確性、パイプラインの並列性、公平性を同時に維持する必要があります。
残された道は1つだけです:
パイプラインバブル問題を修正する。
TiKVのソリューション: enable_multi_batch_write
このTiKV固有の最適化は、パイプライン書き込みアーキテクチャを根本的に改善します。
これは「負荷の不均衡」問題を解決するために明示的に設計されました。
参照実装:
👉 https://github.com/tikv/rocksdb/pull/286
👉 https://github.com/tikv/tikv/issues/12898
マルチバッチ書き込みが行うこと
これは、システムを単純な2段階パイプから調整されたコミットスケジューラに変換します。
主要なアイデア:
- 各ライターが独自のタスクキューを持つ: (キューを共有するMultiWriteBatchと比較)
- ライターはリクエストキューに入る際に順序を確認する: これにより正確性が保持されます。
- HEADライターが最初に完了した場合 → すぐにコミットする: 元のパイプラインコミットと同じ。
-
FOLLOWERが最初に完了した場合:
- HEADライターにまだ保留中のタスクがあるかチェックする
- ある場合、自発的にHEADライターのタスクの処理を手伝う
- この「ヘルパーモード」により、速いバッチが遅いバッチを支援する
- HEADとヘルパーの両方が完了したら:完了したライターをキューから削除し、すべてのヘルパーを起こし、新しいライターが進行できるようにする
言い換えれば:
大きなバッチはもはや小さなバッチをブロックしない。
小さなバッチが大きなバッチの完了を手伝う。
パイプラインはスムーズに流れ続ける。
これがパイプラインバブルを解消するものです。当社の内部ベンチマークと実際のユーザーケースでは、書き込みのテールレイテンシが40%以上削減されました。
これはマイクロ最適化ではありません。
TiKVのストレージエンジンの動作にとって基礎的な要件です。
マルチバッチ書き込みは、書き込みパイプラインを公平でバランスが取れ、常に前進し続ける「オーケストレーター」です。
最後に
RocksDBの書き込みパイプラインは、長年にわたって大幅に進化してきました。
しかし、TiKVのような大規模システムは、その設計を極限まで押し上げます。
TiKVのマルチバッチ書き込みは、基盤となるストレージエンジンの深い理解により、_正確性を犠牲にすることなく_パフォーマンスのボトルネックを修正できる素晴らしい例です。
そして、これこそがRocksDBを分散データベーススケールで真に本番環境対応にするという意味です。
TiDB Xはどのように書き込みを改善するか?
上記のすべての最適化は、正確性を維持しながらRocksDBを限界まで押し上げることに関するものです。
しかしTiDB Xでは、書き込みパスをより根本的に再考する機会があります。
TiDB XはまだLSMツリーを使用しているため、MemTable、SST、コンパクションといった概念は消えません。
しかし、TiDB Xはオブジェクトストレージ上に構築され、計算とストレージを分離しているため、書き込みパスは大きく異なり、先ほど説明した多くの問題がはるかに軽減されるか、完全に消失します。
主な違いは以下の通りです。
1. Region専用のLSMツリー
TiDB Xでは
各Regionが独自の専用LSMツリーを持つ。
これにより、いくつかの即座の利点がもたらされます:
- 異なるRegionへの書き込みが自然に分離される
- Region間での書き込みバッチの干渉がない
- Region間でマルチバッチ書き込みのような複雑な公平性ロジックが不要
- 書き込みの並行性がRegionの数に応じて自然にスケールする
書き込みパイプラインは、よりシンプルで予測可能になり、水平方向のスケールが容易になります。
2. Raft LogがWALになる
従来のTiKV + RocksDBでは:
- すべての書き込みが2つの永続化パスを通過します:
- Raftログ(レプリケーション用)
- RocksDB WAL(ローカル永続化用)
この重複により、レイテンシ、IO、調整の複雑さが増加します。
TiDB Xでは:
Raftログが実質的にRocksDB WALを置き換える。
RocksDBのための個別のWAL書き込みはありません。これにより、書き込みパイプラインが劇的に簡素化されます:
- 1回少ないfsync
- 書き込み増幅の削減
- WAL関連のストールなし
- より低い書き込みレイテンシ
正確性は保持されます。なぜなら、Raftがすでに永続性と順序保証を提供しているからです。
3. 大きな書き込みバッチがオブジェクトストレージに直接保存される
巨大な書き込みバッチに対する別の課題として、TiDB Xはこれを異なる方法で処理します。
TiDBがTiKVに大きな書き込みバッチを送信する場合:
- ペイロードがオブジェクトストレージに直接書き込まれる
- MemTableはそのデータへの**参照(メタデータ)**のみを保存する
- フラッシュ中に、データがSSTに実体化される
言い換えれば:
MemTableは軽量なインデックスになり、データバッファではなくなる。
この設計にはいくつかの利点があります:
- MemTableがより小さく安定する
- フラッシュ頻度が削減される
- 大きな書き込みが小さな書き込みをブロックしなくなる
- 書き込みレイテンシがバッチサイズに対して敏感でなくなる
これにより、先ほど説明したパイプラインバブル問題の多くが自然に回避されます。
TiDB Cloudを試してみる
TiDB Xは単一の「賢い最適化」に依存していません。
代わりに、問題の形を変えるのです。
- これらの最適化が実際のワークロードでどのように動作するかを探索してみてください — TiDB Cloudを無料でお試しください。
- TiDB Cloudでビジネスを始める準備ができましたか — TiDB Cloud Essentail 101をご覧ください
- AIアプリの構築方法に興味がありますか — TiDB Cloud AIをご覧ください