副标题:双活架构、数据同步、流量调度,打造真正的高可用系统!🚀
🎬 开场:单机房的脆弱性
2023年某云厂商机房故障 💥:
14:00 北京某机房光缆被挖断
14:05 网络中断,服务全部不可用
14:10 用户疯狂投诉
14:30 抢修队赶到现场
15:00 发现需要重新铺设光缆
18:00 还在抢修中...
21:00 终于恢复
影响:
├── 服务中断7小时
├── 损失订单:10万笔
├── 资金损失:5000万
├── 用户流失:20%
└── 股价下跌:15%
教训:
单机房 = 单点故障 = 随时可能全军覆没!
这就是我们需要跨机房容灾的原因!
📚 核心概念
什么是跨机房容灾?
单机房部署:
┌────────────────┐
│ 北京机房 │
│ - 应用服务 │
│ - 数据库 │
│ - 缓存 │
└────────────────┘
↑
所有流量
问题:机房挂了 = 全挂!
跨机房部署:
┌────────────────┐ ┌────────────────┐
│ 北京机房 │←──→│ 上海机房 │
│ - 应用服务 │同步│ - 应用服务 │
│ - 数据库 │ │ - 数据库 │
│ - 缓存 │ │ - 缓存 │
└────────────────┘ └────────────────┘
↑ ↑
50%流量 50%流量
优点:一个机房挂了,另一个顶上!
容灾级别
| 级别 | RPO | RTO | 描述 | 成本 |
|---|---|---|---|---|
| 冷备 | 小时级 | 小时级 | 定期备份,手动恢复 | 低 |
| 温备 | 分钟级 | 分钟级 | 准实时同步,自动切换 | 中 |
| 热备 | 秒级 | 秒级 | 实时同步,自动切换 | 高 |
| 双活 | 0 | 0 | 同时提供服务 | 很高 |
| 多活 | 0 | 0 | 多地同时服务 | 极高 |
名词解释:
- 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);
}
}
}
🎉 总结
核心要点 ✨
-
架构设计:
- 异地双活
- 数据实时同步
- 智能流量调度
-
数据同步:
- MySQL主主复制
- Redis双写/主从
- MQ镜像
-
容灾切换:
- 健康检查
- 自动切换
- 灰度恢复
记忆口诀 📝
跨机房容灾要做好,
数据同步是基础。
MySQL主主双向复制,
自增步长要设好。
Redis双写或主从,
缓存一致性重要。
健康检查要及时,
故障切换要自动。
DNS智能来解析,
流量调度最优化。
数据一致要保证,
定期对账不能少。
冲突解决有策略,
业务规则来指导。
容灾演练要定期,
问题发现早处理!
愿你的系统跨越千山万水,永不宕机! 🌍✨