亿级流量系统异地多活:从数据同步到流量切换的架构设计全景
亿级流量系统异地多活:从数据同步到流量切换的架构设计全景
很多人都听过「两地三中心」,但到了真正设计异地多活的时候,才发现最难的不是「数据怎么同步」,而是「冲突怎么解决」和「用户为什么被切到了错误的地域」。
一、异地多活的架构模式选择
异地多活不是一种架构,而是一个架构族。根据业务对数据一致性的容忍度,有三种主流模式:
flowchart TB subgraph Mode1["模式一:同城双活(RPO ≈ 0,RTO < 30s)"] direction TB AZ1["可用区 A\n(机房间延迟 < 2ms)"] AZ2["可用区 B\n(机房间延迟 < 2ms)"] DB1["MySQL 主库 (AZ-A)"] DB2["MySQL 从库 (AZ-B)\n半同步复制"] AZ1 -->|读写| DB1 AZ2 -->|写| DB1 AZ2 -->|读| DB2 end subgraph Mode2["模式二:两地三中心(RPO < 1s,RTO < 5min)"] direction TB Main["主数据中心 (北京)"] DR["同城灾备 (北京-备用)"] Remote["异地灾备 (上海)"] Main -->|"同步复制\n(同城)"| DR Main -->|"异步复制\n(跨地域)"| Remote end subgraph Mode3["模式三:单元化(RPO → 0,全局多活)"] direction TB Router["流量路由层\n(按 userId hash)"] Unit1["单元 A (北京)\n用户 0~4999"] Unit2["单元 B (上海)\n用户 5000~9999"] Unit3["单元 C (广州)\n用户 10000~14999"] Router --> Unit1 Router --> Unit2 Router --> Unit3 end style Mode1 fill:#cfc,stroke:#333 style Mode2 fill:#fc6,stroke:#333 style Mode3 fill:#f96,stroke:#333同城双活是最容易落地的方案,适用于预算有限但又不想单点故障的团队。核心约束是「写操作只走主库」,读操作可以分摊到从库。这种模式本质上是「读写分离 + 机房冗余」,算不上真正的「多活」,但能解决 80% 的可用性问题。
两地三中心是金融行业的标配——但代价高昂。同城灾备使用同步复制(强一致性,RPO=0),异地灾备使用异步复制。这个模式的致命弱点是异地站点在正常情况下是空转的,不承载流量,ROI 极低。
单元化才是互联网公司普遍采用的「真·多活」方案。核心思想是:按用户维度将数据和流量封闭在一个单元内,单元之间无依赖。一个用户的完整请求链路(从接入层到数据库)都在同一个单元内完成,跨单元的操作通过异步消息或全局路由表完成。
二、数据同步:MySQL Binlog 与异构数据同步链
单元化架构中,虽然大部分流量在单元内闭环,但全局数据(如商品信息、配置数据)仍需在所有单元间保持一致。以下是基于 MySQL Binlog + Canal + Kafka 的同步方案:
sequenceDiagram participant Source as 源单元 MySQL participant Canal as Canal Server participant Kafka as Kafka (多 Partition) participant SyncWorker as 同步 Worker (目标单元) participant Target as 目标单元 MySQL Source->>Canal: 1. dump binlog events Note over Canal: 伪装成 MySQL Slave Canal->>Kafka: 2. 发布到对应 Topic (按表分区) Note over Kafka: partition key = table_name Kafka->>SyncWorker: 3. 消费 binlog 事件 SyncWorker->>SyncWorker: 4. 冲突检测 & 转换 alt 无冲突 SyncWorker->>Target: 5. 写入目标 MySQL Target-->>SyncWorker: OK SyncWorker->>Kafka: 6. 提交 Offset else 检测到冲突 SyncWorker->>SyncWorker: 7. 写入冲突日志表 Note over SyncWorker: 触发人工处理或自动仲裁 end核心代码:冲突检测与自动仲裁逻辑。当两个单元同时修改了同一条记录时,需要有一个确定的冲突解决策略。
package com.example.multiregion.sync; import java.time.Instant; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 跨单元数据同步的冲突检测与仲裁器。 * 策略:Last-Writer-Wins (LWW),基于全局时间戳 + 向量时钟。 */ public class ConflictResolver { // 冲突记录缓存 —— 用于去重和聚合 private final Map<String, ConflictRecord> recentConflicts = new ConcurrentHashMap<>(); /** * 尝试将源单元的数据变更应用到目标单元。 * * @param table 表名 * @param rowId 行主键(格式:"unit_id:local_id" 保证全局唯一) * @param columns 变更的列及其新值 * @param sourceTimestamp 源端写入时间(从 binlog 时间戳获取) * @param sourceUnit 来源单元标识 * @return true 表示写入成功,false 表示被冲突仲裁拒绝 */ public boolean tryApply( String table, String rowId, Map<String, Object> columns, Instant sourceTimestamp, String sourceUnit) { // 查询目标单元当前版本 RowVersion current = queryCurrentVersion(table, rowId); if (current == null) { // 目标单元不存在该行,直接插入(无冲突) insertRow(table, rowId, columns, sourceTimestamp, sourceUnit); return true; } // 冲突检测:比较时间戳 if (sourceTimestamp.isAfter(current.timestamp())) { // LWW 规则:源数据更新,覆盖目标 updateRow(table, rowId, columns, sourceTimestamp, sourceUnit); return true; } else if (sourceTimestamp.equals(current.timestamp())) { // 时间戳相同:使用向量时钟或单元优先级仲裁 // 策略:单元 ID 字典序大的优先(确定性仲裁,所有单元结果一致) if (sourceUnit.compareTo(current.sourceUnit()) > 0) { updateRow(table, rowId, columns, sourceTimestamp, sourceUnit); return true; } else { // 目标版本优先,拒绝本次同步 recordConflict(table, rowId, sourceUnit, current.sourceUnit(), "时间戳相同,按单元优先级仲裁:目标优先"); return false; } } else { // 源数据更旧,拒绝同步 recordConflict(table, rowId, sourceUnit, current.sourceUnit(), "源时间戳 " + sourceTimestamp + " < 目标时间戳 " + current.timestamp()); return false; } } private void recordConflict( String table, String rowId, String sourceUnit, String targetUnit, String reason) { ConflictRecord record = new ConflictRecord( table, rowId, sourceUnit, targetUnit, reason, Instant.now() ); recentConflicts.put(rowId, record); // 写入冲突日志表供后续分析 // conflictLogDao.insert(record); // 冲突率超过阈值(如 0.1%)时触发告警 // if (conflictRate > THRESHOLD) alertService.send(...) } // --- 数据访问方法(伪代码实现)--- private RowVersion queryCurrentVersion(String table, String rowId) { // SELECT timestamp, source_unit FROM {table} WHERE global_id = {rowId} return null; } private void insertRow(String table, String rowId, Map<String, Object> columns, Instant timestamp, String sourceUnit) { // INSERT INTO {table} (global_id, ..., _sync_ts, _sync_unit) VALUES (...) } private void updateRow(String table, String rowId, Map<String, Object> columns, Instant timestamp, String sourceUnit) { // UPDATE {table} SET ..., _sync_ts = ?, _sync_unit = ? WHERE global_id = ? } // --- 内部数据结构 --- private record RowVersion(Instant timestamp, String sourceUnit) {} private record ConflictRecord(String table, String rowId, String sourceUnit, String targetUnit, String reason, Instant occurredAt) {} }三、流量切换:DNS/GSLB 的实战要点
流量切换是异地多活的「对用户可见」部分。做不好,用户会被路由到错误的地域,延迟从 10ms 飙升到 200ms。
三层路由体系:
用户请求 │ ▼ DNS 智能解析(第一层:就近接入) │ 根据用户 LocalDNS IP 返回最近机房的 VIP │ 问题:LocalDNS 可能离用户很远(如移动用户用电信 DNS) │ ▼ GSLB(第二层:健康检查 + 权重路由) │ 实时检测各机房健康状态 │ 机房故障时自动摘除 VIP │ ▼ 单元接入层(第三层:用户维度路由) │ 解析 Cookie/Token 中的 unit_id │ 强制路由到用户所属单元 │ unit_id 不匹配时:302 重定向到正确单元DNS 切换的经典陷阱:DNS 的 TTL(Time To Live)决定了故障切换的最长延迟。如果你的 A 记录 TTL 设为 600 秒,意味着故障发生后,最长需要 10 分钟才能全部切换到备用机房。推荐将 TTL 设在60~120 秒之间,在 DNS 查询压力和切换速度之间取得平衡。
更激进的方案是客户端侧的服务发现:App 启动时从服务端获取一份「机房地址列表 + 优先级」,网络请求失败时立即切换到备用地址,绕开 DNS 缓存的延迟。
四、数据冲突的三种解决策略对比
| 策略 | 原理 | 适用场景 | 风险 |
|---|---|---|---|
| Last-Writer-Wins | 时间戳最大的版本覆盖其他 | 用户最后操作即最终状态(如昵称修改) | 时钟偏移导致旧数据覆盖新数据 |
| CRDT 无冲突数据类型 | 数学上可证明的最终一致性(如 Counter、LWW-Register) | 点赞数、计数器等可交换操作 | 仅适用于特定数据结构 |
| 应用层合并 | 业务逻辑感知的冲突合并(如购物车合并) | 复杂业务对象 | 需要业务方投入开发成本 |
绝大多数场景下,LWW + NTP 时间同步(误差 < 10ms)已经足够。但如果你在场景中发现「丢失更新」——即用户的写入在某些单元中消失了——那说明 LWW 不适合你的场景,需要考虑 CRDT 或应用层合并。
五、总结
异地多活从概念到落地,中间隔着五个工程问题:
- 架构选型服从业务容忍度:同城双活(简单可靠)→ 两地三中心(金融级)→ 单元化(互联网级),每一级都有对应的成本和复杂度。
- 数据同步的瓶颈不在传输在冲突:Binlog → Kafka 的同步链路上,性能瓶颈从来不是 Canal 或 Kafka 的吞吐,而是冲突检测和仲裁的延迟。
- 流量切换的核心在 DNS TTL:再好的 GSLB 方案,如果 DNS TTL 设了 600 秒,故障切换就是一场灾难。
- LWW 足够应付 90% 的场景:剩下的 10% 才需要 CRDT 或应用层合并。
- 单元化不是银弹:如果业务存在大量跨单元关联查询(如社交关系的二度好友),单元化带来的数据拆分反而会成为瓶颈。
异地多活的设计,第一步永远是回答:「我的业务能容忍多长的 RTO?能接受多大的 RPO?」这两个数字决定了你该选择哪种架构模式,也决定了你的预算。
