ノードが応答しない場合、プロセスのクラッシュ、ネットワーク分断、CPU の長時間スケジューリング不能、あるいは観測者自身が一時停止している可能性があります。障害検出は、まず観測者がどのような証拠を得られるかを記述し、その後にサービス切り替えの判断を行います。
メッセージ遅延に上限がない非同期モデルでは、有限時間の沈黙は相手が停止したことを証明するものではありません。これはシステムを構築できないという意味ではなく、「疑念」「行動の発動」「安全性の保証」を分離する必要があるということです。
ハートビートとプローブ:証拠を得る2つの方法
ハートビートは、監視対象が自発的に生存信号を送信するものです。プローブは、観測者が応答を要求する能動的なものです。どちらも、あるパスが特定の時点で成功したことを証明するだけで、ノードの全機能が正常であることを保証するものではありません。
ノードが1秒ごとにハートビートを発信し、観測者が3秒待ってから異常とみなすと仮定します。閾値を短くすると、実際の障害をより早く検出できますが、一時的な停止を故障と誤判定しやすくなります。閾値を長くすると誤判定は減りますが、切り替えまでの時間が延びます。閾値は、サービスの目標、遅延分布、スケジューリングの停止、観測コストを考慮して決定すべきであり、特定の分位点を超過したら永遠に正しいという固定要件ではありません。
ノードAがBの失敗を観測しても、CがBの失敗を観測する必然性はありません。障害検出結果には観測者の視点が含まれます。局所的な失敗を争いの余地のないグローバルな事実として直接ブロードキャストすると、局所的なネットワークのジッターが増幅されます。
φ-accrual:異常度と行動閾値の分離
履歴ハートビート間隔の確率変数を X、累積分布関数を F、最後のハートビートから経過した時間を t とします。疑念度は以下のように定義できます。
ϕ ( t ) = − log 10 ( 1 − F ( t )) = − log 10 P ( X > t )
推定分布が「これだけ待たなければならない」という尾部確率を 0.01 と見積もっている場合、φ=2 となります。0.00001 の場合、φ=5 となります。数値が大きいほど、現在の沈黙は過去の行動に対して異常です。これは「ノードが死亡した事後確率」ではなく、後者には故障の事前確率とより完全な統計モデルが必要です。
検出器は連続的な疑念度を出力し、上位レイヤーはリスクに応じて閾値を選択します。例えば、ロードバランサの候補を削除する場合は感度を高く設定できますが、高価なデータ再構築をトリガーする場合はより多くの証拠を要求する可能性があります。このデカップリングは、単一の固定された alive/dead スイッチよりも柔軟です。Akka の φ 検出の説明 は、履歴間隔に基づく実装の考え方を示しています。
分布推定にも限界があります。起動直後はサンプルが少なすぎ、長期的な負荷の変化、ネットワーク遅延のマルチモーダル性、観測者の GC 停止などにより、古い統計が歪むことがあります。サンプリングウィンドウ、最小分散、起動猶予、許可されるハートビート停止はモデルの一部であり、φ 閾値を単にコピーするだけでは不十分です。
どちらも 1200 ms の沈黙なのに、なぜある観測者は緊張し、別の観測者は待っているのか
履歴間隔を正規分布で近似します。平均間隔はどちらも 1000 ms です。リンク A の標準偏差は 50 ms で、1200 ms は平均から4標準偏差外れています。リンク B の標準偏差は 300 ms で、同じ沈黙でも1標準偏差未満です。φ はこれらの異なる背景における異常度を区別します。
from math import erfc , sqrt , log10 , isclose , inf
def phi ( elapsed , mean , stddev , pause_margin = 0 ) :
if elapsed < 0 or stddev <= 0 or pause_margin < 0 :
raise ValueError ( " 間隔と猶予は非負、標準偏差は正でなければなりません" )
z = ( elapsed - pause_margin - mean ) / stddev
tail = 0. 5 * erfc ( z / sqrt ( 2 ) )
return - log10 ( tail ) if tail else inf
stable = phi ( 1200 , 1000 , 50 )
variable = phi ( 1200 , 1000 , 300 )
assert 4. 49 < stable < 4. 51
assert 0. 59 < variable < 0. 61
assert stable > 2 and variable < 2
assert isclose ( phi ( 1200 , 1000 , 50 , pause_margin = 200 ) , log10 ( 2 ) )
assert phi ( 1300 , 1000 , 50 ) > stable
履歴モデル / 猶予 今回の φ 約値 例の閾値が 2 の場合
平均 1000、標準偏差 50 ms 4.50 疑念をトリガー
平均 1000、標準偏差 300 ms 0.60 待機継続
標準偏差 50、追加猶予 200 ms 0.30 待機継続
コードは補完誤差関数を使用して直接尾部を計算し、1 に近い F を先に計算して引き算することによる精度の損失を避けています。極端に大きな間隔ではアンダーフローしてゼロになる可能性がありますが、ここでは無限大の疑念度を返します。正規分布は近似に過ぎず、負の間隔にも少量の確率を割り当てるため、ネットワークの物理法則として扱うことはできません。
このコードは、サンプルウィンドウの学習、最小分散の設定、起動フェーズの実装を行っていません。閾値 2 は比較のための示唆に過ぎず、デプロイメントの推奨ではありません。猶予は許容度を高めますが、実際の障害検出を遅らせます。観測者自身が停止している場合、遠隔ノードの統計を変更して問題が解決したと主張することはできません。Akka のドキュメントは、履歴平均、分散、許容される停止が曲線に与える影響を説明しています。φ 検出器の説明
φ は尾部確率の対数であり、死亡確率ではない
右側の着色部分は、「履歴モデルでは次のハートビートがまだ到達していない」という確率に対応します。1200 ms の沈黙を維持し、標準偏差だけを拡大すると、同じ観察でも φ は顕著に低下します。これは参照分布を変更するものであり、遠隔ノードの実際の生死を変更するものではありません。逆に、分布を固定したまま沈黙を延長すると、尾部確率は縮小し、負の対数は増大します。グラフは有限区間を表示しており、数値は完全な正規近似に基づいて計算されています。
図を準備しています 再読み込み
φ は尾部確率の対数であり、死亡確率ではない 同じ沈黙でも、安定したリンク上ではより異常です。φ=−log₁₀P(X>t):尾部確率が10分の1になるごとに、φ は 1 増加します。
{"id":"phi-tail-probability","title":"φ は尾部確率の対数であり、死亡確率ではない","summary":"同じ沈黙でも、安定したリンク上ではより異常です。φ=−log₁₀P(X>t):尾部確率が10分の1になるごとに、φ は 1 増加します。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"silence\" data-i18n=\"silence\"></label><output for=\"silence\" id=\"silence-value\"></output><input id=\"silence\" type=\"range\" min=\"500\" max=\"1500\" value=\"1200\" step=\"10\"></div><div class=\"control\"><label for=\"sigma\" data-i18n=\"sigma\"></label><output for=\"sigma\" id=\"sigma-value\"></output><input id=\"sigma\" type=\"range\" min=\"50\" max=\"300\" value=\"50\" step=\"10\"></div><svg id=\"curve\" viewBox=\"0 0 400 150\" role=\"img\" data-i18n-label=\"tail\"><path id=\"area\" fill=\"var(--second-soft)\"/><path id=\"density\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2\"/><line id=\"marker\" y1=\"10\" y2=\"140\" stroke=\"var(--second)\" stroke-width=\"2\"/></svg><div class=\"axis\"><span>0 ms</span><span>1000</span><span>2000 ms</span></div><div class=\"metrics\"><div><span data-i18n=\"tail\"></span><strong id=\"tail\"></strong></div><div><span data-i18n=\"phi\"></span><strong id=\"phi\"></strong></div></div><p id=\"equation\" class=\"formula\"></p><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"suspect\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"wait\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n#curve{width:100%;display:block}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}function erfc(x){const z=Math.abs(x),t=1/(1+.5*z),r=t*Math.exp(-z*z-1.26551223+t*(1.00002368+t*(.37409196+t*(.09678418+t*(-.18628806+t*(.27886807+t*(-1.13520398+t*(1.48851587+t*(-.82215223+t*.17087277)))))))));return x>=0?r:2-r;}function draw(){const time=+$('#silence').value,sigma=+$('#sigma').value,p=.5*erfc((time-1000)/(sigma*Math.SQRT2)),phi=-Math.log10(p);set('silence-value',time);set('sigma-value',sigma);set('tail',p.toExponential(3));set('phi',phi.toFixed(3));set('equation','−log₁₀('+p.toExponential(3)+') = '+phi.toFixed(3));let points=[];for(let x=0;x<=2000;x+=5)points.push([x/5,140-110*50/sigma*Math.exp(-.5*((x-1000)/sigma)**2)]);$('#density').setAttribute('d','M'+points.map(p=>p.join(',')).join(' L'));const right=points.filter(p=>p[0]>=time/5);$('#area').setAttribute('d','M'+time/5+',140 L'+right.map(p=>p.join(',')).join(' L')+' L400,140 Z');$('#marker').setAttribute('x1',time/5);$('#marker').setAttribute('x2',time/5);set('explanation',t(phi>=2?'suspect':'wait'));}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"heading":"φ は尾部確率の対数であり、死亡確率ではない","intro":"平均間隔は 1000 ms です。今回の沈黙の長さと履歴の揺らぎを調整し、グラフの右側の面積と φ を照らし合わせてください。","phi":"φ","reset":"リセット","scope":"正規分布の教育的近似。平均は固定 1000 ms。尾部確率は完全な分布に基づいて計算され、画面には 0–2000 ms のみが表示されます。履歴サンプルは学習されていません。閾値 2 は比較用であり、デプロイメントの推奨ではありません。","scopeLabel":"モデル範囲と規約","sigma":"履歴標準偏差 σ / ms","silence":"沈黙 t / ms","suspect":"φ ≥ 2:例の疑念閾値に達しましたが、ノードが死亡したことを証明するものではありません。","tail":"P(X > t)","wait":"φ < 2:例の閾値に達していませんが、ノードが健全であることを保証するものでもありません。"}}
SWIM:検出とメンバー伝播の役割分担
間接プローブ:直接接続の失敗後、異なるネットワークパスを試す
観測者 A
対象 P
直接 PING で ACK を受信せず
仲介者 B
仲介者 C
いずれかの間接パスが有効な ACK を返せば生存の証拠となります。すべて失敗すると、疑念フェーズに入ります。
SWIM はランダムプローブを使用し、直接プローブが失敗した場合、他のメンバーを使用して対象をプローブすることで、単一パスの異常による誤判定を低減します。間接プローブでは、すべての仲介者が成功する必要はありません。有効な ACK が1つ返れば、証拠となります。
疑念フェーズは、生きているノードに古い判断を反論する機会を与え、その後メンバー更新を通じて結果を伝播します。メンバーの incarmation(生成)などのバージョン情報は、新しい生存宣言と古い疑念を区別するために使用され、任意の古い ACK が新しい状態を永久的に上書きすることはできません。SWIM 原論文 は、障害検出とメンバー更新伝播を明確に分離しています。
SWIM と φ は異なる設計です。前者はプローブと伝播プロトコルを記述し、後者は統計的な疑念度の計算を記述します。これらは特定のシステムで組み合わせることができますが、SWIM を「φ と gossip を組み合わせたもの」と定義することはできません。
全結合ハートビートでは、ノードあたり1ラウンドで O(N) 通、全グループで O(N²) 通のメッセージが送信されます。固定プローブ扇出により、単一ノードのプローブメッセージ数を一定量に抑えることができますが、メンバーリスト、伝播ペイロード、再送、障害発見のテールレイテンシは別途考慮する必要があり、O(1) のプローブからシステム全体のコストが一定であると結論付けることはできません。
異常を検出した後、誰が引き継ぐ権利を持つか
検出器が「古いマスターに到達できないと疑っている」と言っても、古いマスターが停止したことを意味するものではありません。新しいマスターがデータベースへの書き込みを開始し、古いマスターが回復して書き込みを続行すると、同時に2つの操作ソースが存在する可能性があります。
安全な引き継ぎには、通常、合意またはリースプロトコルによって権限が付与され、保護されたリソースが fencing token などの世代情報をチェックする必要があります。検出器は疑念を加速しますが、権限プロトコルは古いインスタンスが引き続き有効になるのを防ぎます。検出器が偶発的に誤判定した場合でも、安全性の保護は後者によって行われます。
これは障害モデル の区別と一致しています。安全性は、経験的なタイムアウトが決して誤判定しないことに依存することはできません。
fencing はリソース側でチェックされ、書き込みとアトミックに発生する必要がある
認証サービスが順にトークン 41、42 を発行すると仮定します。古いトークン 41 を保持していたノードが一時停止後に回復し、新しいノードがトークン 42 を保持しているとします。リソースは自分が確認した最大トークンを保存し、書き込み時により低い世代を拒否します。以下の例では、シーケンシャルな関数呼び出しを使用して、アトミックな「世代の比較と書き込み」を表しています。認証サービスやデータベーストランザクションの実装は含まれていません。
リソースが新しい世代を確認した後、遅れた古い書き込みを拒否する
新しいマスター トークン 42
書き込み new
古いマスター トークン 41
遅れた書き込み late-old
リソースは highest = 42 を保存
比較と変更はアトミックに実行される必要がある
受け入れる
拒否:41 < 42
検出タイムアウトは切り替えのトリガーとして使用されます。書き込み権限は、認証プロトコルおよびリソース側の検証によって保護されます。
class Resource :
def __init__ ( self ) :
self . highest = 0
self . value = None
def write ( self , token , value ) :
if token < self . highest :
return False
self . highest , self . value = token , value
return True
r = Resource ( )
assert r . write ( 41 , " old" )
assert r . write ( 42 , " new" )
assert not r . write ( 41 , " late-old" )
assert r . value == " new"
assert r . write ( 42 , " new-again" )
r2 = Resource ( )
assert r2 . write ( 41 , " old" )
issued_token = 42
assert r2 . write ( 41 , " late-but-before-fence" )
assert r2 . write ( issued_token , " new" )
assert not r2 . write ( 41 , " now-rejected" )
2番目の呼び出しグループは保証を定義しています。リソースが 42 を確認した後、41 はそれを上書きできません。42 を発行しただけでは、外部のリソースに瞬時に通知されるわけではありません。認証の切り替え直後に古い書き込みをすべて拒否する必要がある場合、リソースがこの切り替えに参加するか、現在の認証状態をアトミックに検証する必要があります。「最大確認済みトークン」だけに依存することはできません。fencing の書き込み側のメカニズムについては、Martin Kleppmann の期限切れロック保持者に関する分析 を参照してください。
実際のトークンは、信頼できる認証プロトコルによって単調に発行されなければならず、リソースは任意のクライアントが巨大な数字を自己申告することを許可してはいけません。リソースは世代を永続的に保存し、チェックとビジネスロジックの変更を同じアトミック操作に配置する必要があります。チェックが通った後にロックを解除してから値を書き込むと、新旧の要求が依然として交差する可能性があります。同じトークン内での重複請求に対しては、リクエスト ID の重複排除が必要です。fencing は古い世代を隔離し、冪等性は同じ操作の再試行を排除します。これらは互いに代替できるものではありません。
デッドライン、タイムアウト、および回復予算
1つのリクエストには総デッドラインがあり、各下位操作は残りの予算のみを使用できます。A が 80 ms 消費し、総予算が 100 ms の場合、B に独立した 100 ms のタイムアウトを与えると、呼び出し元のコミットメントを超過してしまいます。タイムアウト後、キャンセルを伝播して、期限切れの作業がリソースを占有し続けないようにする必要があります。
再試行も予算と容量を消費します。指数バックオフとジッターは再試行を分散できますが、回数制限、冪等性、過負荷保護に代わるものではありません。メンバーの喪失タイムアウト、RPC デッドライン、ヘルスチェック閾値は異なる意思決定のために使用されるため、1つの「グローバル定数としてのタイムアウト」を共有すべきではありません。
参考
キーワード:heartbeat、timeout、φ-accrual、SWIM、indirect probe、suspicion、incarnation、fencing、deadline