Raftにおけるクエリ最適化の難しさ

RaftでReadをどう考えればよいか

では、Raftにおけるクエリ最適化について論文から抜粋して解説したが、 実際にはこのアプローチは実装が簡単ではない。

説明のため、まずはRaftの基本に立ち戻る。

Raftはコマンドの実行順序について合意をとるアルゴリズムである。

  1. コマンドはまずリーダーに送られる。
  2. リーダーはこれをフォロワーに複製する。
  3. 複製が進み、クラスタ内の過半数にコマンドが複製され永続化されたことを確認すると、このコマンドは「コミットされた」ということになる。
  4. コミットされたコマンドは順に実行される。
  5. ユーザに対してackする。

ここでは例えば、リーダーは以下のようなデータを持つとする。

  1. 複製管理用テーブル:Map<NodeId, Index>
  2. 確認したコミットインデックス commit_index: Index
  3. どこまで実行したか last_applied: Index

どうやって実装するかだが、 ふつうに考えると、1,2,3について 独立した非同期処理が定期タイマー実行し、データを更新していく ような設計を考えることになる。 これには例えば、tokio::time::Intervalを使うことが出来る。

このアプローチの良い点は、それぞれの処理が分離されるので コードが小さくなることと、 Tokioの協調的マルチタスクには適することだ。 これは、協調的マルチタスクでは一つ一つの処理は短くする必要があるためである。

しかし、このアプローチには問題もある。 クライアントからコマンドを受け取ってからユーザにackするまでの レイテンシが、タイマー実行の間隔に依存してしまうことだ。 例えばそれを100msとすると、1つのコマンドを実行するまでに200-300ms程度の レイテンシを見込むことになる。 これは、アプリケーション要件にも依るだろうが、一般的には許容しづらい。

しかし、この問題に対しては解決のアプローチがある。 例えば、それぞれの非同期処理の間で「終わったよ」という通知を飛ばして、 受け取った方は、通知を受け取った場合にはタイマーによる起床を待たずにして すぐに実行を開始する。 この実装には例えば、tokio::sync::Notifyを使うことが出来る。

このような仕組みの中で、 副作用のあるコマンドと副作用のないコマンドを統一的に実行することが可能である。

クエリ最適化は、このうち副作用のないコマンド(クエリコマンドと呼ぶことにしよう)に対する最適化のことである。 副作用のないコマンドをログにいれることは、ログを圧迫する原因になるし、 副作用がないのにログの中に入れられて逐次実行されるしかないというのは、 何かやりようがあるんじゃないかと考えるのはふつうのことである。

read_index最適化では、リーダーがクエリを受け取った場合に、 その時のcommit_indexの直後にクエリコマンドが挿入されたことにしようとする。 こうして、last_appliedcommit_indexまで追いついたあと、 クエリを実行する。

クエリコマンドはMap<ReadIndex, Vec<QueryCommand>>のようなデータ構造で管理出来、 ReadIndexlast_appliedより小さいものについては、並列に実行することが出来る。

さて問題は、このような処理をどう実装するかである。 クエリコマンド実行も同様に非同期処理(これをクエリ実行マンと呼ぶことにしよう)で作るとして、 もっともナイーブな実装はやはりタイマーにより定期実行するものだが、 この実装は、さきほど述べたようにNotifyによる最適化が可能だろうか?

考えればわかるが、可能ではない。 last_appliedが更新された時にクエリ実行マンに通知すれば同様に可能だと思うだろうが、 例えば、副作用ありコマンドの挿入がなくlast_appliedが止まっているにも関わらず、 クエリコマンドのみが発行され続ける状況を考えてほしい。

めちゃくちゃ愚直には、クエリコマンドを受け付けた時点で実行可能ならば クエリ実行マンに任せるまでもなく即実行するという方法がありそうだが、 他にもっとスマートなアイデアはあるだろうか?

comments powered by Disqus
Built with Hugo
テーマ StackJimmy によって設計されています。