では、Raftにおけるクエリ最適化について論文から抜粋して解説したが、 実際にはこのアプローチは実装が簡単ではない。
説明のため、まずはRaftの基本に立ち戻る。
Raftはコマンドの実行順序について合意をとるアルゴリズムである。
- コマンドはまずリーダーに送られる。
- リーダーはこれをフォロワーに複製する。
- 複製が進み、クラスタ内の過半数にコマンドが複製され永続化されたことを確認すると、このコマンドは「コミットされた」ということになる。
- コミットされたコマンドは順に実行される。
- ユーザに対してackする。
ここでは例えば、リーダーは以下のようなデータを持つとする。
- 複製管理用テーブル:
Map<NodeId, Index>
- 確認したコミットインデックス
commit_index: Index
- どこまで実行したか
last_applied: Index
どうやって実装するかだが、
ふつうに考えると、1,2,3について
独立した非同期処理が定期タイマー実行し、データを更新していく
ような設計を考えることになる。
これには例えば、tokio::time::Interval
を使うことが出来る。
このアプローチの良い点は、それぞれの処理が分離されるので コードが小さくなることと、 Tokioの協調的マルチタスクには適することだ。 これは、協調的マルチタスクでは一つ一つの処理は短くする必要があるためである。
しかし、このアプローチには問題もある。 クライアントからコマンドを受け取ってからユーザにackするまでの レイテンシが、タイマー実行の間隔に依存してしまうことだ。 例えばそれを100msとすると、1つのコマンドを実行するまでに200-300ms程度の レイテンシを見込むことになる。 これは、アプリケーション要件にも依るだろうが、一般的には許容しづらい。
しかし、この問題に対しては解決のアプローチがある。
例えば、それぞれの非同期処理の間で「終わったよ」という通知を飛ばして、
受け取った方は、通知を受け取った場合にはタイマーによる起床を待たずにして
すぐに実行を開始する。
この実装には例えば、tokio::sync::Notify
を使うことが出来る。
このような仕組みの中で、 副作用のあるコマンドと副作用のないコマンドを統一的に実行することが可能である。
クエリ最適化は、このうち副作用のないコマンド(クエリコマンドと呼ぶことにしよう)に対する最適化のことである。 副作用のないコマンドをログにいれることは、ログを圧迫する原因になるし、 副作用がないのにログの中に入れられて逐次実行されるしかないというのは、 何かやりようがあるんじゃないかと考えるのはふつうのことである。
read_index最適化では、リーダーがクエリを受け取った場合に、
その時のcommit_index
の直後にクエリコマンドが挿入されたことにしようとする。
こうして、last_applied
がcommit_index
まで追いついたあと、
クエリを実行する。
クエリコマンドはMap<ReadIndex, Vec<QueryCommand>>
のようなデータ構造で管理出来、
ReadIndex
がlast_applied
より小さいものについては、並列に実行することが出来る。
さて問題は、このような処理をどう実装するかである。
クエリコマンド実行も同様に非同期処理(これをクエリ実行マンと呼ぶことにしよう)で作るとして、
もっともナイーブな実装はやはりタイマーにより定期実行するものだが、
この実装は、さきほど述べたようにNotify
による最適化が可能だろうか?
考えればわかるが、可能ではない。
last_applied
が更新された時にクエリ実行マンに通知すれば同様に可能だと思うだろうが、
例えば、副作用ありコマンドの挿入がなくlast_applied
が止まっているにも関わらず、
クエリコマンドのみが発行され続ける状況を考えてほしい。
めちゃくちゃ愚直には、クエリコマンドを受け付けた時点で実行可能ならば クエリ実行マンに任せるまでもなく即実行するという方法がありそうだが、 他にもっとスマートなアイデアはあるだろうか?