4. 故障检测与恢复:心跳检测、超时重试策略、故障转移与回切
故障检测与恢复,说白了就是容灾系统的「眼睛」和「手脚」。眼睛要能及时发现谁挂了,手脚要能快速把活接过来,等原主恢复了再还回去。这章我重点聊聊心跳检测、超时重试、故障转移和回切这几个核心机制。
4.1 心跳检测:别让「假死」骗了你
心跳检测是故障检测最基础的手段。每个节点定期向监控中心发送「我还活着」的信号。但这里有个坑——心跳超时并不等于节点挂了。
我在项目中遇到过这样的情况:网络短暂抖动,心跳包延迟了 200ms,结果系统直接判定节点死亡,触发了切换。切换本身又花了 2 秒,等网络恢复后,两个节点都以为自己是主节点,数据乱成一锅粥。
心跳检测的几个关键参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 心跳间隔 | 1~3 秒 | 太频繁浪费带宽,太慢延迟高 |
| 超时次数 | 3~5 次 | 容忍短暂网络抖动 |
| 超时窗口 | 间隔 × 次数 + 缓冲 | 建议加 20% 缓冲时间 |
4.2 超时重试策略:别把系统「重试死」
超时重试看起来简单,但用不好就是灾难。你想想看,如果 1000 个请求同时超时,每个都重试 3 次,瞬间流量就变成 4000。系统本来只是有点堵,这下直接被打死。
我常用的策略是 指数退避 + 随机抖动:
// 指数退避重试示例
func retryWithBackoff(operation func() error, maxRetries int) error {
baseDelay := 100 * time.Millisecond
for i := 0; i < maxRetries; i++ {
err := operation()
if err == nil {
return nil
}
// 指数退避:2^i * baseDelay
delay := time.Duration(math.Pow(2, float64(i))) * baseDelay
// 随机抖动:±25%
jitter := time.Duration(rand.Int63n(int64(delay / 4)))
delay = delay - jitter/2 + jitter
time.Sleep(delay)
}
return fmt.Errorf("重试 %d 次后仍然失败", maxRetries)
}
超时重试还有个容易忽略的点——幂等性。重试的请求必须是幂等的,否则重复扣款、重复下单这种事故就来了。我见过一个支付系统,重试时没做幂等校验,同一个订单被扣了两次钱,差点出大事。
4.3 故障转移:切换不是拍脑袋
故障转移分两种:冷备切换和热备切换。冷备就是备机平时不干活,主挂了才启动;热备是备机一直同步数据,随时准备接手。
我个人更倾向于热备方案。虽然成本高一点,但切换时间能控制在秒级。冷备的话,光拉起服务、加载数据就要好几分钟,业务早就炸了。
故障转移的核心步骤:
- 确认故障:心跳超时 + 其他节点交叉验证,避免误判
- 选举新主:用共识算法(如 Raft)或预设优先级选出一个新主
- 数据对齐:新主补全未同步的增量数据
- 切换流量:更新 DNS、负载均衡或客户端配置,指向新主
- 通知下游:让依赖方知道主节点变了
4.4 回切:比切换更考验人
回切就是把服务从新主切回原主。很多人觉得切换难,其实回切才是真正的技术活。为什么?因为原主恢复后,它的数据可能已经落后了。
我见过一个案例:原主挂了 10 分钟,新主处理了 5 万笔交易。回切时直接让原主接管,结果那 5 万笔交易全丢了。这就是典型的「回切事故」。
正确的回切流程应该是:
- 数据追赶:原主先作为从节点,从新主同步缺失的数据
- 一致性校验:对比关键数据的 checksum,确保两边一致
- 灰度切换:先切 10% 的流量到原主,观察一段时间
- 全量切换:确认没问题后,再把全部流量切回去
4.5 整体流程:一张图说清楚
下面这张图展示了从故障检测到回切的完整流程。我把它画成了状态机,每个阶段都有明确的触发条件和动作。
嗯,这里要注意:故障转移和回切是两个独立路径。不是每次故障都要回切,也不是每次回切都能成功。我建议在回切前做一次完整的演练,确认数据一致性和流量切换的平滑度。
4.6 总结几个关键原则
- 宁可漏判,不要误判:误切换的成本远高于延迟切换
- 重试要有上限:无限重试等于自杀
- 回切要慢:灰度切换是保命符
- 记录切换日志:每次切换都要有完整的审计记录,方便事后复盘
这些原则是我在多次故障复盘后总结出来的。说白了,故障检测与恢复不是技术问题,而是工程问题——你要在「快」和「稳」之间找到平衡点。