服务介绍
serverA是一个数据仓库服务,提供数据的版本管理,储存和下载等功能。
线上服务变更大致可分为代码变更、配置变更、数据变更,serverA广泛用于线上数据变更。
由于数据处理对磁盘 IO 性能要求较高(如顺序写入和随机读取),相比网络存储(如Ceph,对象存储等),本地磁盘能提供更低的延迟和更高的吞吐量,因此被选为存储介质。这也使其归属于有状态服务。
同城双活架构
flowchart TB
%% 定义城市A
subgraph 城市A
direction LR
subgraph IDC1["IDC1"]
clientA1["客户端"]
ingressA1["接入层(nginx)"]
serverA1["serverA"]
storageA1["存储层"]
clientA1 --> ingressA1
ingressA1 --> serverA1
serverA1 --> storageA1
subgraph storageA1["存储层"]
mysql1[(MySQL主)]
redis1[(Redis)]
zk1_1[(ZK)]
zk1_2[(ZK)]
end
end
subgraph IDC2["IDC2"]
clientA2["客户端"]
ingressA2["接入层(nginx)"]
serverA2["serverA"]
storageA2["存储层"]
clientA2 --> ingressA2
ingressA2 --> serverA2
serverA2 --> storageA2
subgraph storageA2["存储层"]
mysql2[(MySQL从)]
redis2[(Redis)]
zk2_1[(ZK)]
zk2_2[(ZK)]
end
end
subgraph IDC3["IDC3"]
storageA3["存储层"]
subgraph storageA3["存储层"]
zk3[(ZK)]
end
end
end
%% 外部IDC(交换IDC4和IDC6)
subgraph IDC5
client4["客户端"]
end
subgraph IDC4
client5["客户端"]
end
subgraph IDC6
client6["客户端"]
end
%% 客户端连接
client6 --> ingressA2
client5 --> ingressA1
client4 --> ingressA1
%% 添加样式
classDef client fill:#f9f,stroke:#333;
classDef ingress fill:#bbf,stroke:#333;
classDef serverA fill:#bfb,stroke:#333;
classDef 存储层 fill:#fbb,stroke:#333;
classDef mysql fill:#ffd,stroke:#333;
classDef redis fill:#fdd,stroke:#333;
classDef zk fill:#dfd,stroke:#333;
class clientA1,clientA2,client4,client5,client6 client;
class ingressA1,ingressA2 ingress;
class serverA1,serverA2 serverA;
class storageA1,storageA2,storageA3 存储层;
class mysql1,mysql2 mysql;
class redis1,redis2 redis;
class zk1_1,zk1_2,zk2_1,zk2_2,zk3 zk;
插个题外话,这里用mermaid
画的架构图,推荐大家使用。
mermaid VS PNG
优点
- 可以方便的修改
- 版本管理友好,可以和代码一起放到git仓库。
缺点
- 有门槛,需要掌握专门的语法。
- 这个缺点现在已不是问题,通过AI可以方便的用自然语言画图。
整体来说是利大于弊的。
为什么没有做单元化,实现异地多活?
serverA的业务场景存在跨机房使用,例如IDC1上传的数据,在IDC2进行下载使用,本身就没有机房流量闭环。
如果将用户上传的数据同步到所有IDC,但是实际只有部分IDC使用数据,那么将会浪费大量带宽,磁盘。
存储层双活
存储层会通过同城的网络专线进行数据传输,保证较低的延迟。
Mysql
Mysql通过主从部署,IDC2里serverA的写流量会通过中间件转发到IDC1里的Mysql主库。
主从同步通过 binlog 实现,Master 记录写操作日志,Slave 拉取并重放日志,支持异步模式(高性能但有丢失风险)和半同步模式(折中方案,半同步模式要求 Master 收到至少一个 Slave 确认后提交事务,平衡了性能与安全性)。
在IDC1机房故障的情况下,可通过切主操作,使IDC2正常提供服务。
Redis
Redis Sentinel 可实现主从自动切换,Redis Cluster 提供分片和高可用,适合容灾场景。
由于跨机房的网络专线存在延迟,导致主从复制存在毫秒级延迟,IDC1里写入的数据,IDC2里不能马上可见。
对于一致性敏感的操作,例如通过redis实现分布式锁,就需要应用层保证单机房闭环。也就是IDC1里的应用加的锁,只有IDC1里使用,IDC2里不会用到。
或者仅仅把Redis当做缓存来使用。
ZK
ZK采用 221 部署(共 5 节点),基于 Paxos 协议,需至少 3 节点存活以维持服务,适合跨 IDC 的高可用场景。IDC1,IDC2,IDC3任意机房挂掉,整个ZK集群依旧可以提供服务。
机房故障下切流
通过 DNS 切流和备用域名,保障了服务在机房故障下的可用性。
flowchart TD
A[故障感知]
B[DNS 解析切换]
C[TTL 生效]
D[流量切至存活机房]
E[使用备用域名]
A --> B
B --> C
C --> D
A --> E
E --> D
classDef event fill:#f9f,stroke:#333; %% Light Pink for events/triggers
classDef primaryAction fill:#bbf,stroke:#333; %% Light Blue for primary actions
classDef alternativeAction fill:#fbb,stroke:#333; %% Light Red for alternative actions
classDef outcome fill:#bfb,stroke:#333; %% Light Green for outcomes
class A event;
class B,C primaryAction;
class E alternativeAction;
class D outcome;
DNS切流
在IDC1或IDC2机房故障的时候,需要操作各个机房的DNS服务,将serverA域名指向故障机房的解析,改为指向存活机房。这个止损动作可以考虑自动化实现,减少人工干预,提升切换效率。
各IDC的域名解析TTL建议设置为60s,避免DNS缓存时间过长,影响切流生效时间。
备用域名
serverA包含了前端,用户在内网通过域名访问。
在IDC1机房故障时,需要通过域名解析平台,将域名切到IDC2。但这强依赖于域名解析平台。
出于稳定性,服务重要性,以及降低RT(故障恢复时间)等因素考虑,可以通过配置备用域名的方式来解决。
新增一个备用域名,解析到IDC2,在IDC1故障时,直接用备用域名访问到前端页面。
基础管控服务建议统一备用域名规范。例如命名,主域名a.b.com,备用域名a-backup.b.com。这样便于故障期间各团队同学能准确使用备用域名进行止损操作。
数据一致性
IDC之间
用户数据存到serverA,这个动作抽象为一个备份任务。创建备份任务时,同时往ZK里的两个队列写入任务。IDC1内的serverA从队列1里消费备份任务,IDC2内的serverA从队列2里消费备份任务。达到最终一致性效果。
flowchart TD
P["备份任务生产者"]
subgraph Zookeeper
direction LR
Q1["ZK Queue 1 (for IDC1)"]
Q2["ZK Queue 2 (for IDC2)"]
end
subgraph IDC1
direction LR
S1_IDC1["serverA instance 1"]
S2_IDC1["serverA instance 2"]
end
subgraph IDC2
direction LR
S1_IDC2["serverA instance 1"]
S2_IDC2["serverA instance 2"]
end
P --> Q1
P --> Q2
Q1 --> S1_IDC1
Q1 --> S2_IDC1
Q2 --> S1_IDC2
Q2 --> S2_IDC2
classDef producer fill:#f9f,stroke:#333;
classDef zk fill:#dfd,stroke:#333;
classDef idc fill:#bbf,stroke:#333;
classDef serverA fill:#bfb,stroke:#333;
class P producer;
class Q1,Q2 zk;
class IDC1,IDC2 idc;
class S1_IDC1,S2_IDC1,S1_IDC2,S2_IDC2 serverA;
在数据不一致的时间窗口内,serverA是否对外提供服务,取决于控制面服务内相应的开关。满足不同用户对于机房之间数据不一致的不同要求。
IDC内
IDC内会部署2各实例,实现高可用。在消费备份任务时,会先抢zk里的分布式锁。拿到锁的实例执行备份任务。同时两个实例之间通过nfs互挂彼此的数据目录。serverA在备份数据时,会将数据同时写入到自己的数据目录和peer的nfs挂载路径。
存在的问题
架构扩展性差,IDC内只能部署两个实例。不过nfs互挂稳定性很好,没出过什么事故,用不着改造。
但后面随着业务的发展,数仓的数据越来越多,流量也越来越大。IDC内两个实例,在高峰期时,带宽被打满,架构问题迫在眉睫。
新架构
针对 v1 架构的扩展性和双写耦合问题,v2 架构进行了如下改进。
zk里的备份任务队列,不再是两个队列(部署serverA的IDC各一个队列),而是每个serverA实例一个队列,独立消费。IDC内,可以水平扩展多个serverA实例,各自消费自己队列里的任务。
flowchart TD
P["备份任务生产者"]
subgraph Zookeeper_Queues ["Zookeeper (Per-Instance Queues)"]
direction LR
%% Queues for IDC1
Q_S1_IDC1["ZK Queue (IDC1-Srv1)"]
Q_S2_IDC1["ZK Queue (IDC1-Srv2)"]
Q_SN_IDC1["ZK Queue (IDC1-SrvN)"]
%% Queues for IDC2
Q_S1_IDC2["ZK Queue (IDC2-Srv1)"]
Q_S2_IDC2["ZK Queue (IDC2-Srv2)"]
Q_SN_IDC2["ZK Queue (IDC2-SrvN)"]
end
subgraph IDC1 ["IDC1"]
direction TB
S1_IDC1["serverA instance 1"]
S2_IDC1["serverA instance 2"]
SN_IDC1["serverA instance N"]
end
subgraph IDC2 ["IDC2"]
direction TB
S1_IDC2["serverA instance 1"]
S2_IDC2["serverA instance 2"]
SN_IDC2["serverA instance N"]
end
%% Producer to Queues
P --> Q_S1_IDC1
P --> Q_S2_IDC1
P --> Q_SN_IDC1
P --> Q_S1_IDC2
P --> Q_S2_IDC2
P --> Q_SN_IDC2
%% Queues to serverA instances
Q_S1_IDC1 --> S1_IDC1
Q_S2_IDC1 --> S2_IDC1
Q_SN_IDC1 --> SN_IDC1
Q_S1_IDC2 --> S1_IDC2
Q_S2_IDC2 --> S2_IDC2
Q_SN_IDC2 --> SN_IDC2
classDef producer fill:#f9f,stroke:#333;
classDef zk fill:#dfd,stroke:#333;
classDef idc fill:#bbf,stroke:#333;
classDef serverA fill:#bfb,stroke:#333;
class P producer;
class Q_S1_IDC1,Q_S2_IDC1,Q_SN_IDC1,Q_S1_IDC2,Q_S2_IDC2,Q_SN_IDC2 zk;
class IDC1,IDC2 idc;
class S1_IDC1,S2_IDC1,SN_IDC1,S1_IDC2,S2_IDC2,SN_IDC2 serverA;
每个serverA实例执行完成备份任务后,状态写入 DB 后,客户端可动态选择可用实例下载数据,避免了 v1 双写失败导致的服务不可用问题,且吞吐量提升可达数倍(视实例数而定)。