🌍 跨机房数据同步与容灾切换:异地多活架构实战!

62 阅读8分钟

副标题:双活架构、数据同步、流量调度,打造真正的高可用系统!🚀


🎬 开场:单机房的脆弱性

2023年某云厂商机房故障 💥:

14:00  北京某机房光缆被挖断
14:05  网络中断,服务全部不可用
14:10  用户疯狂投诉
14:30  抢修队赶到现场
15:00  发现需要重新铺设光缆
18:00  还在抢修中...
21:00  终于恢复

影响:
├── 服务中断7小时
├── 损失订单:10万笔
├── 资金损失:5000万
├── 用户流失:20%
└── 股价下跌:15%

教训:
单机房 = 单点故障 = 随时可能全军覆没!

这就是我们需要跨机房容灾的原因!


📚 核心概念

什么是跨机房容灾?

单机房部署:
┌────────────────┐
│  北京机房      │
│  - 应用服务    │
│  - 数据库      │
│  - 缓存        │
└────────────────┘
     ↑
  所有流量
     
问题:机房挂了 = 全挂!

跨机房部署:
┌────────────────┐    ┌────────────────┐
│  北京机房      │←──→│  上海机房      │
│  - 应用服务    │同步│  - 应用服务    │
│  - 数据库      │    │  - 数据库      │
│  - 缓存        │    │  - 缓存        │
└────────────────┘    └────────────────┘
     ↑                     ↑
  50%流量            50%流量

优点:一个机房挂了,另一个顶上!

容灾级别

级别RPORTO描述成本
冷备小时级小时级定期备份,手动恢复
温备分钟级分钟级准实时同步,自动切换
热备秒级秒级实时同步,自动切换
双活00同时提供服务很高
多活00多地同时服务极高

名词解释

  • RPO (Recovery Point Objective): 恢复点目标,能容忍丢失多少数据
  • RTO (Recovery Time Objective): 恢复时间目标,多久能恢复服务

🏗️ 异地双活架构

1. 整体架构

               ┌─────────────────┐
               │   DNS/GSLB      │  ← 全局负载均衡
               │  (智能调度)     │
               └────────┬────────┘
                        │
            ┌───────────┴───────────┐
            │                       │
    ┌───────▼────────┐      ┌──────▼────────┐
    │  北京机房      │      │  上海机房      │
    │                │←────→│               │
    │  ┌──────────┐  │ 同步 │  ┌──────────┐ │
    │  │应用集群  │  │      │  │应用集群  │ │
    │  └─────┬────┘  │      │  └─────┬────┘ │
    │        │       │      │        │      │
    │  ┌─────▼────┐  │      │  ┌─────▼────┐ │
    │  │Redis集群│  │←────→│  │Redis集群│ │
    │  └─────┬────┘  │      │  └─────┬────┘ │
    │        │       │      │        │      │
    │  ┌─────▼────┐  │      │  ┌─────▼────┐ │
    │  │MySQL主库│  │←────→│  │MySQL主库│ │
    │  └──────────┘  │binlog│  └──────────┘ │
    └────────────────┘      └───────────────┘

2. 流量调度策略

/**
 * 智能流量调度
 */
@Component
public class TrafficRouter {
    
    /**
     * 根据用户地理位置路由
     */
    public String routeByGeo(String userId, String userIp) {
        // 解析用户IP地理位置
        GeoLocation location = geoService.parseIp(userIp);
        
        if (location.isNorth()) {
            return "beijing-idc";  // 北方用户 → 北京机房
        } else {
            return "shanghai-idc";  // 南方用户 → 上海机房
        }
    }
    
    /**
     * 根据数据分片路由
     */
    public String routeBySharding(String userId) {
        // 用户ID取模
        long uid = Long.parseLong(userId);
        
        if (uid % 2 == 0) {
            return "beijing-idc";  // 偶数用户 → 北京
        } else {
            return "shanghai-idc";  // 奇数用户 → 上海
        }
    }
    
    /**
     * 灾难切换
     */
    public String routeWithFailover(String userId, String primaryIdc) {
        // 检查主机房健康状态
        if (healthCheck.isHealthy(primaryIdc)) {
            return primaryIdc;
        } else {
            // 主机房故障,切换到备用机房
            String backupIdc = getBackupIdc(primaryIdc);
            log.warn("机房故障切换: {} -> {}", primaryIdc, backupIdc);
            return backupIdc;
        }
    }
}

💾 数据同步方案

1. MySQL主主复制

配置主主复制

-- 北京MySQL配置
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-do-db = mydb
auto_increment_increment = 2  -- 自增步长为2
auto_increment_offset = 1     -- 北京从1开始(1,3,5,7...)

-- 上海MySQL配置
[mysqld]
server-id = 2
log-bin = mysql-bin
binlog-do-db = mydb
auto_increment_increment = 2  -- 自增步长为2
auto_increment_offset = 2     -- 上海从2开始(2,4,6,8...)

好处

避免ID冲突:
北京生成的ID: 1, 3, 5, 7, 9...
上海生成的ID: 2, 4, 6, 8, 10...

永远不会冲突!

数据同步监控

@Service
public class MySQLReplicationMonitor {
    
    @Scheduled(fixedDelay = 10000)  // 每10秒检查一次
    public void checkReplicationLag() {
        // 1. 连接到从库
        Connection conn = getSlaveConnection();
        
        // 2. 执行 SHOW SLAVE STATUS
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SHOW SLAVE STATUS");
        
        if (rs.next()) {
            // 3. 检查同步延迟
            long secondsBehindMaster = rs.getLong("Seconds_Behind_Master");
            
            if (secondsBehindMaster > 60) {  // 延迟超过1分钟
                alertService.send(
                    "MySQL同步延迟告警",
                    "当前延迟: " + secondsBehindMaster + " 秒"
                );
            }
            
            // 4. 检查同步状态
            String slaveIORunning = rs.getString("Slave_IO_Running");
            String slaveSQLRunning = rs.getString("Slave_SQL_Running");
            
            if (!"Yes".equals(slaveIORunning) || !"Yes".equals(slaveSQLRunning)) {
                alertService.send(
                    "MySQL同步中断告警",
                    "IO_Running: " + slaveIORunning + 
                    ", SQL_Running: " + slaveSQLRunning
                );
            }
        }
    }
}

2. Redis数据同步

方案1:Redis主从复制

北京Redis(Master)  →  上海Redis(Slave)
         ↓
    写入数据
         ↓
    异步复制  →  上海Redis收到数据

方案2:Redis Cluster跨机房

/**
 * Redis跨机房集群配置
 */
@Configuration
public class RedisClusterConfig {
    
    @Bean
    public RedisClusterConfiguration redisClusterConfiguration() {
        RedisClusterConfiguration config = new RedisClusterConfiguration();
        
        // 北京节点
        config.addClusterNode(new RedisNode("bj-redis-1", 6379));
        config.addClusterNode(new RedisNode("bj-redis-2", 6379));
        config.addClusterNode(new RedisNode("bj-redis-3", 6379));
        
        // 上海节点
        config.addClusterNode(new RedisNode("sh-redis-1", 6379));
        config.addClusterNode(new RedisNode("sh-redis-2", 6379));
        config.addClusterNode(new RedisNode("sh-redis-3", 6379));
        
        return config;
    }
}

方案3:双写策略

@Service
public class CacheService {
    
    @Autowired
    @Qualifier("beijingRedis")
    private RedisTemplate<String, Object> beijingRedis;
    
    @Autowired
    @Qualifier("shanghaiRedis")
    private RedisTemplate<String, Object> shanghaiRedis;
    
    /**
     * 双写:同时写两个机房的Redis
     */
    public void set(String key, Object value, long timeout) {
        // 异步写两个机房
        CompletableFuture.allOf(
            CompletableFuture.runAsync(() -> 
                beijingRedis.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)
            ),
            CompletableFuture.runAsync(() -> 
                shanghaiRedis.opsForValue().set(key, value, timeout, TimeUnit.SECONDS)
            )
        ).join();
    }
    
    /**
     * 读取:优先本地机房
     */
    public Object get(String key) {
        String currentIdc = getCurrentIdc();
        
        if ("beijing".equals(currentIdc)) {
            Object value = beijingRedis.opsForValue().get(key);
            if (value != null) return value;
            
            // 本地没有,尝试从远程读取
            return shanghaiRedis.opsForValue().get(key);
        } else {
            Object value = shanghaiRedis.opsForValue().get(key);
            if (value != null) return value;
            
            return beijingRedis.opsForValue().get(key);
        }
    }
}

3. 消息队列同步

Kafka跨机房镜像

北京Kafka集群  ──MirrorMaker──→  上海Kafka集群
     ↓                              ↓
  本地消费                        本地消费

配置MirrorMaker

# 源集群(北京)
bootstrap.servers=bj-kafka-1:9092,bj-kafka-2:9092

# 目标集群(上海)
producer.bootstrap.servers=sh-kafka-1:9092,sh-kafka-2:9092

# 要镜像的Topic
whitelist=order.*,payment.*,user.*

🚨 容灾切换

1. 健康检查

/**
 * 机房健康检查
 */
@Service
public class IdcHealthChecker {
    
    private final Map<String, IdcHealthStatus> healthStatus = new ConcurrentHashMap<>();
    
    /**
     * 定期健康检查
     */
    @Scheduled(fixedDelay = 5000)  // 每5秒检查
    public void healthCheck() {
        for (String idc : getAllIdcs()) {
            IdcHealthStatus status = checkIdc(idc);
            healthStatus.put(idc, status);
            
            if (!status.isHealthy()) {
                handleIdcDown(idc);
            }
        }
    }
    
    /**
     * 检查单个机房
     */
    private IdcHealthStatus checkIdc(String idc) {
        IdcHealthStatus status = new IdcHealthStatus(idc);
        
        // 1. 检查网络连通性
        boolean networkOk = pingTest(idc);
        status.setNetworkHealthy(networkOk);
        
        // 2. 检查应用服务
        boolean appOk = httpHealthCheck(idc + "/health");
        status.setAppHealthy(appOk);
        
        // 3. 检查数据库
        boolean dbOk = dbHealthCheck(idc);
        status.setDbHealthy(dbOk);
        
        // 4. 检查缓存
        boolean cacheOk = cacheHealthCheck(idc);
        status.setCacheHealthy(cacheOk);
        
        // 5. 综合判断
        status.setHealthy(networkOk && appOk && dbOk && cacheOk);
        
        return status;
    }
}

2. 自动切换

/**
 * 容灾切换管理器
 */
@Service
public class DisasterRecoveryManager {
    
    @Autowired
    private TrafficRouter trafficRouter;
    
    @Autowired
    private DnsService dnsService;
    
    /**
     * 执行容灾切换
     */
    public void failover(String failedIdc, String targetIdc) {
        log.warn("开始容灾切换: {} -> {}", failedIdc, targetIdc);
        
        try {
            // 1. 停止向故障机房发送流量
            trafficRouter.removeIdc(failedIdc);
            
            // 2. 更新DNS,指向健康机房
            dnsService.updateRecord(
                "api.example.com",
                getIdcIp(targetIdc)
            );
            
            // 3. 通知所有客户端
            notifyClients(failedIdc, targetIdc);
            
            // 4. 记录切换日志
            logFailover(failedIdc, targetIdc);
            
            // 5. 发送告警
            alertService.send(
                "容灾切换通知",
                String.format("机房 %s 故障,已切换到 %s", failedIdc, targetIdc)
            );
            
            log.info("容灾切换完成");
            
        } catch (Exception e) {
            log.error("容灾切换失败", e);
            throw e;
        }
    }
    
    /**
     * 切换回原机房
     */
    public void fallback(String originalIdc) {
        log.info("开始切换回原机房: {}", originalIdc);
        
        // 1. 检查原机房是否已恢复
        if (!healthChecker.isHealthy(originalIdc)) {
            log.warn("原机房还未完全恢复,暂不切换");
            return;
        }
        
        // 2. 灰度切换流量
        gradualSwitchTraffic(originalIdc);
    }
    
    /**
     * 灰度切换流量
     */
    private void gradualSwitchTraffic(String targetIdc) {
        // 逐步增加流量:5% → 10% → 20% → 50% → 100%
        int[] percentages = {5, 10, 20, 50, 100};
        
        for (int percentage : percentages) {
            log.info("切换{}%流量到{}", percentage, targetIdc);
            
            trafficRouter.setTrafficPercentage(targetIdc, percentage);
            
            // 等待5分钟,观察效果
            sleep(5 * 60 * 1000);
            
            // 检查是否有异常
            if (hasError(targetIdc)) {
                log.error("切换过程中发现异常,回滚");
                rollback();
                return;
            }
        }
        
        log.info("流量切换完成");
    }
}

3. DNS智能解析

/**
 * 智能DNS服务
 */
@Service
public class SmartDnsService {
    
    /**
     * 根据用户IP返回最近的机房IP
     */
    public String resolve(String domain, String clientIp) {
        // 1. 解析客户端地理位置
        GeoLocation location = geoService.parseIp(clientIp);
        
        // 2. 查找最近的健康机房
        String nearestIdc = findNearestHealthyIdc(location);
        
        // 3. 返回机房IP
        return getIdcIp(nearestIdc);
    }
    
    /**
     * 查找最近的健康机房
     */
    private String findNearestHealthyIdc(GeoLocation location) {
        List<String> healthyIdcs = healthChecker.getHealthyIdcs();
        
        String nearestIdc = null;
        double minDistance = Double.MAX_VALUE;
        
        for (String idc : healthyIdcs) {
            double distance = calculateDistance(location, getIdcLocation(idc));
            
            if (distance < minDistance) {
                minDistance = distance;
                nearestIdc = idc;
            }
        }
        
        return nearestIdc;
    }
}

💡 数据一致性保证

1. 最终一致性

/**
 * 数据一致性检查
 */
@Service
public class DataConsistencyChecker {
    
    /**
     * 定期对账
     */
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点
    public void checkConsistency() {
        log.info("开始数据一致性检查");
        
        // 1. 查询北京机房的数据摘要
        String bjDigest = calculateDigest("beijing");
        
        // 2. 查询上海机房的数据摘要
        String shDigest = calculateDigest("shanghai");
        
        // 3. 对比摘要
        if (!bjDigest.equals(shDigest)) {
            log.error("数据不一致!");
            
            // 4. 找出不一致的数据
            List<DataDifference> differences = findDifferences("beijing", "shanghai");
            
            // 5. 修复不一致
            for (DataDifference diff : differences) {
                repairData(diff);
            }
        }
        
        log.info("数据一致性检查完成");
    }
    
    /**
     * 计算数据摘要
     */
    private String calculateDigest(String idc) {
        // 按表计算MD5
        StringBuilder digest = new StringBuilder();
        
        for (String table : getTables()) {
            String sql = "SELECT MD5(GROUP_CONCAT(id, name, created_at)) FROM " + table;
            String tableMd5 = executeQuery(idc, sql);
            digest.append(tableMd5);
        }
        
        return MD5.hash(digest.toString());
    }
}

2. 冲突解决

/**
 * 数据冲突解决
 */
@Service
public class ConflictResolver {
    
    /**
     * 解决冲突
     */
    public void resolveConflict(DataConflict conflict) {
        // 策略1:时间戳优先(Last Write Wins)
        if (conflict.getBjTimestamp() > conflict.getShTimestamp()) {
            syncToShanghai(conflict.getBjData());
        } else {
            syncToBeijing(conflict.getShData());
        }
        
        // 策略2:版本号优先
        if (conflict.getBjVersion() > conflict.getShVersion()) {
            syncToShanghai(conflict.getBjData());
        } else {
            syncToBeijing(conflict.getShData());
        }
        
        // 策略3:业务规则
        // 例如:金额以大的为准
        if (conflict.isAmountConflict()) {
            BigDecimal bjAmount = conflict.getBjData().getAmount();
            BigDecimal shAmount = conflict.getShData().getAmount();
            
            if (bjAmount.compareTo(shAmount) > 0) {
                syncToShanghai(conflict.getBjData());
            } else {
                syncToBeijing(conflict.getShData());
            }
        }
    }
}

🎯 最佳实践

1. 分级容灾

核心业务(订单、支付):
└─ 双活 + 实时同步

重要业务(用户、商品):
└─ 热备 + 秒级同步

一般业务(日志、统计):
└─ 温备 + 分钟级同步

非核心业务(评论、点赞):
└─ 冷备 + 天级备份

2. 演练流程

/**
 * 容灾演练
 */
@Service
public class DrillService {
    
    /**
     * 定期演练
     */
    @Scheduled(cron = "0 0 2 1 * ?")  // 每月1号凌晨2点
    public void drill() {
        log.info("开始容灾演练");
        
        try {
            // 1. 记录当前状态
            DrillSnapshot snapshot = captureSnapshot();
            
            // 2. 模拟北京机房故障
            simulateIdcFailure("beijing");
            
            // 3. 执行容灾切换
            disasterRecoveryManager.failover("beijing", "shanghai");
            
            // 4. 验证服务可用性
            verifyServiceAvailability();
            
            // 5. 切换回原机房
            disasterRecoveryManager.fallback("beijing");
            
            // 6. 验证数据一致性
            verifyDataConsistency();
            
            // 7. 记录演练结果
            recordDrillResult(snapshot, true);
            
            log.info("容灾演练完成");
            
        } catch (Exception e) {
            log.error("容灾演练失败", e);
            recordDrillResult(null, false);
        }
    }
}

🎉 总结

核心要点 ✨

  1. 架构设计

    • 异地双活
    • 数据实时同步
    • 智能流量调度
  2. 数据同步

    • MySQL主主复制
    • Redis双写/主从
    • MQ镜像
  3. 容灾切换

    • 健康检查
    • 自动切换
    • 灰度恢复

记忆口诀 📝

跨机房容灾要做好,
数据同步是基础。

MySQL主主双向复制,
自增步长要设好。
Redis双写或主从,
缓存一致性重要。

健康检查要及时,
故障切换要自动。
DNS智能来解析,
流量调度最优化。

数据一致要保证,
定期对账不能少。
冲突解决有策略,
业务规则来指导。

容灾演练要定期,
问题发现早处理!

愿你的系统跨越千山万水,永不宕机! 🌍✨