跨机房多活
2019 年 7 月,某云厂商的北京可用区 A 发生了电力故障,持续约 4 小时。故障期间,该可用区内运行的所有服务全部中断,大量使用该可用区的互联网公司受到严重影响。其中一家公司的 CTO 在复盘会上说:「我们原本以为做了备份就够了,结果发现备份只是给自己看的——真正切换的时候才发现,DNS 解析缓存没有清理,数据库没有切换脚本,联系人都在睡觉,整个切换过程用了 3 个小时。」
这是一个典型的「以为做了容灾,但没做演练」的案例。
跨机房多活是高可用架构的终极形态。它的目标是:无论哪个机房发生故障,用户的请求都能在最短时间内被正常处理,用户甚至感知不到故障的发生。 这个目标背后,是巨大的技术投入和运维复杂度。
本篇文章从真实的机房故障场景出发,讲解多活架构的设计思路和落地细节。
从一个真实的故障场景开始
故障背景
某中型互联网公司(DAU 约 300 万)的系统部署在两个机房:
- 主机房 A(北京可用区 A):运行 80% 的服务
- 备机房 B(北京可用区 B):运行 20% 的服务(平时不接流量)
故障发生
凌晨 3:17,机房 A 的主交换机出现硬件故障,导致约 60% 的服务器网络中断。剩余 40% 的服务器虽然网络正常,但由于共享存储(NAS)不可用,部分服务仍然无法正常工作。
真实的切换过程
3:17 故障发生。监控系统报警,值班 SRE 被叫醒。
3:20 确认为机房级故障,启动应急预案。
3:25 手动执行 DNS 切换:将域名解析从机房 A 的 SLB 切换到机房 B。
3:35 大量请求涌入机房 B,机房 B 容量不足,开始出现超时。
3:42 紧急扩容机房 B 的服务实例(从 20% 扩到 100%),但数据库连接池配置
不足以支撑双倍连接,数据库开始告警。
3:50 修改数据库连接池配置,重启数据库服务。
3:58 数据库稳定,但部分订单数据因主机房网络中断而丢失(未同步到备机房)。
4:05 故障恢复,总影响时间:48 分钟。
4:10 开始排查数据丢失原因。
事后统计:故障期间,约 12% 的用户订单数据丢失(未能及时同步),直接损失约 85 万元,间接损失(用户信任度下降)无法量化。
这个案例揭示了多活架构的几个核心问题:
- DNS 切换不是自动的,需要人工操作 + DNS 缓存刷新
- 容量规划不足,备用机房必须能承接全量流量
- 数据同步是最大的坑,跨机房的数据一致性比想象中难得多
多活架构等级
多活不是一个「做了/没做」的二元问题,而是一个有多个等级的连续谱。从最低的冷备到最高的异地多活,每个等级对应不同的投入和保障能力。
等级一:冷备
冷备是最简单、成本最低的容灾方案。备机房平时不运行任何服务,只有在主机房故障后,才手动启动服务、恢复数据。
日常状态:
主机房 A:100% 服务运行,承载全部流量
备机房 B:关机状态,无服务运行,无数据同步
故障后:
1. 启动备机房 B 的服务器(约 30 分钟)
2. 从备份恢复数据(取决于数据量,约 30 分钟 - 数小时)
3. 修改 DNS 指向(约 10 分钟)
4. 切流
RTO:小时级
RPO:取决于备份频率,最差可能丢失几小时的数据
适用场景:非核心系统、内部工具、对停机时间容忍度高的业务。
不适用场景:核心交易系统、用户直接使用的线上服务。
等级二:热备(主备同步)
热备比冷备更进一步:备机房平时保持运行状态,实时同步主机房的数据。故障时不需要启动服务器,但仍然需要手动切换流量。
日常状态:
主机房 A:100% 服务运行,承载全部流量
备机房 B:服务运行,数据库实时同步,但不接收请求
故障后:
1. 提升备机房 B 为主机(自动或手动,约 5 分钟)
2. 修改 DNS 指向(约 10 分钟)
3. 切流
RTO:15-30 分钟
RPO:接近零(实时同步)
热备的问题是备机房平时不承载流量,资源利用率低,而且切换时 DNS 刷新需要时间(用户端 DNS 缓存最长可达 24 小时,部分用户会持续访问故障机房)。
等级三:同城双活
同城双活是大多数中型互联网公司的目标:两个机房同时承载流量,故障时自动切换,不需要人工干预。
日常状态:
主机房 A:50-60% 流量
备机房 B:40-50% 流量(按机房容量比例分配)
两个机房都有完整的服务和数据副本
故障后:
1. 负载均衡器自动将故障机房流量切到健康机房(秒级)
2. 故障机房的服务自动下线(健康检查触发)
RTO:秒级到分钟级
RPO:接近零(同城网络延迟通常 < 5ms,数据同步几乎无感知)
同城双活的关键技术挑战是数据库双向同步——两个机房同时写入时,如何保证数据不冲突?
解决方案:按用户 ID 路由到固定机房。每个用户固定属于某个机房(按 userId % N 路由),该用户的所有读写都在同一个机房内完成,避免跨机房的事务。
// 同城双活路由:按 userId 固定到某个机房
@Service
public class UserRoutingService {
@Autowired private DataSource dataSourceA;
@Autowired private DataSource dataSourceB;
// 路由规则:userId % 2 == 0 → 机房 A,userId % 2 == 1 → 机房 B
public DataSource routeDataSource(Long userId) {
return (userId % 2 == 0) ? dataSourceA : dataSourceB;
}
// 获取用户所属机房
public String getUserHomeRegion(Long userId) {
return (userId % 2 == 0) ? "region-A" : "region-B";
}
}
// 订单服务:按用户 ID 路由
@Service
public class OrderService {
@Autowired private UserRoutingService routingService;
@Transactional
public Order createOrder(Long userId, OrderRequest request) {
// 1. 按 userId 获取对应的数据源
DataSource ds = routingService.routeDataSource(userId);
// 2. 所有操作都在同一个数据源内完成(无跨机房事务)
try (Connection conn = ds.getConnection()) {
// 扣减库存(在当前机房内完成)
stockMapper.decreaseStock(conn, request.getProductId(), 1);
// 创建订单(在当前机房内完成)
Order order = new Order();
order.setUserId(userId);
order.setProductId(request.getProductId());
orderMapper.insert(conn, order);
return order;
}
}
}
等级四:异地多活(单元化)
异地多活是阿里、字节等大厂采用的方案,在地理上相隔足够远的多个位置同时提供服务。它能应对的不只是机房故障,还包括城市级灾难(地震、洪水、大规模停电)。
单元化(Cell-based Architecture)是异地多活的核心设计思想:把整个业务拆成多个相互独立的「单元」,每个单元包含完整的应用层和本地数据,单元之间不跨单元调用。
全国划分为多个 Cell:
Cell 北京:
负责:北京 + 天津 + 河北 的用户
数据:本地用户数据为主,全局数据(订单、账户)通过同步中间件同步
Cell 上海:
负责:上海 + 江苏 + 浙江 的用户
数据:同上
Cell 广州:
负责:广东 + 广西 + 海南 的用户
数据:同上
用户就近接入自己所属的 Cell,故障时:
1. 健康检查发现 Cell 北京不可用
2. 路由层将北京用户流量切到最近的可用 Cell(如 Cell 上海)
3. 北京用户在上海 Cell 临时运行,数据在上海本地处理
4. 故障恢复后,北京 Cell 重新上线,北京用户切回
数据同步:多活架构的核心难题
三类数据,三种同步策略
多活架构中最复杂的问题不是流量切换,而是数据同步。不同类型的数据,需要不同的同步策略:
数据库同步:MySQL 半同步复制 vs Binlog 订阅
// 方案一:MySQL 半同步复制(ASM)
// MySQL 5.7+ 支持半同步复制,主库写入后,等待至少一个从库确认收到日志才返回成功
// 优点:配置简单,MySQL 原生支持
// 缺点:主库在从库响应慢时会阻塞,跨机房延迟高时性能下降明显
// 配置半同步复制(主库)
SET GLOBAL rpl_semi_sync_master_enabled = ON;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 等待 1000ms
// 方案二:Binlog 订阅(Canal / Debezium)
// 优点:解耦主库和从库,不影响主库性能
// 缺点:需要额外维护 Binlog 订阅服务
// Canal 订阅实现(Java)
@Service
public class BinlogSyncService {
@Autowired private KafkaTemplate kafkaTemplate;
// Canal 客户端监听 Binlog 变更
public void onRowData(RowData rowData) {
String tableName = rowData.getTableName();
List<Column> columns = rowData.getColumns();
// 判断变更类型
switch (rowData.getEventType()) {
case INSERT:
case UPDATE:
// 将变更序列化为消息,发送到 Kafka
String json = serializeToJson(tableName, columns);
kafkaTemplate.send("binlog-sync:" + tableName,
getPrimaryKey(rowData), json);
break;
case DELETE:
// 删除操作也要同步
String deleteJson = serializeToJson(tableName, columns);
kafkaTemplate.send("binlog-sync-delete:" + tableName,
getPrimaryKey(rowData), deleteJson);
break;
}
}
}
跨机房同步的一致性问题
当两个机房同时写入同一份数据时,会产生冲突。最常见的场景是余额扣减:两个机房同时对同一个账户余额扣减 100 元,如果各自独立执行,结果会出错。
解决方案一:绝对单向写。所有涉及金额的写操作,只能在主机房执行。备机房只读不写,主机房写入后通过消息队列同步到备机房。这种方式牺牲了可用性(主机房挂了就没法扣减),但保证了强一致性。
解决方案二:冲突检测 + 人工处理。备机房允许写入,但写入时检查版本号。如果发现冲突(版本号已被其他机房更新),记录冲突日志,触发告警,由人工处理。这种方式适合非金融类数据。
// 冲突检测:基于版本号的乐观锁
@Service
public class BalanceSyncService {
@Autowired private KafkaTemplate kafkaTemplate;
@KafkaListener(topics = "binlog-sync:account_balance", groupId = "balance-sync")
public void onBalanceChange(BalanceChangeEvent event) {
String key = event.getAccountId().toString();
// 读取当前版本
AccountBalance current = accountBalanceRepo.findById(event.getAccountId());
// 版本冲突检测
if (current.getVersion() >= event.getVersion()) {
// 说明本机房已经有更新的数据,忽略这条同步消息
log.info("跳过旧版本同步: accountId={}, currentVersion={}, eventVersion={}",
event.getAccountId(), current.getVersion(), event.getVersion());
return;
}
// 更新本地余额
current.setBalance(event.getBalance());
current.setVersion(event.getVersion());
accountBalanceRepo.save(current);
log.info("同步余额变更: accountId={}, balance={}, version={}",
event.getAccountId(), event.getBalance(), event.getVersion());
}
}
健康检查与自动切换
多活架构中最关键的组件是健康检查和流量切换系统。这个系统的职责是:持续监控每个机房的健康状态,在故障发生时自动将流量切换到健康机房,整个过程不需要人工干预。
健康检查的设计
// 健康检查服务
@Service
public class HealthCheckService {
@Autowired private DataSource dataSourceA;
@Autowired private DataSource dataSourceB;
@Autowired private RedisTemplate redisTemplate;
// 检查项
private static final int CHECK_INTERVAL_MS = 5000; // 每 5 秒检查一次
private static final int THRESHOLD_UNHEALTHY = 3; // 连续 3 次失败标记为不健康
// 每秒检查一次
@Scheduled(fixedDelay = 1000)
public void checkHealth() {
checkRegionA();
checkRegionB();
}
private void checkRegionA() {
int failures = 0;
// 检查项 1:数据库连接
try (Connection conn = dataSourceA.getConnection()) {
if (!conn.isValid(3000)) failures++;
} catch (Exception e) {
failures++;
}
// 检查项 2:Redis 连接
try {
redisTemplate.getConnectionFactory().getConnection().ping();
} catch (Exception e) {
failures++;
}
// 检查项 3:应用健康端点
try {
RestTemplate rest = new RestTemplate();
rest.getForObject("http://region-a-internal:8080/health",
String.class);
} catch (Exception e) {
failures++;
}
// 更新健康状态(使用 Redis 存储,跨服务共享)
String key = "health:region-a:failures";
if (failures > 0) {
redisTemplate.opsForValue().increment(key);
} else {
redisTemplate.delete(key);
}
Integer failureCount =
Integer.parseInt(redisTemplate.opsForValue().get(key) ?? "0");
if (failureCount >= THRESHOLD_UNHEALTHY) {
// 触发故障告警
alertService.alert("Region A 不健康,连续失败 " + failureCount + " 次");
}
}
}
自动流量切换
// 流量切换服务
@Service
public class TrafficSwitchService {
@Autowired private HealthCheckService healthCheckService;
@Autowired private DnsService dnsService;
@Autowired private AlertService alertService;
@Autowired private RedisTemplate redisTemplate;
private volatile boolean isInSwitch = false;
// 每 5 秒执行一次切换检查
@Scheduled(fixedDelay = 5000)
public void checkAndSwitch() {
// 判断主机房是否不健康
boolean regionAHealthy = healthCheckService.isHealthy("region-A");
boolean regionBHealthy = healthCheckService.isHealthy("region-B");
if (!regionAHealthy && regionBHealthy && !isInSwitch) {
// 触发切换:Region A → Region B
doSwitch("region-A", "region-B");
} else if (regionAHealthy && !isInSwitch) {
// Region A 恢复,等待一段时间后切回
String switchRecoveryKey = "switch:recovery:count";
Integer count = Integer.parseInt(
redisTemplate.opsForValue().get(switchRecoveryKey) ?? "0");
if (count >= 12) { // 连续 12 次(1 分钟)都健康,才切回
doSwitchBack("region-B", "region-A");
redisTemplate.delete(switchRecoveryKey);
} else {
redisTemplate.opsForValue().increment(switchRecoveryKey);
}
} else if (!regionAHealthy && !regionBHealthy) {
// 两个机房都不健康,触发最高级别告警
alertService.critical("两个机房均不可用!");
}
}
private void doSwitch(String fromRegion, String toRegion) {
log.warn("执行主备切换: {} → {}", fromRegion, toRegion);
alertService.alert("主备切换已开始:" + fromRegion + " → " + toRegion);
// 1. 修改 DNS:逐步将流量从故障机房切到健康机房
// 注意:不要一次性全部切换,要分批(10% → 30% → 50% → 100%)
dnsService.updateWeight(fromRegion, 0);
dnsService.updateWeight(toRegion, 10); // 先切 10%,观察
// 等待 30 秒,观察指标
try { Thread.sleep(30000); } catch (InterruptedException ignored) {}
// 2. 观察健康机房的指标(错误率、延迟)
boolean healthy = observeTargetRegion(toRegion);
if (healthy) {
dnsService.updateWeight(toRegion, 100); // 切完全部流量
isInSwitch = true;
alertService.alert("主备切换完成");
} else {
// 切换后健康机房出现问题,回滚
log.error("目标机房 {} 不健康,回滚切换", toRegion);
dnsService.updateWeight(fromRegion, 100);
dnsService.updateWeight(toRegion, 0);
}
}
private boolean observeTargetRegion(String region) {
// 观察 30 秒内的错误率和延迟
// 错误率 > 5% 或 p99 延迟 > 1s → 不健康
// ...
return true; // 简化实现
}
}
分批切换的原因:即使健康机房能承接全量流量,突然涌入的流量也会造成性能波动(CPU 抖动、连接池瞬时压力)。分批切换可以让健康机房有时间「热身」。
DNS 切换的坑
DNS 切换是很多多活方案中最容易被低估的环节。
DNS 缓存问题
DNS 记录在用户端有缓存,TTL(Time To Live)决定了缓存多久过期。即使你在 DNS 服务端改了记录,用户端也可能继续访问旧 IP,长达数小时。
解决方案:
- 设置合理的 TTL。生产环境的 DNS 记录 TTL 通常设置为 60-300 秒。太长会导致切换慢,太短会增加 DNS 查询压力。
- 使用 HTTPDNS / DoH / DoT。绕过运营商 DNS 缓存,直接在客户端做 DNS 解析。阿里云 HTTPDNS、腾讯云 HTTPDNS 都提供这个能力。
- 客户端侧兜底。在 SDK 层检测到请求失败后,主动尝试备用域名/IP,而不是等 DNS 缓存自然过期。
// 客户端 DNS 降级逻辑
public class DnsFailoverInterceptor implements Interceptor {
private List<String> ipList = Arrays.asList(
"1.1.1.1", // 机房 A
"2.2.2.2" // 机房 B
);
@Override
public Response intercept(Chain chain) throws IOException {
for (String ip : ipList) {
try {
// 逐个尝试每个 IP
Request request = chain.request()
.newBuilder()
.url(chain.request().url().newBuilder()
.host(ip)
.build())
.build();
Response response = chain.proceed(request);
if (response.isSuccessful()) {
return response;
}
} catch (Exception e) {
// 当前 IP 不可用,尝试下一个
log.warn("请求 IP {} 失败,尝试下一个: {}", ip, e.getMessage());
}
}
throw new IOException("所有机房均不可用");
}
}
真实踩坑案例
踩坑一:切换后发现「幽灵数据」
某公司在故障切换后,发现备机房的数据库里有一些「幽灵订单」——这些订单在备机房创建了,但主机房宕机前同步过来了一条更新,导致订单状态不一致。
排查发现:故障发生时,主机房有一个事务正在写入订单(状态 = PENDING),但还没提交。备机房同步到的数据是这条未提交事务的中间状态(部分字段已更新,部分字段未更新)。
教训:数据库主从同步无法保证分布式事务的原子性。解决方案是使用半同步复制,确保事务日志至少在一个备库确认收到后才算提交。
踩坑二:缓存击穿
故障切换后,大量请求涌入健康机房。但健康机房的 Redis 缓存是空的(之前没有承载这些用户),导致所有请求直接打到数据库,数据库瞬间过载。
教训:缓存不能只做单机房内的缓存。健康检查和缓存预热必须联动:一旦检测到主机房可能不可用,立即开始向备用机房预热缓存数据。
踩坑三:RabbitMQ 消息重复消费
故障切换后,消息队列中的消息被两个机房的消费者同时消费,导致库存被扣减两次。
教训:MQ 消费者必须支持幂等消费。方案是在消息中添加唯一 ID,消费者在处理前先检查该 ID 是否已处理(用 Redis 或数据库唯一索引)。
// 幂等消费
@Service
public class InventoryConsumer {
@Autowired private RedisTemplate redisTemplate;
@KafkaListener(topics = "inventory-deduct")
public void consume(InventoryDeductMessage msg) {
String idempotentKey = "consumed:inventory:" + msg.getMessageId();
// 检查是否已消费
Boolean already = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1",
Duration.ofHours(24));
if (Boolean.FALSE.equals(already)) {
log.info("消息已消费,跳过: messageId={}", msg.getMessageId());
return;
}
try {
// 执行库存扣减
inventoryService.deduct(msg.getProductId(), msg.getQuantity());
} catch (Exception e) {
// 消费失败,删除幂等标记,等待重试
redisTemplate.delete(idempotentKey);
throw e;
}
}
}
术语表
总结
跨机房多活是成本最高的架构演进之一,但它解决的是最硬的需求:在任何情况下,用户的请求都能被处理。
多活等级的选择:
- 冷备:成本最低,适合非核心系统
- 热备:需要实时数据同步,适合中等可用性要求的系统
- 同城双活:两个机房同时承载流量,适合高可用要求的系统(大多数中型互联网公司的目标)
- 异地多活(单元化):应对城市级灾难,适合金融、核心交易系统
多活架构的三大核心问题:
- 数据同步:三类数据(用户数据、业务数据、缓存数据)各有不同策略,强一致性数据只能单向写,最终一致性数据用消息队列同步
- 健康检查:多维度检查(DB、Redis、应用层),连续多次失败才触发切换,避免误判
- DNS 切换:设置合理 TTL,配合 HTTPDNS 绕过缓存,配合客户端降级兜底
多活架构的真正难点不是技术设计,而是运维流程和故障演练。 一次真实的机房故障,考验的是团队在压力下的执行力和判断力。演练的频率和深度,直接决定了真实故障时的 RTO。
建议每个季度至少做一次完整的故障演练,模拟机房断电、网络中断、数据中心级别的灾难。每一次演练暴露出的问题,都是真实故障中可能吃的大亏。