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 最隐蔽:表现为规律性抖动

两条工程铁律:

  1. 心跳必须与业务线程池隔离:独立的 goroutine / 线程 + 独立连接。业务线程池打满时,心跳仍然发得出去——否则 “业务慢” 会被判成 “进程死”。
  2. 心跳要能反映业务健康:只发 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   再次误判 → 又切
结果:连接风暴 + 双主写入 + 缓存全部击穿

抑制手段 (叠加使用):

  1. 连续 N 次超阈才动作 (n=3 时误判率从 1% 降到 1e-6)。
  2. 最小切换间隔 / 冷却期:切完 60s 内不允许再切。
  3. 切换失败要回退:新主接管失败就回到旧主,别把两个都搞挂。
  4. 把检测结果分级:先摘流量 (φ>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,而不是任何超时或概率。