系统架构 · 故障检测与可用性

没收到心跳 ≠ 死了: 网络堵 / GC 停 20s / CPU 打满都会装死 — 判死太快的代价是双主双写, 判死太慢的代价是停机拉长

系统架构 · 故障检测与可用性: 心跳时序 / phi 爬升 / 误判链 vs 正确链 / 三探针分工 心跳缺失 ≠ 死亡 · 判死 = 概率推断 + 多数派确认 ① 心跳时序与 Phi Accrual: 沉默时间换算成怀疑度 φ φ 由历史心跳到达间隔分布估出, 阈值一般取 8 (Akka 默认) 心跳序列: 500ms 一次 ping/pong A 仍在发 ping, 只是收不到 pong ping pong pong 消失: node-B Full GC STW 20s — 活着, 不是死了 0 1s 2s 4s t=2s 起 pong 消失 φ 怀疑度: 沉默越久, 爬得越快 (指数型) φ φ=8 判死阈值 φ 爬升越过阈值才判死, 不看单次超时 2s 4s ≈5.5s 判死 x 轴: 距最后一次心跳的时间 · φ 由到达间隔分布累积估出 ② 判死之后: 误判链 (rose) vs 正确链 (emerald) 判死太快 = 双主双写, 判死太慢 = 停机拉长 — 三关设计平衡两者 事故链 · 单次超时立刻切 1. node-B Full GC 停顿 20s (STW) 心跳线程也停 → 连丢 2 个 pong 2. down-after=5s 一到 → 立即判死 不看历史分布, 不问其他哨兵 3. 秒级 failover: node-A 升主 客户端随即把写切到新主 A 4. GC 结束 B 醒来, 仍自认是主 A、B 同时收写 → 双主双写数据错乱 正确链 · 判死要过三关 第 1 关 · 连续 N 次失败才计数 period 5s × 3: 单次超时只扣分, 不动作 第 2 关 · φ≥8 或 failureThreshold 达标 看心跳到达间隔分布, 不看单点超时 第 3 关 · quorum 确认才 failover Sentinel: sdown → odown 需 ≥quorum 票 4. 切换 + 新主带更高 epoch 旧主复活被 fencing 拒之门外 → 恒单主 事故现场: 同一笔扣款在 A、B 各执行一次 → ERROR 1062 (23000): Duplicate entry 'PAY20260926001' for key 'uk_order_no' Sentinel 只认 odown: quorum 个哨兵都报 sdown 才切换, 单哨兵无权动主 ③ K8s 三探针分工: 谁管启动, 谁管流量, 谁管生死 失败动作完全不同: 摘流 ≠ 重启 — 配错动作 = 放大事故 Startup Probe 启动探针 管「起来了吗」 · 只在启动期生效 成功前: liveness/readiness 全部失效 给 JVM 预热 / 大镜像拉取留启动预算 失败: 按 failureThreshold 重试后重启 failureThreshold: 36 · periodSeconds: 5 Readiness Probe 就绪探针 管「能接流量吗」 · 全生命周期 失败 → 从 Endpoints 摘除, 不重启 依赖抖动时先摘流自愈, 而不是陪葬 successThreshold 防止回流抖动 periodSeconds: 5 · failureThreshold: 3 Liveness Probe 存活探针 管「还活着吗」 · 死锁/僵死兜底 失败 → kubelet 重启容器 检查必须极轻: 只查自身进程状态 绝不查 DB/下游 — 那是 readiness 的事 timeoutSeconds: 2 · periodSeconds: 10 配比经验: timeoutSeconds ≪ periodSeconds · timeout 必须大于被检测方的最大合理停顿(GC/锁), 否则就是定时误判器

机制视角 — 判死是统计问题

  • • 心跳是采样不是证明: 单次超时 = 证据不足
  • • φ 由到达间隔分布累积, 沉默越久爬得越快
  • • 三关判死: 连续 N 次 → φ 阈值 → quorum 确认
  • • suspect 必须可复活: 收到心跳立即恢复并补数据

行为视角 — 三探针分工

  • • startup 管启动预算, 成功前屏蔽其他探针
  • • readiness 管流量: 失败摘流, 服务不中断
  • • liveness 管生死: 失败重启, 检查必须极轻
  • • 摘流 ≠ 重启: 配错动作 = 把抖动放大成事故

生产价值 — 切换的代价账

  • • 每次误切 = 一次非计划 failover + 数据一致性风险
  • • down-after / failureThreshold 是「误判率×接管速度」的旋钮
  • • 超时必须大于被检测方最大合理停顿(GC/锁)
  • • 剧本不演练 = 真故障时现学, 切换 40 分钟起步

💡 一句话理解

医生不会因为病人一次没接电话就宣布死亡: 先量生命体征(心跳), 再对照病史看异常程度(Phi Accrual), 最后多学科会诊(quorum)才下结论。故障检测的本质是概率推断, 不是布尔开关 — 没收到心跳只说明「通信失败」, 而通信失败的原因清单里有: 网络分区、Full GC 停 20 秒、CPU 打满、丢包, 它们全都伪装成死亡。

判死的两个方向都会出事: 判太快, 误切引发双主双写, 数据修复比停机贵得多; 判太慢, 真死了没人接管, 停机窗口被拉长。所以工程上把判死拆成三关(连续 N 次 / φ 阈值 / 多数派确认), 再用三探针把「摘流量」与「重启进程」两种动作分开 — 这就是本页的全部主线。

🧠 必知必会 必考 & 必会

Heartbeat 心跳
周期性「我还活着」信号, 最朴素的活性证据。要点: 心跳丢失只能推出「通信失败」, 推不出「进程死亡」— 网络分区、Full GC、CPU 打满都伪装成死亡, 所以丢失要计数而不是立刻宣判。
for range ticker.C {
    if err := ping(peer); err != nil { misses++ } // → misses==1: 只告警
}
// 关键: misses 是计数器不是开关, 到 N 才升级为 suspect
Health Check 健康检查
主动探测, 比心跳多一层「服务语义」: HTTP /healthz、TCP 端口、执行命令。要点: 健康检查分「活着」(liveness)与「能服务」(readiness)两种语义, 混用会把依赖抖动放大成重启风暴。
func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)  // → 200, 只证明进程能响应
}
// 关键: healthz 只查自身, 不碰 DB — 依赖挂了是 readiness 的事
Liveness Probe 存活探针
K8s 中失败 → kubelet 重启容器。用于清除死锁/僵死; 检查必须极轻, 误触发等于无计划重启风暴 — 这是三种探针里最「危险」的一个。
livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10   # 关键: 失败动作是重启, 检查必须轻到 O(1)
  failureThreshold: 3
Readiness Probe 就绪探针
失败 → 从 Service Endpoints 摘除(不重启)。用于依赖抖动时先摘流自愈; 配得太敏感(单次失败即摘)反而造成流量振荡, 流量在实例间来回打摆。
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
  failureThreshold: 3  # 关键: 失败动作只是摘流, 才敢放依赖检查
Startup Probe 启动探针
慢启动容器(JVM 预热/大镜像)专用: 成功之前屏蔽 liveness/readiness, 防止启动期被误杀。有了它, liveness 上的 initialDelaySeconds 基本可以删掉。
startupProbe:
  httpGet: { path: /healthz, port: 8080 }
  failureThreshold: 30  # 30×5s=150s 启动预算, 成功后其他探针才接管
  # 关键: JVM 冷启 90s 的服务, 没 startup 会在第 30s 被 liveness 杀掉
Timeout 超时
一切探测的止损线。设置公式: 超时 ≥ 被检测方正常耗时 P99 × 2~3, 且必须大于其最大合理停顿(GC/锁等待) — 超时小于 GC 停顿的探测, 是一台「定时误判器」。
timeoutSeconds: 1  # 示例见坑 3: 1s < JVM STW 20s → 每次大 GC 必误判
# 关键: 超时必须 > 被检测方的最大合理停顿, 而不是越小越「严格」
Failure Detector 故障检测器
把「超时」升级成决策组件的抽象。理论结论: 异步系统里完美故障检测器不存在, 只有 eventually perfect — 所以每次判死都必须可撤销(suspect → 收到心跳 → revive + 补数据)。
// 伪代码: 检测器的关键不是判死, 是「判死可撤销」
detector.OnSuspect(func(n ID) { quarantine(n) }) // 疑似: 先隔离
detector.OnRevive(func(n ID) { resync(n) }) // 关键: 复活必须回补数据
Phi Accrual 累积怀疑度
不输出「死没死」, 输出连续量 φ: 由历史心跳到达间隔的分布, 估算「沉默 t 秒后对方还活着」的置信度。φ 越高越可疑, 应用按业务选阈值(Akka 默认 8): 支付链路调低(快判), 报表链路调高(慢判)。
phi := fd.Phi(time.Since(lastHeartbeat))
if phi >= 8 { markSuspect(peer) } // Akka 默认阈值 8, 可按业务调 6~12
// 关键: φ 是由到达间隔分布算出的连续量, 不看单次超时
Gossip 流言式成员管理
每秒随机互传成员表, O(log N) 轮全集群收敛; Suspect 状态像传闻扩散, 多数成员附议后才 Dead(SWIM 用间接探测找人作证)。代价: 收敛期内各节点视图允许短暂分叉 — 所以 gossip 传「怀疑」, 不传「授权」。
// SWIM/memberlist: 直探失败 → 找 k 个伙伴间接探测 → 才广播 suspect
// 关键: 怀疑要找人 corroborate(间接探测), 不自己一票定死
Suspect / Dead 判定
两阶段下线: 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 才切换
Failover 故障切换
把流量从判死节点迁到备位。切换三件套: 数据侧(新主可写、同步追平) + 路由侧(客户端/中间件刷新指向) + 身份侧(新主带更高 epoch, 旧主复活被 fence)。缺任何一件, 切换就是在制造双主。
epoch := raft.BecomeLeader()  // 伪代码: 新任期 = 旧任期+1, 多数派落盘
grantFencingToken(epoch)      // 新主后续写必须带这个 token
// 关键: 提权 + 刷路由 + 发新 epoch, 缺一就是给双主开门票
Failback 故障回切
故障节点修复后迁回主位。红线: 数据未追平不得回切 — 回切本质是又一次 failover, 要走同样的 quorum 确认与同步检查, 差 1 秒日志都不行。
-- 回切前检查: 副本必须已追平
SHOW REPLICA STATUS;  -- MySQL 8.0.22+ → Seconds_Behind_Master: 0
-- 关键: 差 1 秒日志也不回切, 回切本身就是一次 failover
Active-Standby / Active-Active
主备(单活): 备位不接流量, 切换简单但资源闲置; 双活: 都接流量, 利用率高但必须解决写冲突 — 要么按分片路由互不重叠, 要么接受 LWW 丢更新。没有互斥路由的双活, 等于没有协调的双主。
shard := crc32(userID) % 2 // 双活按分片路由, 两机房写的 key 永不重叠
// 关键: 没有「分片互斥」这个前置协调, Active-Active 就是灾难片

🏭 生产实战 real world

场景 1 · 大促前调优 Sentinel: 主库每小时被误切两次

双 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 换算过, 可接受

场景 2 · Spring Boot 服务的三探针分层落地模板

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

场景 3 · /healthz 轻量端点设计: liveness 与 readiness 分家的 Go 模板

常见反模式是一个 /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)
}

场景 4 · 500 节点集群的 Gossip 成员管理: memberlist 参数实配

自研控制面用 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

场景 5 · 故障切换演练: 用 SIGSTOP 模拟「GC 式装死」

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 新主, 立刻修剧本

场景 6 · Failback 数据回迁: 旧主修复后的 GTID 追平回切

故障主修复后重新入组, 先以副本身份追平新主, 追平之前保持只读 — 回切窗口的双写是数据事故的头号来源。

-- 旧主回切流程 (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;

场景 7 · Kafka 消费组心跳配比: 3 倍法则防误踢

消费组反复 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

场景 8 · 事故排查: Full GC 把节点「装死」, liveness 反复误杀

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+轻检查兜底

场景 9 · 探活接口去依赖化: Redis 挂了不再重启全站

曾把 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, 失败只摘流不重启

场景 10 · 双活机房探活: 各探各的, 结论跨机房同步

两地机房互发探针, 公网晚高峰抖 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), 不传「探测流量」

⚠️ 编码注意与常见坑 pitfalls

坑 1 · liveness 里检查 DB, Redis 一抖全站重启 — 症状: 下游 Redis 抖 10s, 全部 pod 依次被 kubelet 重启, 缓存全冷雪崩. 原因: liveness 端点里 ping 了外部依赖, 依赖故障被翻译成「进程死了」. 正解: liveness 只查自身, 依赖检查放 readiness。
# 错: livenessProbe: /health  # /health 内部 ping DB → DB 抖 = 全部重启
# 对: liveness 只查自身; DB 检查放 readiness, 失败摘流不重启
坑 2 · 一次失败即判死, failureThreshold=1 — 症状: 网络单次抖动就摘流/重启, 抖动被放大成事故. 原因: 阈值=1 把「采样噪声」当「证据」. 正解: 连续 N 次失败才动作, N×period 形成 15s 以上的观察窗。
# 错: failureThreshold: 1  # 单次超时就动作
# 对: failureThreshold: 3 + periodSeconds: 5 # 15s 窗口连挂才动作
坑 3 · 探测超时小于最大 GC 停顿 — 症状: JVM 每次大 GC 都被摘一次流量/重启一次. 原因: timeoutSeconds=1 而 STW 停 20s, GC 必然「超时」. 正解: 超时必须大于最大合理停顿, 重检查不放 liveness。
# 错: timeoutSeconds: 1  # JVM STW 20s → 每次大 GC 必误判
# 对: timeoutSeconds: 5 + 轻 liveness; STW 用 -XX:MaxGCPauseMillis 收敛
坑 4 · 探针端口/路径配错, 探的一直是 sidecar — 症状: 应用彻底 hang 死却不被重启, 看探针日志全是 200. 原因: 容器里 8080 被 sidecar(envoy)占用, 探针打到了 sidecar. 正解: 用 named port 引用, 端口变更不漂移。
# 错: port: 8080  # sidecar 占了 8080 → 一直探的是 sidecar
# 对: port: http  # named port, containerPort 声明处统一管
坑 5 · 健康检查里有锁或慢查询, 探针排队拖死实例 — 症状: 大表期间探针超时率飙升, 甚至把应用线程池占满. 原因: /health 里跑了 COUNT(*) 全表扫描, 或和其他接口抢同一把锁. 正解: 健康检查 O(1) 返回预计算状态位。
// 错: db.query("SELECT COUNT(*) FROM orders") // 全表扫, 探针排队
// 对: return cachedHealthFlag  // 后台协程预计算, O(1) 返回
坑 6 · failback 未追平先切回, 差 10s 日志丢单 — 症状: 旧主修好后立刻切回, 用户投诉扣款成功但订单消失. 原因: 旧主还差 10s 复制日志, 切回的 10s 写入全部丢失. 正解: Seconds_Behind_Master=0 才回切, 回切走完整剧本。
-- 错: 直接把 VIP 切回旧主 → Seconds_Behind_Master=10, 中间写入丢失
-- 对: SHOW REPLICA STATUS 确认 0 延迟后, 按剧本回切
坑 7 · N² 心跳连接风暴 — 症状: 500 实例全互联互探, 单机 12 万+长连接, fd 与带宽先爆. 原因: 每对节点都维护探测连接. 正解: gossip(每节点固定条数)或星型拓扑(协调者代探), 连接数 O(N)。
# 错: 全互联: n=500 → 500×499/2 ≈ 12.5 万条连接
# 对: gossip 每节点固定 3 个伙伴, 或协调者星型: 连接数 O(N)
坑 8 · 僵尸实例不剔除, 流量打向幽灵 — 症状: 注册表里 8 个实例一半早已不存在, 请求打过去超时再重试, 成功率上不去. 原因: 实例暴毙没机会反注册, 注册表只进不出. 正解: TTL 心跳/健康检查 + deregister_critical_service_after 自动注销。
# 错: 注册后永不注销, 实例 crash 后永久留在列表里
# 对: consul check + deregister_critical_service_after = 1m
坑 9 · 判死后无隔离动作, 旧主复活继续写 — 症状: 切换成功, 但 20s 后旧主 GC 结束复活, 拿着旧身份继续收写 → 双主. 原因: 判死只做了 failover, 没做隔离(fence). 正解: 判死三连: 旧主置只读 / 撤路由 / 存储层拒绝旧 epoch。
// 错: 判死只做 promote(replica), 旧主进程原地放着不管
// 对: demote(old) + revokeVIP(old) + fence(old.epoch) 三连
坑 10 · sentinel down-after-milliseconds 过短 — 症状: 复制抖动/主从网络闪断就被当成宕机, 一天切换十几次. 原因: 5s 阈值低于 Redis 常见抖动窗口(AOF 重写/全量同步). 正解: 30s 起步, 与业务可容忍接管窗口对齐。
# 错: down-after-milliseconds 5000   # 5s: 复制抖动就误切
# 对: down-after-milliseconds 30000  # 30s: 稳, 接管慢的代价可算
坑 11 · TTL 探针用客户端时钟判过期, 时钟漂移全错 — 症状: 某台机器 NTP 慢 5s 后, 它的租约/心跳全被判过期, 反复重连. 原因: 客户端本地算 expireAt 与服务端比较, 两边时钟不可比. 正解: 过期判定只在服务端(etcd lease/ZK session), 客户端只监听回调。
// 错: if time.Now().After(localExpireAt) { reconnect() } // 时钟会说谎
// 对: <-lease.Done() // 服务端裁决过期, 客户端被动感知
坑 12 · TCP 探活只证端口开, 死锁照样 200 — 症状: 应用内部死锁完全无响应, 探针却一直报健康. 原因: TCP 三次握手由内核完成, 进程 hang 住也能 accept. 正解: 用 HTTP 探针打真实 handler, 超时内返回才健康。
# 错: check.tcp = ":8080"  # 内核代答, 死锁照样过
# 对: http 探针打真实 handler, 2s 内 200 才算活
坑 13 · 健康检查自身把服务压垮 — 症状: 探针频率 1s 一次, /health 里顺带导出 pprof heap + 全量指标, 实例 CPU/内存被健康检查自己打爆. 原因: 把「重诊断」挂在了「轻探活」路径上. 正解: /health O(1); pprof 独立端口 + 手动触发。
// 错: /health 里 runtime.GC() + dump 全量 heap // 探针每秒压一次
// 对: /health O(1); pprof 独立端口 + 鉴权 + 手动触发
坑 14 · 切换后客户端连接池未刷新, 老连接打向旧主 — 症状: failover 完成, 应用却持续报 read-only 类错误长达半小时. 原因: 连接池长连接永不回收, 还握着旧主. 正解: maxLifetime 短于故障恢复周期, 或 failover 回调里主动重建连接池。
# 错: maxLifetime=0(永不回收) → 30min 后还在向旧主写
# 对: maxLifetime=60s; failover 回调里主动重建连接池
坑 15 · 心跳走公网/跨城链路, 抖动误判 — 症状: 跨机房部署后, 每晚高峰互相「判死」几次. 原因: 跨城 RTT 30ms 但晚高峰抖到 2s, 而 timeout 只配了 1s. 正解: 探活本地化 — 各机房探本机实例, 跨机房只同步健康结论。
# 错: 跨机房探针 timeout=1s  # 晚高峰抖 2s → 天天误判
# 对: 各机房探自己的实例, 跨机房只传结论不传探测
坑 16 · gossip 收敛慢期间脑裂 — 症状: 网络闪断 30s, 恢复后发现两个「主」各写了一份数据. 原因: 各节点按本地成员表「对面没有主就自己上」, 收敛期内互相不知情. 正解: 当选/切换必须过多数派投票或租约, gossip 只传怀疑不授权。
// 错: members 里看不到 leader → 自己 becomeLeader()  // 双主
// 对: becomeLeader 前先 Campaign 过多数派 / 续上租约
坑 17 · readiness 抖动反复摘流, Endpoints 每分钟跳十次 — 症状: 流量在实例间来回打摆, 尾延迟反而变差. 原因: readiness 太敏感(单次超时即摘), 回流又太快. 正解: failureThreshold≥3 拉长观察窗, 配 successThreshold 让回流渐进。
# 错: failureThreshold: 1 + periodSeconds: 1 # 单次超时就摘
# 对: failureThreshold: 3 · successThreshold: 2 # 摘慢回稳
坑 18 · 多探针互相矛盾, 永远「活着但不接活」 — 症状: pod NotReady 几小时却不重启, 容量白白损失. 原因: liveness 一直过(进程活着), readiness 一直挂(依赖坏了), 没人兜底. 正解: 明确升级路径 — readiness 失败超 N 分钟告警/人工介入或 liveness 兜底重启。
# 错: liveness 200 + readiness 503 并存 8 小时无人处理
# 对: readiness 失败持续 15min → 告警升级; 视情况 liveness 兜底
坑 19 · 探活超时全局同一个值 — 症状: 全部探针 timeoutSeconds=1, 慢接口(报表导出预热)必然超时被误判. 原因: 不同检查的正常耗时差两个数量级, 却共用一个超时. 正解: 按检查项分层: 轻探针 1s, 重检查单独端点 + 更长超时更低频率。
# 错: 所有探针 timeoutSeconds: 1 # 重检查必然超时误判
# 对: 轻探针 1s; 重检查独立端点 timeout 5s + period 30s
坑 20 · 切换剧本无人演练, 大促当夜现学 — 症状: 真故障时手忙脚乱, 切换 40 分钟, 回切又双写. 原因: 剧本躺在 wiki, 从未完整执行过一次. 正解: 季度演练 — kill -STOP 模拟装死, 计时切换, 验证回切数据一致。
# 错: 剧本从未执行过, 故障时边查 wiki 边操作
# 对: 季度演练: kill -STOP → 切换计时 < SLO → 回切核对 GTID 一致