Skip to content

设计一个异地多活系统

问题

同城双活 vs 异地多活怎么选?数据冲突如何解决(CRDT)?全局时钟问题怎么处理?

先搞清楚:你到底要防什么级别的故障

很多团队一上来就说"我们要做异地多活",但问清楚需求后发现,他们其实只要同城双活就够了。

维度同城双活异地多活
机房距离同一城市,< 50km跨省/跨国,> 500km
专线延迟< 2ms10-100ms
一致性强一致(同步复制)最终一致(异步复制)
扛什么故障单机房故障、交换机故障、电力闪断地域级灾难:地震、大面积停电、光缆被挖
成本中等(物理机 + 专线 x2)高昂(物理机 + 专线 + 数据传输 + 架构复杂度 x3)
典型用户中型互联网公司、电商平台金融、支付、社交巨头

两者的关系是互补的:同城双活做业务连续性,异地多活做灾难恢复。大多数公司先做同城双活,如果业务对可用性要求极高(比如蚂蚁、微信、AWS),再上异地多活。

(踩坑) 2018 年我在某中型电商公司,CTO 一拍脑袋要上"三地五中心"架构。结果半年后复盘:自建机房成本暴涨 300%,运维团队从 5 人扩充到 20 人,而实际业务 SLA 只从 99.95% 提到 99.97%。这 0.02% 的提升,多花了 2500 万。在 SLA 99.95% 的业务上做异地多活,投入产出比极其难看。

异地多活的核心矛盾:数据怎么不打架

异地多活最大的坑是写冲突。用户 A 在北京机房改订单的收货地址,用户 B 在上海机房也改同一个订单,两个机房各自写入了不同的值,最后同步时谁覆盖谁?

方案一:最后写入胜利(LWW)

每个写操作带一个时间戳,同步时以时间戳最新的为准。实现简单,但有丢数据的风险。

java
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 + removeadd 全集 - remove 全集购物车商品
LWW-Register(最后写入寄存器)set时间戳大的胜出用户昵称⚠️ 有限
2P-Set(两阶段集合)add + removeadd 并集 - remove 并集黑名单

PN-Counter 实现

java
// 简化版 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 实现

java
// 简化版 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 被哈希到了同一个单元,该单元的所有存储和计算资源都承受了不成比例的压力。修复方案:对超大客户单独建微单元,不参与通用哈希路由,走定制化隔离策略。

三种方案对比

维度LWWCRDT单元化
实现复杂度⭐ 低⭐⭐⭐ 中高⭐⭐⭐⭐ 高
数据一致性最终一致,可能丢数据强最终一致,数学保证强最终一致(无冲突)
写冲突覆盖(可能丢)自动合并不存在(无交叉写)
存储开销极小翻倍(元数据)接近单机房
适用场景非关键配置、用户资料计数器、集合型数据订单、账户、交易
跨单元读直接读直接读需路由到正确单元
面试官最常问"LWW 丢数据怎么处理?""CRDT 的合并冲突细节?""单元扩容怎么做?"

全局时钟:为什么不能用系统时间

跨机房的写冲突解决,LWW 依赖时间戳。但 NTP 同步精度有限,两个机房之间的时钟差可能达到几十毫秒。如果恰好有并发写,时间戳靠后的那个写可能实际上是"先发生的"。

真实数据:Google 的论文《Spanner: TrueTime》提到,即使使用 GPS + 原子钟,TrueTime 的不确定窗口也有 1-7ms。普通机房用 NTP,时钟偏差通常在 20-250ms。用系统时间戳做 LWW 排序,在跨机房场景下几乎一定会出错。

解决方案:Hybrid Logical Clock(HLC)

java
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(纯软件)
TrueTime1-7ms 不确定窗口GPS + 原子钟✅ 强因果保证极高
逻辑时钟(Lamport)不依赖物理时间✅ 因果保证0(但无法排序无关事件)

异地多活的流量调度和故障切换

流量调度

DNS 解析 + GSLB(全局负载均衡)将用户调度到最近的单元。但要小心流量切换的平滑性

  1. 切流量前先做在线压测,确认目标单元能扛住
  2. 逐步灰度:1% → 10% → 50% → 100%
  3. 每步观察错误率、延迟、业务正确性

(踩坑 - 流量切换现场) 某次凌晨 2 点做新加坡机房到法兰克福机房的流量切换,灰度到 10% 时一切正常,到 50% 时突然告警——法兰克福机房的数据库连接池满了。排查发现:新加坡机房的应用层缓存了 token 到本地,流量切到法兰克福后,法兰克福没有一个 token 缓存,所有请求都去查 DB,把连接池打爆了。教训:切换前必须在目标单元预热缓存,否则流量来了就是雪崩。

故障切换

主单元故障时,自动切换到备用单元。切换过程中要保证:

  1. 已提交的订单不丢——MQ 积压消息在切换后继续消费
  2. 未同步的数据用增量同步 + 对账修复——定时跑对账脚本,发现不一致就走人工修复流程
  3. 切换后禁止写回故障单元——直到故障恢复且数据追平
java
// 故障切换决策逻辑(简化版)
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 类型走人工对账。关键原则:网络分区期间,保证业务可用 > 保证数据绝对一致。 这就是为什么异地多活只能做到最终一致。

总结

异地多活是高可用架构的终极形态,但也是成本最高、最复杂的。很多公司做到同城双活就够用了,没必要为了"多活"而多活。

如果真的需要,记住三条原则:

  1. 单元化是核心——按用户路由,写不跨单元,从根上避免冲突
  2. CRDT 是兜底——实在需要跨单元写,用数学保证不冲突
  3. 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)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。