单点故障与冗余设计:从一次线上事故说起

我记得刚接手特征服务那会儿,遇到过一件让我印象特别深的事。凌晨三点,告警电话响了——线上特征获取全部超时。我登录上去一看,单机部署的 Redis 挂了,所有流量都打在这台机器上,它一倒,整个业务跟着停。那晚我就在想:单点故障,说白了就是把所有鸡蛋放在一个篮子里。篮子一翻,全完蛋。

这一章,我们就来聊聊怎么拆掉这些「篮子」。

单点故障分析:你的系统里藏着多少「单点」?

单点故障(Single Point of Failure,SPOF)不光是机器挂了这么简单。我习惯把单点分成三个层面来看:

层面 典型例子 影响范围
计算层 单实例特征计算服务、单进程模型 该实例承载的所有特征请求不可用
存储层 单节点 Redis、单主库 MySQL 全量特征数据不可读/写
网络/接入层 单台 Nginx、单个 SLB 实例 所有外部请求无法进入系统

我在项目中遇到过最隐蔽的单点,是配置中心。当时我们用了单节点的 Apollo,结果配置推送时那个节点 OOM 了,所有服务拿不到最新特征版本号,线上特征全部回退到默认值。嗯,这个坑踩得值。

避坑指南:我曾经以为「数据库有主从切换就不算单点」,结果主库挂了之后,从库晋升期间写操作全部阻塞了 30 秒。记住:切换过程本身也是不可用时间。真正的冗余,要连切换动作一起消除。

冗余设计三剑客:主备、多活、无状态

冗余不是简单「多放几台机器」。你想想看,如果两台机器共享同一个数据库,那数据库还是单点。冗余要的是独立故障域——一台挂了,另一台完全不受影响。

1. 主备模式:简单但切换有代价

主备是最基础的冗余策略。一台主节点处理所有请求,一台或多台备节点冷眼旁观。主挂了,备顶上。

我个人的习惯是:主备适合对一致性要求高、但对切换时间容忍度在 10 秒以上的场景。比如特征存储的 MySQL 主从,或者 ZooKeeper 的 Leader-Follower 模式。

# 主备切换的典型流程(伪代码)
if health_check(master) == FAIL:
    promote_slave_to_master(slave_id)
    update_dns_or_vip(new_master_ip)
    notify_all_clients_to_reconnect()
    # 注意:这期间所有写请求都会失败或阻塞
我的经验:主备切换最怕「脑裂」。我曾经见过主备之间网络抖动,备机以为主机挂了就自己升主,结果两台都在写数据。解决方案?引入第三方仲裁(比如 etcd 租约),或者用 STONITH(Shoot The Other Node In The Head)强制关掉旧主。

2. 多活模式:真正的「零切换」

多活,说白了就是所有节点同时处理请求,挂了任何一个都不影响整体。这是在线特征服务的理想形态。

多活又分两种:

  • 同城多活:多个机房在同一个城市,延迟 1-3ms。适合特征数据实时同步。
  • 异地多活:机房跨城市甚至跨洲,延迟 10-100ms。需要处理数据冲突。

我在设计特征服务时,用的是「写本地、读全局」的模式。每个机房独立写入本地特征存储,然后通过消息队列异步同步到其他机房。读取时,客户端根据特征 key 的哈希值选择最近的机房。

# 多活写入策略示意
def write_feature(user_id, feature_name, value):
    local_cluster = get_local_cluster()
    local_cluster.write(user_id, feature_name, value)
    
    # 异步同步到其他集群
    async_send_to_other_clusters({
        'user_id': user_id,
        'feature': feature_name,
        'value': value,
        'timestamp': now()
    })
核心要点:多活不是「所有节点都能写」就完了。你必须解决数据冲突问题。我常用的策略是「最后写入者获胜」(LWW),配合全局时钟或逻辑时钟。如果业务要求强一致性,那就得用 Paxos/Raft 了——但性能会打折扣。

3. 无状态设计:冗余的「终极形态」

你想想看,为什么主备和多活都这么麻烦?因为状态。状态让节点之间产生了依赖。如果服务本身不保存任何状态,那冗余就变得极其简单——随便加机器,随便重启,流量随便切。

无状态设计的原则就三条:

  1. Session 数据外置:用户登录态、临时缓存全部放到 Redis 或 Memcached。
  2. 本地不落盘:特征计算服务不写本地文件,所有持久化走远端存储。
  3. 配置动态加载:启动时不依赖本地配置文件,从配置中心拉取。
# 有状态 vs 无状态对比
# ❌ 有状态:本地缓存特征
class FeatureService:
    def __init__(self):
        self.local_cache = {}  # 挂了就丢
    
    def get_feature(self, key):
        if key in self.local_cache:
            return self.local_cache[key]
        return db.query(key)

# ✅ 无状态:缓存外置到 Redis
class FeatureService:
    def __init__(self, redis_client):
        self.redis = redis_client  # Redis 集群自身有冗余
    
    def get_feature(self, key):
        value = self.redis.get(key)
        if value is None:
            value = db.query(key)
            self.redis.set(key, value, ttl=60)
        return value
注意:无状态不代表「没有状态」,而是把状态托管给了有冗余能力的中间件。你仍然要保证 Redis、MySQL 这些中间件自身的高可用。我曾经见过一个团队把 Session 放进了 Redis,但 Redis 是单节点——结果 Redis 挂了,所有用户强制重新登录。这就是「把单点从 A 挪到了 B」。

一张图看懂本章核心逻辑

下面这张图,是我自己画的一个总结。它把单点故障的来源、三种冗余策略、以及它们之间的关系串在了一起。

单点故障与冗余设计知识图谱 单点故障与冗余设计核心逻辑 单点故障来源 计算层单实例 存储层单节点 网络/接入层单点 配置/协调单点 三种冗余策略 主备模式 多活模式 无状态设计 关键要点 切换时间 < 10s 数据冲突解决(LWW) 状态外置到中间件 独立故障域 设计原则总结 1. 识别所有单点:计算、存储、网络、配置,一个都不能漏 2. 根据业务容忍度选择策略:主备简单但切换慢,多活复杂但零切换 3. 无状态是终极目标:让冗余变得像「加机器」一样简单

实战中的选择:什么时候用哪种?

没有银弹。我一般按这个逻辑来选:

  • 特征数据存储:用多活 + 最终一致性。因为特征数据允许短暂不一致(比如几秒内读到旧值),但不能不可用。
  • 特征计算服务:用无状态 + 水平扩展。计算本身不存数据,随便加 Pod。
  • 配置/元数据:用主备 + 强一致性。配置错了比配置不可用更可怕。
一个小技巧:我习惯在架构图上给每个组件标一个「单点风险等级」:🔴 红色表示单点,🟡 黄色表示有冗余但切换有损,🟢 绿色表示完全冗余。这样一眼就能看出哪里最危险。