35.4 故障检测 (心跳 / φ 值)
一句话
故障检测永远在误判与漏判之间二选一;固定心跳给出 “死 / 活” 的二元判断,φ 累积故障检测器把 “多久没消息” 换算成 “它已经死了的概率”,让每个调用方按自己的代价选阈值。
为什么难:在异步网络里,“慢” 和 “死” 不可区分
| 现象 | 真实原因可能是 |
|---|---|
| 3 秒没收到心跳 | 进程崩了 / 网络断了 / 机器过载了 / 对端在 Full GC / 交换机丢包 / 你的探测包丢了 |
| 收到心跳但业务超时 | 心跳线程活着,工作线程全被慢查询堵死 |
没有心跳之外的旁证时,“无响应” 是唯一能观测到的事实,“死亡” 只是推断。这就是 22.2 里 FLP 结论的工程投影:在纯异步模型里,不可能有既 “最终准确” 又 “总能给出判断” 的故障检测器。所以工程上不再追求 “判得准”,而是追求判错的代价可控。
🔑 故障检测器的两个可调参数——检测速度与误判率——永远此消彼长。任何号称 “又快又准” 的方案,都是在某一类故障上作弊。
三种心跳形态
| 形态 | 谁发起 | 优点 | 缺点 | 典型系统 |
|---|---|---|---|---|
| Push 心跳 | 被监测方定时上报 | 实现简单,监测方无状态 | 监测方要维护「最后上报时间」;大规模时集中写入 | 服务注册中心、Agent 上报 |
| Pull 探测 | 监测方定时 ping | 能主动重试多路径,可带探测内容 | 探测方承担 N×M 的探测流量 | K8s probe、LB 健康检查、MySQL 主库探活 |
| 间接探测(Gossip / SWIM) | 成员互相探测 + 代问 | O(log N) 消息量,无需中心 | 收敛有延迟,结论是概率性的 | Consul、Cassandra、Redis Cluster |
固定超时:参数与它决定的两个数字
三个参数:心跳间隔 T、单次超时 k×T、连续失败次数 n。
检测时间(最好情况)≈ k×T
检测时间(最坏情况)≈ n×k×T
误判概率 ≈ (单次丢包率)^n (丢包独立假设下)
| T | k | n | 最快检出 | 最坏检出 | 单次丢包 1% 时的误判率 |
|---|---|---|---|---|---|
| 1s | 3 | 1 | 3s | 3s | 1%(每秒都可能误判) |
| 1s | 3 | 3 | 3s | 9s | 1e-6 |
| 1s | 5 | 3 | 5s | 15s | 1e-6 |
| 5s | 3 | 2 | 15s | 30s | 1e-4 |
规律很直接:
T决定检测速度的上限:心跳再密,也不可能比T更早发现。n决定误判率:每多一次连续失败,误判率乘一次丢包率——这是最便宜的抗误判手段。k×T不能小于业务 P99 延迟:超时比正常响应还短,等于把 “慢” 判成 “死”。
⚠️ 常见错误:
T=1s, k=1, n=1(1 秒没心跳就摘流量)。对端一次 800ms 的 GC 停顿就能触发全场摘除,流量瞬间压到剩余节点,引发级联雪崩。
假死:固定超时最大的敌人
对端进程活得好好的,只是暂时没法发心跳:
| 阻塞源 | 典型时长 | 现象 |
|---|---|---|
| JVM Full GC / Go STW | 100ms–5s | 心跳线程与业务线程一起停 |
| 磁盘 fsync 抖动(云盘) | 100ms–10s | 落盘线程卡住,心跳写日志也卡 |
| CPU 被抢占(超卖实例) | 100ms–2s | 线程调度延迟 |
| 网络拥塞 / 队列溢出 | 1–30s | 心跳包排队或丢失 |
| 容器被 cgroup 限流(CPU throttling) | 周期性的 100ms | 最隐蔽:表现为规律性抖动 |
两条工程铁律:
- 心跳必须与业务线程池隔离:独立的 goroutine / 线程 + 独立连接。业务线程池打满时,心跳仍然发得出去——否则 “业务慢” 会被判成 “进程死”。
- 心跳要能反映业务健康:只发 TCP ACK 的心跳,在 “进程活着但数据库连接全死” 时依然报活。
readiness应该校验关键依赖,liveness只校验进程本身 (见下表)。
φ 累积故障检测器:把二值判断换成概率
Hayashibara 等人 2004 年提出的 accrual failure detector。核心三步:
① 采样:记录最近 N 个心跳到达间隔 → 得到均值 μ 和标准差 σ
② 估算:假设间隔服从正态分布,算「还会继续等更久」的概率
P_later = P(X > 已等待时间) = 1 - Φ( (已等待 - μ) / σ )
③ 输出:φ = -log10(P_later)
φ 的读法极其直观——φ 就是 “误判的量级”:
| φ | P_later | 含义 | 适合谁用 |
|---|---|---|---|
| 1 | 10% | 十次里有一次是误判 | 读流量摘除(错了代价小) |
| 2 | 1% | 一般业务路由 | |
| 3 | 0.1% | 主库切换候选 | |
| 8 | 1e-8 | 基本可确认 | 触发数据迁移、fencing |
🔑 φ 的真正价值是把阈值决策交还给调用方:同一个检测器,读请求用 φ>1 (快速摘流量),主库切换用 φ>8 (宁可慢,不能错)。固定超时做不到这一点——它只有一个阈值,所有人共享同一个错误代价。
自适应是怎么来的
φ 不写死 “多久算死”,它从观测到的抖动里推断:
- 网络平稳 (σ≈0.14s):φ=3 大约在 1.4s 就达到;
- 网络抖动大 (σ≈0.52s):同样的 φ=3 需要 2.6s。
同一套阈值,抖动大时自动变保守。这就是它比固定超时强的根本原因:固定超时在网络状况变化时,误判率会静默地涨上去,而 φ 把误判率钉在常量上,让检测时间浮动。
可运行的 φ 检测器
package main
import (
"fmt"
"math"
)
// phiDetector 只维护最近若干次心跳间隔,不写死任何超时阈值。
type phiDetector struct {
window []float64 // 历史到达间隔(秒)
max int
last float64 // 上次心跳时刻(秒,模拟时钟)
seen bool // 是否已经收到过第一次心跳
}
func newPhi(max int) *phiDetector { return &phiDetector{max: max} }
// Heartbeat 在 t 时刻收到心跳,把本次间隔记入滑动窗口。
func (d *phiDetector) Heartbeat(t float64) {
if d.seen {
d.window = append(d.window, t-d.last)
if len(d.window) > d.max {
d.window = d.window[1:] // 窗口滑动,让检测器跟得上网络变化
}
}
d.last = t
d.seen = true
}
// stats 返回样本均值与样本标准差(n-1,避免小样本低估抖动)。
func (d *phiDetector) stats() (mean, std float64) {
if len(d.window) < 2 {
return 0, 0
}
for _, v := range d.window {
mean += v
}
mean /= float64(len(d.window))
for _, v := range d.window {
std += (v - mean) * (v - mean)
}
return mean, math.Sqrt(std / float64(len(d.window)-1))
}
func (d *phiDetector) Mean() float64 { m, _ := d.stats(); return m }
func (d *phiDetector) Std() float64 { _, s := d.stats(); return s }
// Phi 表示:到 now 还没收到心跳,「对端已死」的置信度量级。
// φ = -log10(P(间隔 > 已等待时间))
func (d *phiDetector) Phi(now float64) float64 {
mean, std := d.stats()
if std <= 0 {
return 0
}
z := (now - d.last - mean) / std
p := 0.5 * math.Erfc(z/math.Sqrt2) // 1 - Φ(z),即右尾概率
if p <= 1e-16 {
return 16 // 用上限代替 +Inf,方便与阈值比较
}
return -math.Log10(p)
}
// timeToPhi 扫描「距上次心跳多久后 φ 达到阈值」,用于对比不同方案的检出速度。
func (d *phiDetector) timeToPhi(target float64) float64 {
for extra := 0.0; extra < 30; extra += 0.01 {
if d.Phi(d.last+extra) >= target {
return extra
}
}
return -1
}
func feed(gaps []float64) *phiDetector {
d := newPhi(20)
t := 0.0
d.Heartbeat(t) // 先建立一个时间基准,否则第一个间隔会被丢掉
for _, g := range gaps {
t += g
d.Heartbeat(t)
}
return d
}
func main() {
// 两条链路的平均心跳间隔都是 1s,唯一区别是抖动大小
calm := feed([]float64{1.15, 0.85, 1.10, 0.90, 1.20, 0.80, 1.05, 0.95, 1.10, 0.90})
noisy := feed([]float64{1.50, 0.50, 1.45, 0.55, 1.60, 0.40, 1.35, 0.65, 1.55, 0.45})
for _, c := range []struct {
name string
det *phiDetector
}{{"抖动小", calm}, {"抖动大", noisy}} {
fmt.Printf("%s:均值=%.3fs 标准差=%.3fs\n", c.name, c.det.Mean(), c.det.Std())
for _, extra := range []float64{0.3, 0.5, 1.0, 1.5, 2.5} {
fmt.Printf(" 沉默 %.1fs → φ=%5.2f\n", extra, c.det.Phi(c.det.last+extra))
}
fmt.Printf(" φ=3(误判率 0.1%%)需要 %.2fs 才达到;固定超时 3s 方案恒定 3.00s\n\n",
c.det.timeToPhi(3))
}
}
输出:
抖动小:均值=1.000s 标准差=0.137s
沉默 0.3s → φ= 0.00
沉默 0.5s → φ= 0.00
沉默 1.0s → φ= 0.30
沉默 1.5s → φ= 3.86
沉默 2.5s → φ=16.00
φ=3(误判率 0.1%)需要 1.43s 才达到;固定超时 3s 方案恒定 3.00s
抖动大:均值=1.000s 标准差=0.524s
沉默 0.3s → φ= 0.04
沉默 0.5s → φ= 0.08
沉默 1.0s → φ= 0.30
沉默 1.5s → φ= 0.77
沉默 2.5s → φ= 2.67
φ=3(误判率 0.1%)需要 2.63s 才达到;固定超时 3s 方案恒定 3.00s
读法:同样的 φ=3 阈值,平稳链路 1.43s 就动手,抖动链路要等到 2.63s。误判率被钉死在 0.1%,检测时间随网络自动伸缩——这正是固定超时做不到的。
检测到之后:不能直接 “判死就切”
“我认为它死了” 和 “它真的死了” 是两件事。切换到备节点时,旧节点可能只是卡了一下,随后满血复活继续写数据——这就是脑裂 (18.6)。
必须叠加三道保险:
| 保险 | 做法 | 作用 |
|---|---|---|
| 租约(Lease) | 旧主必须定期续租,租约过期即失去写权限 | 把「我认为」变成「它自己也知道」 |
| Fencing Token | 每次选主分配单调递增的 token,存储层拒绝旧 token 的写 | 即使旧主复活,写也会被存储层挡下 |
| 双确认 / 最小切换间隔 | 连续 N 次 φ 超阈 + 冷却期,才允许切换 | 抑制抖动导致的反复切换 |
⚠️ 只做检测、不做 fencing 的自动切换系统,是 “高可用” 和 “数据丢失” 的同一个东西:切得越快,丢得越多。
flapping:误判代价的真实形状
一次误判通常不致命,连续误判才致命:
t=0 误判 A 死 → 切到 B
t=3s A 恢复 → 切回 A(或 B 认为自己才是主)
t=6s 再次误判 → 又切
结果:连接风暴 + 双主写入 + 缓存全部击穿
抑制手段 (叠加使用):
- 连续 N 次超阈才动作 (n=3 时误判率从 1% 降到 1e-6)。
- 最小切换间隔 / 冷却期:切完 60s 内不允许再切。
- 切换失败要回退:新主接管失败就回到旧主,别把两个都搞挂。
- 把检测结果分级:先摘流量 (φ>1),再摘连接 (φ>3),最后才切换 (φ>8)。
K8s 的三级探针,本质是三种不同的误判代价
| 探针 | 失败后果 | 该查什么 | 常见错法 |
|---|---|---|---|
| liveness | 重启容器(代价最大) | 进程是否死锁 / 卡死 | 把下游依赖查进去 → DB 抖一下,全集群 Pod 一起重启 |
| readiness | 从 Service 摘流量(代价小) | 能否正常处理请求 | 不查依赖 → 流量打到病节点 |
| startup | 重启(启动期专用) | 启动是否完成 | 用 liveness 兼做启动检查 → 慢启动应用无限重启 |
initialDelaySeconds / failureThreshold 的取值公式:阈值 ≥ 应用最慢启动时间 / 探测周期。Go 服务冷启动 8s、周期 5s、阈值 2 → 第 10s 就被杀,永远起不来。
方案对比
| 方案 | 检测速度 | 误判率 | 自适应 | 消息量 | 适用 |
|---|---|---|---|---|---|
| 固定超时心跳 | 由 T×n 决定 | 随网络恶化静默上升 | ❌ | 低 | 内网稳定、代价低的场景 |
| φ 累积检测 | 随抖动自动伸缩 | 恒定在阈值对应量级 | ✅ | 低 | 跨机房、云上抖动大、切换代价高 |
| SWIM(间接探测) | O(log N) 收敛 | 低(多路径) | 部分 | O(N log N) | 大规模集群成员管理 |
| 租约 | 由租期决定(通常 10–15s) | 极低(不靠推断) | ❌ | 极低 | 主备切换、分布式锁 |
| TCP 探测 | 内核 RST 立即 | 高(NAT/中间设备会伪造) | ❌ | 低 | 仅作辅助信号 |
反模式
| 反模式 | 后果 |
|---|---|
T=1s, k=1, n=1 | 一次 GC 停顿即全场摘除,流量雪崩到剩余节点 |
| 心跳线程与业务共用一个线程池 | 业务一慢,心跳也慢,健康节点被批量判死 |
| liveness 里查数据库 / 下游服务 | 下游抖动 → 上游 Pod 集体重启,故障放大 |
| 只做检测不做 fencing | 主备切换后旧主继续写,脑裂丢数据 |
| 无条件自动切换,无冷却期 | flapping:分钟级反复切换,连接与缓存全崩 |
| 用单次 TCP 握手失败判定节点死亡 | 中间设备、NAT 超时、SYN 队列溢出都会误判 |
| φ 阈值全公司统一一个数 | 读流量白等 3s,主库切换却因阈值太低而误切 |
心跳只回 200 OK 不查依赖 | 进程活着、DB 连接全断,流量照打 |
| 窗口取全部历史 | 网络状况变化后,旧样本拖慢收敛 |
自检
Q1 你的服务每 1s 发心跳、连续 3 次失败判死。某次发布后,整个集群在 5 分钟内互相摘流量,服务全挂但进程都没崩。最可能的原因是什么?
▸ 答案:心跳与业务没有隔离,或者超时阈值小于业务 P99。发布引入了一个慢操作 (比如新加的同步调用),业务线程被占满,心跳线程要么排队、要么停止发送;1s×3 的判定迅速把 “慢” 升级成 “死”,集群成员互相摘除,剩余节点承接全部流量后同样变慢,形成正反馈。修法:心跳独立线程/协程 + 独立连接池;k×T 提到业务 P99 的 2–3 倍以上;连续失败次数加到 5;并先摘流量 (readiness) 而不是直接判死。
Q2 固定超时已经调到 5s 了,跨机房链路偶尔抖动 6s,还是误判。为什么换成 φ 能解决?φ 的代价是什么?
▸ 答案:固定超时的误判率是网络的函数——抖动一变,误判率就变,而阈值是常数,只能靠不断调大超时来压误判,检测速度随之变慢。φ 反过来:它把误判率钉死在常量上 (φ=3 → 0.1%),让检测时间成为网络的函数,抖动大时自动等更久。代价有三:① 需要维护滑动窗口和统计量,实现复杂度高于一个定时器;② 假设间隔近似正态,遇到双峰分布 (如规律性 cgroup 限流) 时估计会偏;③ 团队要理解 “φ=3 是什么意思”,否则参数会被随意改成魔法数。
Q3 检测器报告 φ=10,你就把主库切了,5 分钟后发现两个库都在接受写入。哪一步想错了?
▸ 答案:把 “检测” 当成了 “授权”。φ=10 只说明 “按历史分布,这么久没心跳几乎不可能是正常延迟”,它无法证明对端进程已经停止写。旧主可能只是网络分区,恢复后仍然认为自己是主。正确顺序是:检测 (φ) → 租约到期 (旧主自己失去写权限) → 存储层用 fencing token 拒绝旧主的写 → 才允许新主服务。检测只提供 “该去看看了” 的信号,切换的合法性必须来自租约与 fencing,而不是任何超时或概率。