一个节点迟迟不回复,可能是进程崩溃、网络分区、CPU 长时间调度不到,也可能是观察者自己暂停了。故障检测首先描述观察者能获得什么证据,然后才是决定何时切换服务。
在消息延迟没有上界的异步模型中,有限时间的沉默不能证明对方已停止。这并不意味着不能构建系统,而是需要把“怀疑”“采取行动”和“保证安全”分开。
心跳与探测:两种取得证据的方法
心跳是被观察者主动发送存活信号;探测是观察者主动请求响应。两者都只能证明某条路径在某个时刻成功,不保证节点所有功能正常。
假设节点每 1 秒发一次心跳,观察者等待 3 秒后标记异常。较短阈值可以更早发现真故障,也更容易把短暂暂停判成故障;较长阈值减少误判,却延长接管时间。阈值应结合服务目标、延迟分布、调度暂停和观测成本,而不是固定要求超过某个分位数就永远正确。
节点 A 观察 B 失败,也不代表 C 观察 B 必然失败。故障检测结果带有观察者视角;把局部失败直接广播成不可争辩的全局事实,会放大局部网络抖动。
φ-accrual:把异常程度与行动阈值分离
设历史心跳间隔随机变量为 X,累计分布为 F,距离上次心跳已过 t。怀疑度可定义为:
ϕ(t)=−log10(1−F(t))=−log10P(X>t)
若估计分布认为“还要等这么久”的尾部概率为 0.01,φ=2;若为 0.00001,φ=5。数字越大,当前沉默相对历史行为越异常。它不是“节点已死的后验概率”,后者还需要故障先验和更完整的统计模型。
检测器负责输出连续怀疑度,上层按不同风险选择阈值。例如移除一个负载均衡候选可以较敏感,触发昂贵的数据重建则可能要求更多证据。这个解耦比单个固定 alive/dead 开关更灵活。Akka 的 φ 检测说明 展示了基于历史间隔的实现思路。
分布估计也有边界:刚启动样本太少、长期负载变化、网络延迟多峰、观察者 GC 暂停,都会让旧统计失真。采样窗口、最小方差、启动宽限与允许的心跳暂停是模型的一部分,不能只抄一个 φ 阈值。
同样沉默 1200 ms,为什么一个观察者很紧张,另一个还在等
用正态分布近似历史间隔,平均间隔都是 1000 ms。链路 A 的标准差为 50 ms,1200 ms 已偏离均值四个标准差;链路 B 的标准差为 300 ms,同样的沉默还不到一个标准差。φ 把这两种背景下的异常程度区分开。
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):尾部概率每缩小十倍,φ 增加一。
{"id":"phi-tail-probability","title":"φ 是尾部概率的对数,不是死亡概率","summary":"同样的沉默,在稳定链路上更反常。φ=−log₁₀P(X>t):尾部概率每缩小十倍,φ 增加一。","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:检测与成员传播分工
SWIM 使用随机探测,并在直接探测失败后借助其他成员探测目标,以降低单条路径异常造成的误判。间接探测不要求所有中介都成功;任一有效 ACK 就能提供证据。
怀疑阶段给活节点机会反驳过时判断,再通过成员更新传播结果。成员 incarnation 等版本信息用于区分新的存活声明与旧怀疑,不能让任意旧 ACK 永久覆盖较新的状态。SWIM 原论文 将故障检测和成员更新传播明确分开。
SWIM 与 φ 是不同设计:前者描述探测与传播协议,后者描述统计怀疑度计算。它们可以在某些系统中组合,但不能把 SWIM 定义为“把 φ 与 gossip 合起来”。
全互连心跳每节点每轮发送 O(N) 条、全组 O(N²) 条。固定探测扇出可把单节点探测消息数量控制在常数量级,但成员列表、传播载荷、重传和故障发现尾延迟仍需单独计入,不能由 O(1) 探测推出整个系统成本为常数。
检测到异常以后,谁有权接管
检测器说“怀疑旧主不可达”,不等于旧主已停止。若新主开始写数据库,而旧主恢复后继续写,可能同时存在两个操作源。
安全接管通常需要由共识或租约协议授予权限,并让被保护资源检查 fencing token 等代际信息。检测器加速作出怀疑;权限协议防止过时实例继续生效。即使检测器偶尔误判,也应由后者保护安全性。
这与 故障模型 的区分一致:安全性不能只依赖一个经验超时永远不会误判。
Fencing 必须在资源侧检查,并与写入原子发生
假设授权服务依次发出 token 41、42。旧主持有 41,暂停后恢复;新主持有 42。资源保存自己见过的最大 token,在写入时拒绝更小代次。下例用顺序函数调用表示原子的“比较代次并写入”,没有实现授权服务或数据库事务。
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")
第二组调用界定了保证:资源看到 42 以后,41 不能再覆盖它;仅仅发出 42,不会瞬间通知所有外部资源。若需求是授权切换后立即拒绝任何旧写,必须让资源参与这个切换或原子校验当前授权状态,不能只依赖“最大已见 token”。fencing 的写入侧机制可参见 Martin Kleppmann 对过期持锁者的分析。
真实 token 必须由可信授权协议单调发放,资源不能允许任意客户端自报一个超大数字。资源还要持久保存代次,并把检查与业务修改放在同一个原子操作中;若检查通过后先解锁再写值,新旧请求仍能穿插。对于同一 token 内的重复扣款,仍需请求 ID 去重:fencing 隔离过时代次,幂等消除同一操作重试,两者并不互相代替。
Deadline、超时与恢复预算
一次请求有总 deadline,各下游操作只能使用剩余预算。A 已花 80 ms、总预算 100 ms,再给 B 一个独立的 100 ms 超时会突破调用方承诺。超时后还需传播取消,避免过期工作继续占用资源。
重试同样消耗预算和容量。指数退避与 jitter 能分散重试,但不能替代限次、幂等和过载保护。成员失联超时、RPC deadline 和健康检查阈值服务于不同决策,不应共用一个“全局超时常数”。
参考
关键词:heartbeat、timeout、φ-accrual、SWIM、indirect probe、suspicion、incarnation、fencing、deadline