主题
异地多活架构:单元化、数据同步与「三地五中心」怎么落地
本文是分布式系统学习系列的 G2 容灾篇。前置:32. 分布式设计模式手册、33. 工程案例复盘。 学完可以配合面试题食用:G1 故障检测与雪崩隔离
异地多活不是什么高深概念,是容灾的自然演进
先理清一条线:从单机房到异地多活,解决的问题是逐步升级的。
- 单机房单机 —— 挂了就挂了,没有容灾
- 同城双活 —— 同一个城市两个机房,距离几十公里,光纤专线直连,延迟 1-2ms。数据库同步通常用主从复制或 MGR 跨机房部署。问题:一个城市地震/停电/光缆全断,两个机房一起完蛋
- 两地三中心 —— 同城双活 + 异地一个冷备。冷备只接受数据同步,不接流量。成本高(三套机房),且异地冷备的 RTO 很长(小时级,要拉起冷备机、追数据、切流量)
- 异地双活 —— 两个城市都接流量,不是"冷备",是"热备"。难点:数据怎么跨城同步且不冲突
- 异地多活 —— 三个及以上城市同时服务,典型如"三地五中心"。解决了单个城市全挂时其他城市还能扛量
每个阶段不是凭空出现的,是上一阶段解决不了某个问题才升级的。比如"两地三中心"的冷备太慢,才催生了异地双活。
异地多活的核心矛盾:数据一致性与可用性
异地多活最难的不是"怎么让流量进来",而是数据怎么跨城市同步而不冲突。
写冲突是最大敌人
假设你在上海机房下了一个订单,用户在广州操作同一笔订单。如果两个机房都允许写,那么订单状态可能在上海机房里是"已支付",在广州机房里是"已取消"。谁是对的?
解决方案只有两条路:
- 不让冲突发生 —— 单元化架构,每个用户的数据只在一个单元(机房)里写,别的地方只读
- 允许冲突并仲裁 —— 写的时候不阻塞,但同步后通过一定规则(如时间戳、版本号、单元优先级)决定谁对
大部分生产系统走第一条,因为第二条实现太复杂,而且业务上很难接受两个订单都"成功"之后又撤销一个。
跨机房数据同步的三种方式
DB 原生复制
MySQL 主从复制、MGR 跨机房部署。延迟低(同城 < 2ms,异地 < 50ms),但写冲突无解——主从架构下两地都写会冲突,除非你做成"上海写、广州读"的伪双活。而且异地跨机房延迟一旦超过 100ms,主从复制就容易出问题(半同步超时、复制延迟膨胀)。
CDC / Binlog 订阅
用 Canal / Otter / DRC 等工具,把 MySQL binlog 解析后跨机房发送到目标端执行。好处是业务代码不用改,坏处是延迟不可控——binlog 消费有积压、跨城网络有抖动。典型场景:异步数据同步,不做强一致。
业务层双写
业务代码里显式写两个库。可控性最强,但改造成本最大,且原子性保证困难(双写一个成功一个失败怎么办?通常用事务消息或本地消息表兜底)。
下面是一个简化版的异地多活数据同步架构图:
mermaid
flowchart LR
subgraph SH["上海机房"]
A1[App 单元 A] --> DB1[(MySQL A)]
DB1 -->|Binlog| Canal1["Canal 订阅"]
Canal1 --> MQ1["MQ 跨城"]
end
subgraph GZ["广州机房"]
MQ2["MQ 接收"] --> Canal2["Canal 回放"]
Canal2 --> DB2[(MySQL B)]
A2[App 单元 B] --> DB2
end
MQ1 -.->|跨城专线| MQ2
DB2 -.->|Binlog| Canal2 -.->|反向同步| MQ1 -.->|给上海| DB1注意:双向同步必须做去环——防止 A 机房产生的 binlog 同步到 B 机房后又回写给 A 机房。Otter 的做法是在每行数据上加一个 sync_flag 标记。
单元化:多活的前提
"单元"(Cell / Unit)是异地多活的核心概念。一个单元是一组自包含的服务集群,包括应用层、缓存层、数据库层。用户请求根据路由规则(比如用户 ID 哈希后取模)被分配到某个固定单元,该单元处理该用户的所有读写请求。
为什么要单元化
如果没有单元化,所有流量同时打到所有机房,每个机房都要有一份完整数据,写冲突就不可避免。单元化让每个用户的数据只在一个单元里写入,其他单元只读或同步副本。
单元化路由
java
// 用户路由:按用户 id 取模定向到单元
public class UserRouter {
// 单元映射表:单元编号 -> 机房
private static final Map<Integer, String> CELL_MAP = Map.of(
0, "shanghai-a",
1, "shanghai-b",
2, "guangzhou-a",
3, "beijing-a"
);
// 路由规则:用户 id 后四位取模
public static String route(long userId) {
int cellIndex = (int)(userId % CELL_MAP.size());
return CELL_MAP.get(cellIndex);
}
// 注意:路由规则一旦上线就不能轻易改
// 改了就变成"同一个人去了不同单元",数据分片错乱
// 扩容时用虚拟节点 + 一致性哈希 或 双写后灰度切流
}路由规则必须稳定——上线后不能随意改,否则用户数据会跑到不同单元,从"写冲突"变成"找不到数据"。扩容时一般采用虚拟节点 + 一致性哈希,或者双写迁移的方式。
单元内闭环
一个单元里的服务之间调用走本地,不跨单元。比如订单服务调用库存服务,如果都在同一个单元内,直接 gRPC 调用;跨单元就得走 MQ 或 RPC 但降级为异步。
单元化最难受的地方是跨单元的查询——比如用户 A 在上海单元,用户 B 在广州单元,管理员想查他俩的订单合并报表。这需要一个全局的聚合层,定期从各单元拉数据,或者通过数据同步汇总到中心库。
活的程度:流量怎么切、切多少、数据怎么追平
"活"不是二态——不是"活"或"死",而是有多少流量能正常处理、数据能追到多近。
切流策略
- 全量切:一个机房挂了,DNS/GSLB 把 100% 流量切到另一个机房。简单粗暴,但另一个机房必须能扛两倍流量,且跨城延迟会放大
- 灰度切:按用户 id 尾号,逐步切流量。比如先切 5% 到异地机房,观察 15 分钟,如果正常再切 10%、20%……直到 100%。这种做法的好处是发现异常可以回滚,但持续时间长,需要有人值守
切流时的数据瓶颈
流量切到异地机房了,但数据可能还没同步完。比如上海机房挂了,广州机房接管流量,但广州还有 30 秒的 binlog 延迟没追完。这时候用户查订单,看不到最后一笔支付记录——这就是 RPO(Recovery Point Objective)不为零。
RPO 和 RTO 是异地多活的两个核心指标:
- RTO(Recovery Time Objective):从故障发生到恢复服务要多久。异地多活期望做到秒级到分钟级
- RPO(Recovery Point Objective):最多丢失多少数据。异地多活通常做不到零丢失,因为异步复制有延迟,目标一般是秒级
防脑裂:两个机房同时认为对方挂了
网络分区时,两个机房都以为对方死了,都开始接管流量,数据就分裂了——这就是脑裂。
常见的防脑裂手段:
- 仲裁节点:引入第三个机房或云上的仲裁节点,判断哪个机房存活
- 租约机制:租约过期后自动释放写权限,避免同时写
- 单元归属丢弃:两个机房存了冲突数据,按单元归属丢弃非本单元的数据(比如 user_id 属于上海单元,广州单元写入的数据就丢弃)
真实案例:某金融公司的三地五中心
(脱敏简化)某金融公司要求 RTO < 60s,RPO < 5s,部署模式如下:
- 三个城市:上海、杭州、北京,每个城市两个可用区(AZ),共 5 个可用区
- 交易类数据:单元化,按用户 id 路由到上海/杭州,北京只做冷备
- 基础数据(产品信息、费率表)用 CDC 双向同步,走 DRC 平台,带去环标记
- 每季度做一次"故障演练":随机停掉一个可用区的网络,观察系统是否能自动切流
演练结果:第一次演练时,切流后 20% 的查询超时。原因是跨城查询走了同步 RPC,异地延迟 50ms 叠加业务层超时 3s 导致上游线程池打满。修正是把所有跨单元查询改为异步或降级为本地缓存查询(允许最终一致)。
总结
异地多活不是"买几台机器放不同城市"就完事,它是从数据一致性、流量调度、故障切换三个维度一起决策的架构选择。
- 同城双活做读写分离,异地双活必须做单元化
- 数据同步选 CDC 还是业务层双写,取决于对延迟的容忍度
- 流量切多少、切多快,取决于异地机房的容量和数据延迟
- 防脑裂靠仲裁或租约,不是靠"多写几个 if"
面试时问到异地多活,别只讲概念。能说出"单元化解决了写冲突"、"CDC 双向同步需要去环"、"RPO 和 RTO 不是零"这类工程细节,才说明你真的做过。
参考
- Google 的全球数据中心架构(Spanner + F1)
- 阿里十年架构演进中的"三地五中心"实践
- 美团分布式 ID 与单元化改造
- DynamoDB Global Tables 设计
- Otter / Canal 的双向同步去环机制