单点故障与冗余设计:从一次线上事故说起
我记得刚接手特征服务那会儿,遇到过一件让我印象特别深的事。凌晨三点,告警电话响了——线上特征获取全部超时。我登录上去一看,单机部署的 Redis 挂了,所有流量都打在这台机器上,它一倒,整个业务跟着停。那晚我就在想:单点故障,说白了就是把所有鸡蛋放在一个篮子里。篮子一翻,全完蛋。
这一章,我们就来聊聊怎么拆掉这些「篮子」。
单点故障分析:你的系统里藏着多少「单点」?
单点故障(Single Point of Failure,SPOF)不光是机器挂了这么简单。我习惯把单点分成三个层面来看:
| 层面 | 典型例子 | 影响范围 |
|---|---|---|
| 计算层 | 单实例特征计算服务、单进程模型 | 该实例承载的所有特征请求不可用 |
| 存储层 | 单节点 Redis、单主库 MySQL | 全量特征数据不可读/写 |
| 网络/接入层 | 单台 Nginx、单个 SLB 实例 | 所有外部请求无法进入系统 |
我在项目中遇到过最隐蔽的单点,是配置中心。当时我们用了单节点的 Apollo,结果配置推送时那个节点 OOM 了,所有服务拿不到最新特征版本号,线上特征全部回退到默认值。嗯,这个坑踩得值。
冗余设计三剑客:主备、多活、无状态
冗余不是简单「多放几台机器」。你想想看,如果两台机器共享同一个数据库,那数据库还是单点。冗余要的是独立故障域——一台挂了,另一台完全不受影响。
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()
# 注意:这期间所有写请求都会失败或阻塞
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()
})
3. 无状态设计:冗余的「终极形态」
你想想看,为什么主备和多活都这么麻烦?因为状态。状态让节点之间产生了依赖。如果服务本身不保存任何状态,那冗余就变得极其简单——随便加机器,随便重启,流量随便切。
无状态设计的原则就三条:
- Session 数据外置:用户登录态、临时缓存全部放到 Redis 或 Memcached。
- 本地不落盘:特征计算服务不写本地文件,所有持久化走远端存储。
- 配置动态加载:启动时不依赖本地配置文件,从配置中心拉取。
# 有状态 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
一张图看懂本章核心逻辑
下面这张图,是我自己画的一个总结。它把单点故障的来源、三种冗余策略、以及它们之间的关系串在了一起。
实战中的选择:什么时候用哪种?
没有银弹。我一般按这个逻辑来选:
- 特征数据存储:用多活 + 最终一致性。因为特征数据允许短暂不一致(比如几秒内读到旧值),但不能不可用。
- 特征计算服务:用无状态 + 水平扩展。计算本身不存数据,随便加 Pod。
- 配置/元数据:用主备 + 强一致性。配置错了比配置不可用更可怕。