没收到心跳 ≠ 死了: 网络堵 / GC 停 20s / CPU 打满都会装死 — 判死太快的代价是双主双写, 判死太慢的代价是停机拉长
医生不会因为病人一次没接电话就宣布死亡: 先量生命体征(心跳), 再对照病史看异常程度(Phi Accrual), 最后多学科会诊(quorum)才下结论。故障检测的本质是概率推断, 不是布尔开关 — 没收到心跳只说明「通信失败」, 而通信失败的原因清单里有: 网络分区、Full GC 停 20 秒、CPU 打满、丢包, 它们全都伪装成死亡。
判死的两个方向都会出事: 判太快, 误切引发双主双写, 数据修复比停机贵得多; 判太慢, 真死了没人接管, 停机窗口被拉长。所以工程上把判死拆成三关(连续 N 次 / φ 阈值 / 多数派确认), 再用三探针把「摘流量」与「重启进程」两种动作分开 — 这就是本页的全部主线。
for range ticker.C { if err := ping(peer); err != nil { misses++ } // → misses==1: 只告警 } // 关键: misses 是计数器不是开关, 到 N 才升级为 suspect
/healthz、TCP 端口、执行命令。要点: 健康检查分「活着」(liveness)与「能服务」(readiness)两种语义, 混用会把依赖抖动放大成重启风暴。 func healthz(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) // → 200, 只证明进程能响应 } // 关键: healthz 只查自身, 不碰 DB — 依赖挂了是 readiness 的事
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10 # 关键: 失败动作是重启, 检查必须轻到 O(1)
failureThreshold: 3readinessProbe:
httpGet: { path: /ready, port: 8080 }
failureThreshold: 3 # 关键: 失败动作只是摘流, 才敢放依赖检查initialDelaySeconds 基本可以删掉。 startupProbe:
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30 # 30×5s=150s 启动预算, 成功后其他探针才接管
# 关键: JVM 冷启 90s 的服务, 没 startup 会在第 30s 被 liveness 杀掉timeoutSeconds: 1 # 示例见坑 3: 1s < JVM STW 20s → 每次大 GC 必误判 # 关键: 超时必须 > 被检测方的最大合理停顿, 而不是越小越「严格」
// 伪代码: 检测器的关键不是判死, 是「判死可撤销」 detector.OnSuspect(func(n ID) { quarantine(n) }) // 疑似: 先隔离 detector.OnRevive(func(n ID) { resync(n) }) // 关键: 复活必须回补数据
phi := fd.Phi(time.Since(lastHeartbeat)) if phi >= 8 { markSuspect(peer) } // Akka 默认阈值 8, 可按业务调 6~12 // 关键: φ 是由到达间隔分布算出的连续量, 不看单次超时
// SWIM/memberlist: 直探失败 → 找 k 个伙伴间接探测 → 才广播 suspect // 关键: 怀疑要找人 corroborate(间接探测), 不自己一票定死
suspect(疑似, 可复活) 与 dead(判死, 剔除)。Redis Sentinel 的版本: 主观下线 sdown(单个哨兵的意见) → 客观下线 odown(quorum 票附议) → 才允许 failover。 redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster # → 1) "10.0.0.12" 2) "6379" (切换后返回新主地址) # 关键: sdown 是单人意见, odown = quorum 票, 只有 odown 才切换
epoch := raft.BecomeLeader() // 伪代码: 新任期 = 旧任期+1, 多数派落盘 grantFencingToken(epoch) // 新主后续写必须带这个 token // 关键: 提权 + 刷路由 + 发新 epoch, 缺一就是给双主开门票
-- 回切前检查: 副本必须已追平 SHOW REPLICA STATUS; -- MySQL 8.0.22+ → Seconds_Behind_Master: 0 -- 关键: 差 1 秒日志也不回切, 回切本身就是一次 failover
shard := crc32(userID) % 2 // 双活按分片路由, 两机房写的 key 永不重叠 // 关键: 没有「分片互斥」这个前置协调, Active-Active 就是灾难片
双 11 压测期间 Redis 主从复制偶发抖动, down-after 只配了 5s, 每小时误切 2-3 次, 每次切换伴随连接抖动。调参 + parallel-syncs 限流回切。
# sentinel.conf — 3 哨兵 + 1 主 2 从 sentinel monitor mymaster 10.0.0.11 6379 2 # quorum=2: 至少 2 哨兵同意才 odown sentinel down-after-milliseconds mymaster 30000 # 30s 无有效回复才算主观下线 sentinel failover-timeout mymaster 180000 # 两次 failover 最小间隔 3min sentinel parallel-syncs mymaster 1 # 切换后逐台 reconfig, 读不全灭 # 调参前 down-after=5000: 每次复制抖动都触发切换; 调后误切归零, # 代价是真宕机接管从 5s 变 30s — 用 SLO 换算过, 可接受
JVM 冷启动 90s, 没 startup 探针时容器在第 30s 被 liveness 杀掉循环重启。用 Boot 2.3+ 的 actuator 分组端点, 三探针各司其职。
containers: - name: order-api startupProbe: # 先给足启动预算 httpGet: { path: /actuator/health/liveness, port: 8080 } periodSeconds: 5 failureThreshold: 36 # 5s×36=180s 启动窗口 readinessProbe: # 依赖抖动摘流, 不重启 httpGet: { path: /actuator/health/readiness, port: 8080 } periodSeconds: 5 failureThreshold: 3 # 连挂 15s 才摘, 抗单次抖动 livenessProbe: # 只回答进程死没死 httpGet: { path: /actuator/health/liveness, port: 8080 } periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3
常见反模式是一个 /health 查所有依赖, 结果 DB 抖一下全站被重启。正确模板: liveness 查自身 O(1), readiness 才允许查依赖且必须带超时。
// liveness: 只回答「进程还能响应吗」, 不碰任何依赖 func liveness(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } // readiness: 回答「能接流量吗」, 允许查依赖但必须带超时+快速失败 func readiness(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond) defer cancel() if err := db.PingContext(ctx); err != nil { http.Error(w, "db down", http.StatusServiceUnavailable) // → 503 摘流 return } w.WriteHeader(http.StatusOK) }
自研控制面用 hashicorp/memberlist(SWIM)做成员管理, 直探失败后靠间接探测定疑似, 避免单节点网络抖动直接判死。
cfg := memberlist.DefaultLANConfig() // SWIM 实现, gossip 扩散 suspect cfg.Name = hostname cfg.BindPort = 7946 cfg.ProbeInterval = 1 * time.Second // 直探周期 cfg.ProbeTimeout = 500 * time.Millisecond // 单探超时, 必须 ≪ ProbeInterval cfg.SuspicionMult = 3 // 怀疑窗口 ≈ 3×log(N)×ProbeInterval list, err := memberlist.Create(cfg) // 判死路径: 直探失败 → k 个伙伴间接探测 → suspicion 窗口无人反驳 → suspect
kill -9 验证不了最危险的场景: 进程活着但不响应。kill -STOP 让进程冻结, 完美模拟 STW, 能顺带验证旧主复活后会不会被正确降级。
# 演练目标: master「装死 40s」下 sentinel 切换是否安全、旧主复活是否被降级 redis_pid=$(docker inspect --format '{{.State.Pid}}' redis-master) kill -STOP $redis_pid # 模拟 STW: 进程在, 只是不响应(比 kill -9 真实) sleep 40 redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster # 期望: 30s(down-after)后完成切换, 返回 10.0.0.12(原从库), 期间无孤儿写 kill -CONT $redis_pid # 「GC 结束」复活 — 期望: 旧主被降级为 replica # 若旧主复活后仍可写 → 说明客户端/拓扑没有 following 新主, 立刻修剧本
故障主修复后重新入组, 先以副本身份追平新主, 追平之前保持只读 — 回切窗口的双写是数据事故的头号来源。
-- 旧主回切流程 (MySQL 8.0, 8.0.22+ 起用 SOURCE/REPLICA 术语) -- 1. 先钉死只读, 杜绝回切窗口双写 SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON; -- 2. 以副本身份追新主 (GTID 自动定位) CHANGE REPLICATION SOURCE TO SOURCE_HOST='10.0.0.12', SOURCE_USER='repl', SOURCE_PASSWORD='***', SOURCE_AUTO_POSITION = 1; START REPLICA; -- 3. 追平才允许回切: Replica_SQL_Running: Yes 且 Seconds_Behind_Master: 0 SHOW REPLICA STATUS;
消费组反复 Rebalance, 根因是老集群 session.timeout.ms 还是 10s, 一次 Full GC 就被踢。按 3 倍法则重配心跳与超时。
# consumer.properties — Kafka 3.x (KIP-735 起 session 默认已调大到 45s) session.timeout.ms=45000 heartbeat.interval.ms=15000 # ≈ session 的 1/3: 丢 1 次心跳还有 2 次机会 max.poll.interval.ms=300000 # 两次 poll 间隔上限, 批处理要单独估算 # 3 倍法则: session.timeout ≥ 3 × heartbeat.interval, # 且两者都必须 > 消费端最大 GC 停顿, 否则每轮大 GC 都触发 Rebalance
K8s 里 order-api 每 20 分钟被重启一次, 监控看 CPU 正常。用 jstat 抓现场: 老年代 100%, FGC 累计停顿小时级 — 是 STW 把探针「装死」了。
# 现场: K8s 反复重启, CPU 正常、内存水位 92% — 先怀疑 GC 装死 jstat -gcutil <pid> 1000 10 # → E 100.00 O 99.87 FGC 412 FGCT 1683.20 (老年代打满, FGC 412 次) jcmd <pid> GC.heap_info # JDK 11+ 开停顿归因日志, 直接看 STW 时长与原因 java -Xlog:gc*,safepoint:file=gc.log:time,uptime:filecount=5,filesize=20m -jar app.jar # → gc.log: GC(412) Pause Full (G1 Compaction Pause) 8192M->1180M 20413ms # 修法: 调大 heap/治内存泄漏; 同时 liveness 探针换 startup+轻检查兜底
曾把 Redis ping 放进 liveness, Redis 抖 10s → 全部 pod 被重启 → 缓存全冷 → 雪崩。改造: liveness 只验证「主循环还推进」, 用进度时间戳自检。
var lastProgress atomic.Int64 // 各工作协程每处理一批就写入 unix 毫秒 func liveness(w http.ResponseWriter, r *http.Request) { idle := time.Now().UnixMilli() - lastProgress.Load() if idle > 60000 { // 主循环卡死 60s 才算死 — Redis 挂了不再重启全站 http.Error(w, "stalled", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // 依赖健康(含 Redis)全部挪进 readiness, 失败只摘流不重启
两地机房互发探针, 公网晚高峰抖 2s 就互相「判死」。改造为探活本地化: Consul 本机 TCP 探活, 跨机房只靠 WAN gossip 同步健康结论。
# consul agent 配置(HCL): 本机房服务注册 + TCP 探活 service = { name = "order-api" port = 8080 check = { id = "order-api-tcp" tcp = "127.0.0.1:8080" # 本机探活, 绝不跨机房探测 interval = "5s" timeout = "2s" # < interval: 单次超时不会叠成探测队列 deregister_critical_service_after = "1m" # 僵尸注册 1 分钟自动注销 } } # 双活规则: 跨机房只传「健康结论」(gossip), 不传「探测流量」
# 错: livenessProbe: /health # /health 内部 ping DB → DB 抖 = 全部重启 # 对: liveness 只查自身; DB 检查放 readiness, 失败摘流不重启
# 错: failureThreshold: 1 # 单次超时就动作 # 对: failureThreshold: 3 + periodSeconds: 5 # 15s 窗口连挂才动作
# 错: timeoutSeconds: 1 # JVM STW 20s → 每次大 GC 必误判 # 对: timeoutSeconds: 5 + 轻 liveness; STW 用 -XX:MaxGCPauseMillis 收敛
# 错: port: 8080 # sidecar 占了 8080 → 一直探的是 sidecar # 对: port: http # named port, containerPort 声明处统一管
COUNT(*) 全表扫描, 或和其他接口抢同一把锁. 正解: 健康检查 O(1) 返回预计算状态位。 // 错: db.query("SELECT COUNT(*) FROM orders") // 全表扫, 探针排队 // 对: return cachedHealthFlag // 后台协程预计算, O(1) 返回
Seconds_Behind_Master=0 才回切, 回切走完整剧本。 -- 错: 直接把 VIP 切回旧主 → Seconds_Behind_Master=10, 中间写入丢失 -- 对: SHOW REPLICA STATUS 确认 0 延迟后, 按剧本回切
# 错: 全互联: n=500 → 500×499/2 ≈ 12.5 万条连接 # 对: gossip 每节点固定 3 个伙伴, 或协调者星型: 连接数 O(N)
deregister_critical_service_after 自动注销。 # 错: 注册后永不注销, 实例 crash 后永久留在列表里 # 对: consul check + deregister_critical_service_after = 1m
// 错: 判死只做 promote(replica), 旧主进程原地放着不管 // 对: demote(old) + revokeVIP(old) + fence(old.epoch) 三连
# 错: down-after-milliseconds 5000 # 5s: 复制抖动就误切 # 对: down-after-milliseconds 30000 # 30s: 稳, 接管慢的代价可算
expireAt 与服务端比较, 两边时钟不可比. 正解: 过期判定只在服务端(etcd lease/ZK session), 客户端只监听回调。 // 错: if time.Now().After(localExpireAt) { reconnect() } // 时钟会说谎 // 对: <-lease.Done() // 服务端裁决过期, 客户端被动感知
# 错: check.tcp = ":8080" # 内核代答, 死锁照样过 # 对: http 探针打真实 handler, 2s 内 200 才算活
// 错: /health 里 runtime.GC() + dump 全量 heap // 探针每秒压一次 // 对: /health O(1); pprof 独立端口 + 鉴权 + 手动触发
read-only 类错误长达半小时. 原因: 连接池长连接永不回收, 还握着旧主. 正解: maxLifetime 短于故障恢复周期, 或 failover 回调里主动重建连接池。 # 错: maxLifetime=0(永不回收) → 30min 后还在向旧主写 # 对: maxLifetime=60s; failover 回调里主动重建连接池
# 错: 跨机房探针 timeout=1s # 晚高峰抖 2s → 天天误判 # 对: 各机房探自己的实例, 跨机房只传结论不传探测
// 错: members 里看不到 leader → 自己 becomeLeader() // 双主 // 对: becomeLeader 前先 Campaign 过多数派 / 续上租约
successThreshold 让回流渐进。 # 错: failureThreshold: 1 + periodSeconds: 1 # 单次超时就摘 # 对: failureThreshold: 3 · successThreshold: 2 # 摘慢回稳
# 错: liveness 200 + readiness 503 并存 8 小时无人处理 # 对: readiness 失败持续 15min → 告警升级; 视情况 liveness 兜底
# 错: 所有探针 timeoutSeconds: 1 # 重检查必然超时误判 # 对: 轻探针 1s; 重检查独立端点 timeout 5s + period 30s
kill -STOP 模拟装死, 计时切换, 验证回切数据一致。 # 错: 剧本从未执行过, 故障时边查 wiki 边操作 # 对: 季度演练: kill -STOP → 切换计时 < SLO → 回切核对 GTID 一致