はじめに
別のサービスへリクエストを送った。
しかし、応答が返ってこない。
相手が停止したのでしょうか。それとも、通信が遅れているだけでしょうか。
処理は成功したものの、応答だけが失われた可能性もあります。
分散システムの難しさは、失敗が起きることだけではありません。
何が起きたのかを、外から確定できないことです。
TL;DR
分散システムでは、次の3つを前提に設計する必要があります。
タイムアウトや時刻は、不確実性をなくしてくれるものではありません。
そのため、リトライされても壊れない処理や、一台の判断に依存しない設計が必要になります。
1. 分散システムでは、暗黙の前提が崩れる
分散システムについて語られるとき、「分散コンピューティングの誤謬」がよく登場します。
ネットワークは信頼できる。遅延はゼロである。帯域幅は無限である。構成は変化しない。
普段は意識しないこれらの前提が、分散環境では成立しません。
本稿では、8つを一つずつ説明するのではなく、障害を考えるうえで根本となる3つへ絞ります。
この3つは独立した問題ではありません。
一部のノードから応答がなくても、故障したのか、通信が遅いのかを区別できない。
さらに、複数ノードのログを見ても、時計がずれていれば正確な順序を判断できない。
それぞれが重なることで、システム全体の状態を把握することが難しくなります。
TCPを使っても、処理の成功までは分からない
TCPは、アプリケーションに対して信頼性のある順序付きバイトストリームを提供します。パケットの損失を検出し、再送する仕組みも持っています。
ただし、TCPが保証するのは通信路上のバイト列です。
受信したアプリケーションが処理を完了したか、データベースへの保存が成功したか、その結果を送信元が受け取れたかまでは保証しません。
クライアントから見えるのは、応答が返ってこなかったという事実だけです。
この時点では、失敗とも成功とも断定できません。
2. 部分故障では、一部だけが壊れる
単一マシン上のプログラムでは、マシンが停止すると、そこで動いている処理もまとめて停止します。
分散システムでは、Node Bが停止しても、Node AとNode Cは動き続けます。
システム全体が動いているか、停止しているかという二択にはなりません。
ある利用者の処理は成功し、別の利用者の処理は失敗することもあります。
これが部分故障です。
応答がないだけでは、停止したとは言えない
Node Bから応答がない場合、実際には複数の状態が考えられます。
Node Aからは、これらを直接見分けられません。
プロセスが停止した場合も、長いGCや高負荷で一時的に止まっている場合も、外からは応答がないように見えます。
したがって、障害検知は事実の確認ではなく、観測結果からの推測になります。
故障したノードが正しく振る舞うとも限らない
多くの業務システムでは、ノードが停止して応答しなくなるクラッシュ障害を主に扱います。
さらに厳しい環境では、故障したノードが矛盾した値や誤った値を返すことも考えます。
このような障害はビザンチン障害と呼ばれます。
本稿では合意アルゴリズムまで踏み込みませんが、分散システムにおける故障は、単純な「動く・止まる」だけではないという点が重要です。
3. 非同期ネットワークでは、遅延と故障を区別できない
ネットワークには、必ず何秒以内に応答が返るという保証がありません。
通信経路の混雑、再送、相手側の負荷などによって、応答時間は変動します。
TCPも接続指向の通信を提供しますが、それ自体に相手の生存を確実に検出する能力があるわけではありません。
そのため、応答を待ち続けるわけにはいかず、アプリケーション側ではタイムアウトを設定します。
タイムアウトは故障の証明ではない
例えば、3秒でタイムアウトする処理を考えます。
3秒以内に応答がなかったからといって、Node Bが停止したとは限りません。
タイムアウトが示すのは、
指定した時間内に応答を確認できなかった
ということだけです。
タイムアウトは相手の状態を確定する仕組みではなく、待つのをやめるためのローカルな判断です。
リトライすると、処理が重複する
タイムアウト後にリトライすれば、一時的な通信障害から回復できることがあります。
しかし、最初の処理が成功していた場合は、同じ処理をもう一度実行することになります。
だからこそ、分散システムでは単にリトライを追加するだけでは不十分です。
同じリクエストが複数回来ても結果が壊れないように、冪等キーや重複排除を設計する必要があります。
不確実性をなくすのではなく、不確実なまま再実行されても壊れないようにします。
4. 時計のずれによって、出来事の順序も曖昧になる
複数ノードの処理を調査するとき、ログの時刻を並べて順序を確認することがあります。
しかし、各ノードの時計が完全に一致しているとは限りません。
分散データベースのSpannerも、時計が完全に一致するとは扱わず、時刻を一点ではなく不確実性を含む範囲として公開しています。
実際には更新Aの後に更新Bが起きていても、記録された時刻だけを見ると逆に見えることがあります。
経過時間と日時は別の時計で考える
例えばJavaでは、currentTimeMillis()は現在日時を表すために使われます。
一方、nanoTime()は経過時間の計測用であり、壁時計の時刻とは関係しません。異なるJVM間で値を比較することも想定されていません。
時計を正しく使い分けても、異なるノードで起きたイベントの因果関係まで物理時刻だけで保証することはできません。
正確な時刻ではなく、因果関係を持たせる
例えば、データの更新にバージョンを持たせます。
この場合、各ノードの時計が多少ずれていても、どの更新を前提に次の更新が行われたかを判断できます。
論理的な同期を使い、普遍的な物理時計への依存を減らす研究も行われています。
重要なのは、時計を完全に同期させれば問題が消えると考えないことです。
物理時計は観測値として使い、順序や整合性が必要な場所には、それを表す仕組みを別に持たせます。
5. 不確実性を前提にすると、設計の方向が変わる
部分故障、非同期ネットワーク、時計のずれには、共通点があります。
どれも、離れた場所の状態を完全には把握できないという問題です。
この前提を受け入れると、設計の目標も変わります。
状態を必ず正しく判定することではなく、判定を誤っても壊れないことを目指します。
部分故障には、一台だけで決めない
一つのノードから応答がないだけで、即座に全体の状態を決めるのは危険です。
複数ノードからの応答を使う、リーダーの任期を管理するなど、古い判断や孤立した判断が残らないようにします。
非同期には、重複しても壊れない
応答を確認できない以上、リトライは避けられません。
そこで、処理を一度しか実行しないことを期待するのではなく、同じ要求が複数回来ても結果が壊れないようにします。
時計のずれには、時刻だけで順序を決めない
物理時刻はログや表示には役立ちます。
一方、更新の競合や因果関係の判定には、バージョンや論理時計などを使います。
ここで挙げた仕組みは、不確実性をなくす魔法ではありません。
不確実性が残っていても、システムを継続して動かすための設計です。
おわりに
分散システムでは、応答がないだけで相手の停止を確定できません。
タイムアウトしても、処理が失敗したとは限りません。
ログの時刻を比較しても、正確な順序を判断できないことがあります。
分散システム設計の出発点は、不確実性を取り除こうとすることではありません。
相手の状態を確定できない場合があると認め、その状態でも壊れないように設計することです。
この前提があるからこそ、冪等性、クォーラム、リーダー選出、論理時計といった仕組みが必要になります。