#多活架构设计
2019年某互联网公司的主数据中心因为电力故障导致全面停电,服务中断长达6小时。
技术团队排查后发现:整个系统只有单机房部署,没有任何灾备方案。用户无法访问服务,损失惨重。
更可怕的是:该公司曾在2018年经历过一次类似的故障,当时承诺的"双机房改造"因为种种原因一直没有落地。
这次故障导致服务中断6小时,直接损失约5000万元,用户流失率上升15%。
【面试官手记】
多活架构是保障业务连续性的关键。我面试过的候选人里,能说清楚"双活架构"的有30%,能说清楚"异地多活"的有20%,能说清楚"流量调度"的有15%。多活架构的关键词是数据同步 + 流量切换。
#一、多活的四种模式 🔴
#1.1 四种模式详解
多活四种模式:
1. 主备模式
- 主机房对外服务
- 备机房待命
- 主挂后切换到备
- 缺点:资源利用率低,切换时间长
2. 同城双活
- 两个机房都在同城
- 同时对外服务
- 网络延迟<1ms
- 优点:切换快,资源利用率高
- 缺点:不能防城市级灾难
3. 两地三中心
- 同城两个机房
- 异地一个备份机房
- 优点:既能防机房故障,又能防城市灾难
- 缺点:成本高,切换复杂
4. 异地多活
- 多个机房分布在不同城市
- 同时对外服务
- 优点:最高的可用性
- 缺点:成本高,数据一致性难#1.2 模式对比
模式对比:
| 模式 | 成本 | 切换时间 | 防灾能力 | 复杂度 |
|------|------|----------|----------|--------|
| 主备 | 低 | 小时级 | 单机房 | 低 |
| 同城双活 | 中 | 分钟级 | 单机房 | 中 |
| 两地三中心 | 高 | 分钟级 | 城市级 | 高 |
| 异地多活 | 很高 | 秒级 | 城市级 | 很高 |#二、同城双活架构 🔴
#2.1 架构设计
同城双活架构:
┌─────────────────────────────────────┐
│ 负载均衡层 │
│ (DNS + GSLB流量调度) │
└──────────┬──────────────┬──────────────┘
│ │
┌───────────────┴───┐ ┌──────┴───────────────┐
│ 机房A │ │ 机房B │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ 网关集群 │ │ │ │ 网关集群 │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌──────┴───────┐ │ │ ┌──────┴───────┐ │
│ │ 应用集群 │ │ │ │ 应用集群 │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
│ │ │ │ │ │
│ ┌──────┴───────┐ │ │ ┌──────┴───────┐ │
│ │ 数据层 │ │ │ │ 数据层 │ │
│ │ MySQL主从 │ │ │ │ MySQL主从 │ │
│ │ Redis集群 │ │ │ │ Redis集群 │ │
│ └──────────────┘ │ │ └──────────────┘ │
└────────────────────┘ └─────────────────────┘
│ │
┌─────────┴──────────────┴─────────┐
│ 同步层(光缆直连) │
│ MySQL binlog同步 / Redis同步 │
└───────────────────────────────────┘#2.2 数据同步方案
// MySQL双主同步配置
// 机房A MySQL配置
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW
gtid-mode = ON
enforce-gtid-consistency = ON
# 双主配置
log-slave-updates = ON
auto-increment-offset = 1
auto-increment-increment = 2
# 机房间同步
replicate-do-db = business_db
replicate-ignore-db = mysql
// 机房B MySQL配置
[mysqld]
server-id = 2
auto-increment-offset = 2
auto-increment-increment = 2
# 其他配置同机房A// Redis双活同步
@Service
public class RedisMultiActiveService {
@Autowired
private RedisTemplate redisTemplateA; // 机房A
@Autowired
private RedisTemplate redisTemplateB; // 机房B
@Autowired
private RedisClusterClient clusterClientA;
/**
* 写入:双写
*/
public void set(String key, String value) {
// 写入本机房
redisTemplateA.opsForValue().set(key, value);
// 异步同步到另一个机房
CompletableFuture.runAsync(() -> {
try {
redisTemplateB.opsForValue().set(key, value);
} catch (Exception e) {
log.error("Redis同步失败,key={}", key, e);
// 记录同步失败,等待重试
syncFailedKey(key, value);
}
});
}
/**
* 读取:本机房优先
*/
public String get(String key) {
// 优先读本机房
String value = (String) redisTemplateA.opsForValue().get(key);
if (value != null) {
return value;
}
// 本机房没有,读另一个机房
value = (String) redisTemplateB.opsForValue().get(key);
if (value != null) {
// 回填本机房
redisTemplateA.opsForValue().set(key, value);
}
return value;
}
}#三、异地多活架构 🟡
#3.1 数据分类
异地多活数据分类:
1. 可合并数据(最终一致)
- 用户行为日志
- 消息记录
- 访问统计
- 特点:可以异步同步,允许短暂不一致
2. 单元闭环数据(强一致)
- 用户Session
- 分布式锁
- 库存扣减
- 特点:必须在本机房处理
3. 全局一致数据(实时同步)
- 账户余额
- 订单状态
- 支付流水
- 特点:必须实时同步#3.2 流量路由
// 流量路由服务
@Service
public class TrafficRouter {
@Autowired
private ConfigService configService;
/**
* 根据用户ID路由到机房
* 路由规则:hash(userId) % 机房数
*/
public String route机房(Long userId) {
// 从配置中心获取机房列表
List<String> regions = configService.getRegions();
// 按用户ID哈希
int index = Math.abs(userId.hashCode()) % regions.size();
return regions.get(index);
}
/**
* 根据地域路由
*/
public String routeByRegion(String region) {
Map<String, String> regionMapping = Map.of(
"华南", "广州机房",
"华东", "上海机房",
"华北", "北京机房",
"西北", "西安机房"
);
return regionMapping.getOrDefault(region, "北京机房");
}
/**
* 故障切换
*/
public void switchTo(String targetRegion) {
// 更新DNS/负载均衡配置
configService.setActiveRegion(targetRegion);
// 发送告警
alertService.sendAlert("机房切换", "切换到" + targetRegion);
log.info("流量切换到{}", targetRegion);
}
}#3.3 数据同步策略
// 异地数据同步
@Service
public class CrossRegionSyncService {
@Autowired
private KafkaTemplate kafkaTemplate;
/**
* 异步同步:消息队列
*/
public void asyncSync(String table, Object data) {
// 发送消息到跨机房Topic
kafkaTemplate.send("cross-region-sync",
table,
JSON.toJSONString(new SyncEvent(table, data, System.currentTimeMillis()))
);
}
/**
* 同步:强一致数据用DTS
*/
@Transactional
public void syncBalance(Long userId, BigDecimal change) {
// 1. 本机房更新
accountService.updateBalance(userId, change);
// 2. 发送同步消息
kafkaTemplate.send("balance-sync",
String.valueOf(userId),
JSON.toJSONString(new BalanceSyncEvent(userId, change))
);
}
/**
* 冲突处理:后写者胜出
*/
@KafkaListener(topics = "balance-sync")
public void handleBalanceSync(String message) {
BalanceSyncEvent event = JSON.parseObject(message, BalanceSyncEvent.class);
// 检查版本
AccountDO local = accountService.getByUserId(event.getUserId());
if (local.getVersion() >= event.getVersion()) {
// 本地版本更新,跳过
return;
}
// 更新本地
accountService.updateBalanceWithVersion(event);
}
}#四、故障切换 🟡
#4.1 切换流程
故障切换流程:
发现阶段:
1. 监控告警:机房故障
2. 自动探测:健康检查失败
3. 人工确认:值班人员确认
决策阶段:
4. 影响评估:多少用户受影响
5. 切换决策:是否切换
6. 切换方案:切哪个机房
执行阶段:
7. DNS切换:流量切到备用机房
8. 数据同步:确保数据一致
9. 验证服务:确认服务正常
恢复阶段:
10. 故障修复:恢复原机房
11. 灰度回切:逐步切回
12. 故障总结:复盘分析#4.2 自动切换实现
// 自动故障切换
@Service
public class FailoverService {
@Autowired
private HealthCheckService healthCheck;
@Autowired
private TrafficRouter trafficRouter;
@Autowired
private KafkaTemplate kafkaTemplate;
private final AtomicInteger failureCount = new AtomicInteger(0);
private static final int FAILURE_THRESHOLD = 3;
/**
* 健康检查
*/
@Scheduled(fixedRate = 5000)
public void healthCheck() {
String currentRegion = configService.getCurrentRegion();
boolean healthy = healthCheck.checkRegion(currentRegion);
if (!healthy) {
int count = failureCount.incrementAndGet();
if (count >= FAILURE_THRESHOLD) {
log.error("机房{}健康检查连续失败{}次,触发切换", currentRegion, count);
triggerFailover(currentRegion);
}
} else {
failureCount.set(0);
}
}
/**
* 触发故障切换
*/
private void triggerFailover(String failedRegion) {
// 1. 找到备用机房
String standbyRegion = findStandbyRegion(failedRegion);
if (standbyRegion == null) {
log.error("没有可用备用机房!");
alertService.sendCriticalAlert("无备用机房可用");
return;
}
// 2. 切换流量
trafficRouter.switchTo(standbyRegion);
// 3. 发送切换事件
kafkaTemplate.send("failover-event",
failedRegion,
JSON.toJSONString(new FailoverEvent(failedRegion, standbyRegion))
);
// 4. 记录切换日志
log.warn("故障切换完成:{} -> {}", failedRegion, standbyRegion);
// 5. 发送告警
alertService.sendAlert("机房切换", failedRegion + "切换到" + standbyRegion);
}
private String findStandbyRegion(String failedRegion) {
List<String> allRegions = configService.getAllRegions();
return allRegions.stream()
.filter(r -> !r.equals(failedRegion))
.filter(this::checkRegionHealth)
.findFirst()
.orElse(null);
}
private boolean checkRegionHealth(String region) {
try {
return healthCheck.checkRegion(region);
} catch (Exception e) {
return false;
}
}
}#五、生产避坑 🟡
#5.1 多活架构的五大坑
坑1:数据同步延迟
问题:异地同步有延迟,导致数据不一致
场景:用户在北京注册,在广州下单
解决方案:
- 按用户ID单元化,同一用户在同一机房处理
- 关键数据实时同步
- 非关键数据异步同步坑2:双写数据冲突
问题:两个机房同时写入,冲突了
场景:用户同时在两个机房下单
解决方案:
- 按用户ID路由,保证同一用户在同一机房
- 冲突时后写者胜出
- 或使用分布式事务坑3:跨机房调用
问题:业务逻辑需要跨机房调用
场景:订单服务在A机房,要查用户在B机房
解决方案:
- 数据跟着用户走,用户在哪个机房,数据就在哪个机房
- 避免跨机房调用坑4:切换后数据丢失
问题:切换后,本机房未同步的数据丢失
场景:binlog同步有延迟
解决方案:
- 切换前检查同步延迟
- 延迟超过阈值不能切换
- 或接受短暂数据丢失坑5:切换后不回切
问题:故障恢复后没有回切
场景:一直在备机房运行
解决方案:
- 故障恢复后自动/手动回切
- 回切前检查数据一致性#5.2 多活检查清单
架构规范:
- [ ] 流量按用户ID路由
- [ ] 数据单元化处理
- [ ] 同步延迟可监控
运维规范:
- [ ] 健康检查配置
- [ ] 故障切换预案
- [ ] 回切流程
- [ ] 数据一致性检查
监控规范:
- [ ] 机房健康状态
- [ ] 同步延迟
- [ ] 流量分布
- [ ] 切换次数#六、真实面试回放 🟡
面试官:多活架构怎么设计?
候选人(小张):四种模式:
一是主备。
二是同城双活,两个机房都在同城,同时对外服务,网络延迟低。
三是两地三中心,同城两个机房加异地一个备份。
四是异地多活,多个机房分布在不同城市。
面试官:数据怎么同步?
小张:三类数据:
一是可合并数据,用消息队列异步同步,允许短暂不一致。
二是单元闭环数据,必须在本机房处理,不能跨机房。
三是全局一致数据,比如余额,实时同步。
面试官:怎么保证同一用户在同一机房?
小张:按用户ID哈希。
比如hash(userId) % 2,用户ID确定后,就路由到固定的机房。
【面试官手记】
小张这场面试的亮点:
知道四种多活模式
知道数据分类处理
知道按用户ID路由
多活架构是P7工程师必备知识点,能完整回答的候选人,说明有高可用架构经验。
多活架构的核心是数据同步 + 流量切换。记住三个要点:
- 数据分类:可合并异步同步,单元闭环本机房处理
- 流量路由:按用户ID哈希,保证同用户同机房
- 故障切换:健康检查→流量切换→数据一致性验证
多活架构是业务连续性的最后一道防线,必须提前规划。