设计一个异地多活系统
问题
同城双活 vs 异地多活怎么选?数据冲突如何解决(CRDT)?全局时钟问题怎么处理?
先搞清楚:你到底要防什么级别的故障
很多团队一上来就说"我们要做异地多活",但问清楚需求后发现,他们其实只要同城双活就够了。
| 维度 | 同城双活 | 异地多活 |
|---|---|---|
| 机房距离 | 同一城市,< 50km | 跨省/跨国,> 500km |
| 专线延迟 | < 2ms | 10-100ms |
| 一致性 | 强一致(同步复制) | 最终一致(异步复制) |
| 扛什么故障 | 单机房故障、交换机故障、电力闪断 | 地域级灾难:地震、大面积停电、光缆被挖 |
| 成本 | 中等(物理机 + 专线 x2) | 高昂(物理机 + 专线 + 数据传输 + 架构复杂度 x3) |
| 典型用户 | 中型互联网公司、电商平台 | 金融、支付、社交巨头 |
两者的关系是互补的:同城双活做业务连续性,异地多活做灾难恢复。大多数公司先做同城双活,如果业务对可用性要求极高(比如蚂蚁、微信、AWS),再上异地多活。
(踩坑) 2018 年我在某中型电商公司,CTO 一拍脑袋要上"三地五中心"架构。结果半年后复盘:自建机房成本暴涨 300%,运维团队从 5 人扩充到 20 人,而实际业务 SLA 只从 99.95% 提到 99.97%。这 0.02% 的提升,多花了 2500 万。在 SLA 99.95% 的业务上做异地多活,投入产出比极其难看。
异地多活的核心矛盾:数据怎么不打架
异地多活最大的坑是写冲突。用户 A 在北京机房改订单的收货地址,用户 B 在上海机房也改同一个订单,两个机房各自写入了不同的值,最后同步时谁覆盖谁?
方案一:最后写入胜利(LWW)
每个写操作带一个时间戳,同步时以时间戳最新的为准。实现简单,但有丢数据的风险。
public class LwwEntry<T> {
private final T value;
private final long timestamp;
// 合并时,时间戳大的胜出
public static <T> LwwEntry<T> merge(LwwEntry<T> local, LwwEntry<T> remote) {
if (local.timestamp >= remote.timestamp) {
return local;
}
return remote;
}
}适合场景:用户的非关键信息,比如昵称、头像、个人简介——覆盖错了也没人投诉。
不适合场景:订单地址、账户余额、库存数量——LWW 就是灾难。
(踩坑) 某社交平台用 LWW 同步用户头像,结果出现"用户 A 换了个悲伤青蛙头像,被远端机房迟到的旧头像覆盖"的 bug。排查了 3 天才发现是 LWW 时间戳粒度是秒级,两端刚好在 1 秒内发生了并发写。修复方案:时间戳改毫秒级 + 每个写操作增加本地递增序列号。
方案二:CRDT(无冲突复制数据类型)
用数学保证并发写不会冲突,不需要协调。几个常用的 CRDT 类型:
| 类型 | 操作 | 合并规则 | 典型场景 | 适用 |
|---|---|---|---|---|
| G-Counter(增长计数器) | 只增 | 各节点取 max | 页面浏览量 | ✅ |
| PN-Counter(正负计数器) | incr + decr | 分别加总 | 点赞数、库存扣减 | ✅ |
| G-Set(只增集合) | 只能 add | 取并集 | 已读消息 ID | ✅ |
| OR-Set(添加/删除集合) | add + remove | add 全集 - remove 全集 | 购物车商品 | ✅ |
| LWW-Register(最后写入寄存器) | set | 时间戳大的胜出 | 用户昵称 | ⚠️ 有限 |
| 2P-Set(两阶段集合) | add + remove | add 并集 - remove 并集 | 黑名单 | ✅ |
PN-Counter 实现:
// 简化版 PN-Counter
public class PNCounter {
private final GCounter positive = new GCounter();
private final GCounter negative = new GCounter();
public void increment(String nodeId, long delta) {
positive.add(nodeId, delta);
}
public void decrement(String nodeId, long delta) {
negative.add(nodeId, delta);
}
public long value() {
return positive.value() - negative.value();
}
// 合并两个计数器
public void merge(PNCounter other) {
positive.merge(other.positive);
negative.merge(other.negative);
}
}OR-Set 实现:
// 简化版 OR-Set:每个元素带唯一 tag,支持 add/remove 不乱序
public class OrSet<E> {
private final Map<E, Set<String>> addSet = new HashMap<>();
private final Map<E, Set<String>> removeSet = new HashMap<>();
private final AtomicLong tagCounter = new AtomicLong(0);
public void add(E element) {
String tag = UUID.randomUUID() + "-" + tagCounter.incrementAndGet();
addSet.computeIfAbsent(element, k -> new HashSet<>()).add(tag);
}
public void remove(E element) {
Set<String> tags = addSet.get(element);
if (tags != null) {
removeSet.computeIfAbsent(element, k -> new HashSet<>()).addAll(tags);
}
}
public Set<E> value() {
Set<E> result = new HashSet<>();
for (E element : addSet.keySet()) {
Set<String> added = addSet.getOrDefault(element, Collections.emptySet());
Set<String> removed = removeSet.getOrDefault(element, Collections.emptySet());
if (!added.equals(removed)) {
result.add(element);
}
}
return result;
}
public void merge(OrSet<E> other) {
other.addSet.forEach((k, v) -> this.addSet.merge(k, v, (a, b) -> { a.addAll(b); return a; }));
other.removeSet.forEach((k, v) -> this.removeSet.merge(k, v, (a, b) -> { a.addAll(b); return a; }));
}
}CRDT 的代价是存储翻倍——每个元素都要带元数据(ID、时间戳),而且不是所有数据结构都能用 CRDT 表达。比如"订单状态流转:待支付 → 已支付 → 已发货 → 已签收 → 已完成",这个有向无环图就不能用 CRDT 自然表达——因为状态机要求"不能从已发货回到待支付",但 CRDT 合并时不会区分这种语义。
方案三:单元化架构
这是工业界最主流的方案。按用户维度固定路由——同一个用户永远只在一个单元写。这样物理上就不存在跨单元写冲突了。
用户 123456 → 哈希(userId) → Zone-A(北京)
用户 789012 → 哈希(userId) → Zone-B(上海)单元化架构的流量模型:
DNS/GSLB
│
├── 用户 123456 ──→ API Gateway ──→ Zone-A(北京)
│ │
│ ├── 业务服务(无状态)
│ ├── Redis(本地)
│ └── DB(本地)
│
└── 用户 789012 ──→ API Gateway ──→ Zone-B(上海)
│
├── 业务服务(无状态)
├── Redis(本地)
└── DB(本地)单元间通过 MQ 异步同步全局数据(如全局配置、库存总量),用户数据不跨单元同步。
(踩坑) 单元化架构听上去很美,但单元间数据倾斜是隐形杀手。某支付平台上线单元化后,发现一个单元负载 70%,另一个只有 30%。排查发现是因为某个大客户(账务往来密集)的用户 ID 被哈希到了同一个单元,该单元的所有存储和计算资源都承受了不成比例的压力。修复方案:对超大客户单独建微单元,不参与通用哈希路由,走定制化隔离策略。
三种方案对比
| 维度 | LWW | CRDT | 单元化 |
|---|---|---|---|
| 实现复杂度 | ⭐ 低 | ⭐⭐⭐ 中高 | ⭐⭐⭐⭐ 高 |
| 数据一致性 | 最终一致,可能丢数据 | 强最终一致,数学保证 | 强最终一致(无冲突) |
| 写冲突 | 覆盖(可能丢) | 自动合并 | 不存在(无交叉写) |
| 存储开销 | 极小 | 翻倍(元数据) | 接近单机房 |
| 适用场景 | 非关键配置、用户资料 | 计数器、集合型数据 | 订单、账户、交易 |
| 跨单元读 | 直接读 | 直接读 | 需路由到正确单元 |
| 面试官最常问 | "LWW 丢数据怎么处理?" | "CRDT 的合并冲突细节?" | "单元扩容怎么做?" |
全局时钟:为什么不能用系统时间
跨机房的写冲突解决,LWW 依赖时间戳。但 NTP 同步精度有限,两个机房之间的时钟差可能达到几十毫秒。如果恰好有并发写,时间戳靠后的那个写可能实际上是"先发生的"。
真实数据:Google 的论文《Spanner: TrueTime》提到,即使使用 GPS + 原子钟,TrueTime 的不确定窗口也有 1-7ms。普通机房用 NTP,时钟偏差通常在 20-250ms。用系统时间戳做 LWW 排序,在跨机房场景下几乎一定会出错。
解决方案:Hybrid Logical Clock(HLC)。
public class HybridLogicalClock {
private long physicalTime; // 物理时钟(毫秒级)
private int logicalCounter; // 逻辑递增计数器
// 本地事件,递增逻辑时钟
public long tick() {
long now = System.currentTimeMillis();
if (now > physicalTime) {
physicalTime = now;
logicalCounter = 0;
} else {
logicalCounter++;
}
return (physicalTime << 16) | logicalCounter;
}
// 收到远端消息,取两边 clock 的 max
public long recv(long remoteClock) {
long remotePhysical = remoteClock >>> 16;
long remoteLogical = remoteClock & 0xFFFF;
long now = System.currentTimeMillis();
long maxPhysical = Math.max(Math.max(now, physicalTime), remotePhysical);
if (maxPhysical == physicalTime && maxPhysical == remotePhysical) {
logicalCounter = Math.max(logicalCounter, remoteLogical) + 1;
} else if (maxPhysical == physicalTime) {
logicalCounter++;
} else if (maxPhysical == remotePhysical) {
logicalCounter = remoteLogical + 1;
} else {
logicalCounter = 0;
}
physicalTime = maxPhysical;
return (physicalTime << 16) | logicalCounter;
}
}HLC 保证:如果事件 A 发生在事件 B 之前,则 HLC(A) < HLC(B)。这样即使物理时钟有偏差,因果关系也不会颠倒。
HLC vs TrueTime vs 普通 NTP 时间戳:
| 方案 | 精度 | 依赖硬件 | 因果保证 | 成本 |
|---|---|---|---|---|
| 普通 NTP 时间戳 | 20-250ms 偏差 | 无 | ❌ 时钟回拨即错 | 0 |
| HLC | 无物理限制 | 无 | ✅ 强因果保证 | 0(纯软件) |
| TrueTime | 1-7ms 不确定窗口 | GPS + 原子钟 | ✅ 强因果保证 | 极高 |
| 逻辑时钟(Lamport) | 不依赖物理时间 | 无 | ✅ 因果保证 | 0(但无法排序无关事件) |
异地多活的流量调度和故障切换
流量调度
DNS 解析 + GSLB(全局负载均衡)将用户调度到最近的单元。但要小心流量切换的平滑性:
- 切流量前先做在线压测,确认目标单元能扛住
- 逐步灰度:1% → 10% → 50% → 100%
- 每步观察错误率、延迟、业务正确性
(踩坑 - 流量切换现场) 某次凌晨 2 点做新加坡机房到法兰克福机房的流量切换,灰度到 10% 时一切正常,到 50% 时突然告警——法兰克福机房的数据库连接池满了。排查发现:新加坡机房的应用层缓存了 token 到本地,流量切到法兰克福后,法兰克福没有一个 token 缓存,所有请求都去查 DB,把连接池打爆了。教训:切换前必须在目标单元预热缓存,否则流量来了就是雪崩。
故障切换
主单元故障时,自动切换到备用单元。切换过程中要保证:
- 已提交的订单不丢——MQ 积压消息在切换后继续消费
- 未同步的数据用增量同步 + 对账修复——定时跑对账脚本,发现不一致就走人工修复流程
- 切换后禁止写回故障单元——直到故障恢复且数据追平
// 故障切换决策逻辑(简化版)
public class FailoverDecider {
public FailoverDecision decide(String unitId, HealthCheckResult health) {
if (health.isHealthy()) {
return FailoverDecision.NOOP; // 健康,不做切换
}
// 连续 3 次健康检查失败才触发切换,防止抖动
if (health.getConsecutiveFailures() >= 3) {
// 检查备用单元是否健康
HealthCheckResult standbyHealth = healthCheckClient.check(standbyUnitId);
if (!standbyHealth.isHealthy()) {
return FailoverDecision.ABORT; // 备用也不健康,放弃切换
}
// 检查数据同步延迟是否在可接受范围内
long syncLag = syncMonitor.getLag(unitId, standbyUnitId);
if (syncLag > MAX_ACCEPTABLE_LAG_MS) {
return FailoverDecision.WAIT_FOR_SYNC; // 数据还没追平,等
}
return FailoverDecision.SWITCH_TO_STANDBY;
}
return FailoverDecision.NOOP;
}
}切换时序图:
主单元宕机
│
├── 0ms: 健康检查超时,标记为可疑
├── 300ms: 第 2 次检查超时,标记为疑似故障
├── 600ms: 第 3 次检查超时,触发切换决策
│ │
│ ├── 检查备用单元健康 → 健康
│ ├── 检查数据同步延迟 → 500ms(可接受 < 5s)
│ └── 决策:切换
│
├── 650ms: 更新 DNS 记录,TTL 设为 60s
├── 700ms: 通知各网关,关闭故障单元入口
├── 800ms: 备用单元开始接管流量
└── 60s 后: DNS 缓存过期,全部流量到达备用单元哪些业务适合异地多活
不是所有业务都适合。正确做法是单元化 + 全局:
- 无状态业务(用户登录、商品浏览、内容搜索)——任意单元都可处理,天然适合多活
- 有状态业务(订单、账户、购物车)——按用户 ID 哈希路由到固定单元,避免跨单元写
- 全局数据(库存总量、全局配置、热门商品列表)——单独维护,跨单元 MQ 同步
面试官追问示例:
面试官: "单元化架构下,如果某个用户的数据量特别大(比如某个大客户),导致一个单元负载过高怎么办?" 回答要点: 大客户隔离策略——把大客户固定到独立微单元,不参与通用哈希;或者对该客户做"子单元拆分",例如用
userId % 单元数改为(userId / 1000) % 单元数,粒度更细,分布更均匀。
面试官: "假如两个单元物理断连了,但用户还在各自单元写数据,恢复后怎么处理?" 回答要点: 断连期间,每个单元独立记录变更日志(带 HLC 时间戳)。恢复后,按时间戳顺序重放变更日志,CRDT 类型自动合并,非 CRDT 类型走人工对账。关键原则:网络分区期间,保证业务可用 > 保证数据绝对一致。 这就是为什么异地多活只能做到最终一致。
总结
异地多活是高可用架构的终极形态,但也是成本最高、最复杂的。很多公司做到同城双活就够用了,没必要为了"多活"而多活。
如果真的需要,记住三条原则:
- 单元化是核心——按用户路由,写不跨单元,从根上避免冲突
- CRDT 是兜底——实在需要跨单元写,用数学保证不冲突
- HLC 保因果——别用物理时钟做排序,用 Hybrid Logical Clock
蚂蚁的 SOFAStack 单元化方案、微信的异地多活架构、AWS 的 Region + AZ 模型——这些都是验证过的工业级方案,踩过的坑比文档里的字还多。学它们的思路,但别照搬,你的业务规模可能完全不需要同样的复杂度。
参考: 蚂蚁金服 SOFAStack 单元化架构、微信异地多活实践、AWS 多 Region 架构、CRDT 论文(Bien 2016)、Hybrid Logical Clock(Kulkarni 2014)、Spanner: TrueTime and External Consistency(Google 2012)