概述

凌晨 2 点 17 分,手机弹出告警:核心机房 A 区网络设备故障,数据库主从同步中断。值班 SRE 切到灾备机房 B 区,发现应用连上新主库后,部分订单数据查不到——异步复制窗口期丢了 47 秒数据。业务恢复用了 38 分钟,超出 RTO 目标 8 分钟。

这是一次真实的容灾切换事故。事后复盘发现,系统设计了容灾架构,配置了数据同步,也写了切换脚本,但真正切换时还是出了问题。问题不在于"有没有灾备",而在于容灾架构的每个环节是否真正经得起生产级考验。

本文拆解跨机房容灾的核心架构决策,覆盖同城双活、异地灾备、两地三中心三种模式的选型权衡,同步复制的性能代价与脑裂防护,异步复制的窗口期补偿机制,以及故障切换从决策到执行的完整流程。每个环节都附有真实踩坑记录和性能数据。

在我经手过的跨机房容灾方案中,最终达成 RPO<5min、RTO<30min 的指标。这个数字看起来不算极致,但它是成本、稳定性和业务需求三方博弈后的工程最优解——不是理论上的 RPO=0,而是生产环境能跑通、能验证、能回滚的方案。

RPO 和 RTO 的工程真相

不是技术指标,是业务指标

RPO(Recovery Point Objective)和 RTO(Recovery Time Objective)这两个词被技术圈用烂了,但很多人理解反了——它们首先是业务指标,然后才是技术指标。

RPO 回答的是"能容忍丢多少数据"。比如一个电商系统,订单数据的 RPO 必须是 0(不能丢订单),但用户行为日志的 RPO 可以是 1 小时(丢了不影响核心交易)。RTO 回答的是"能容忍停多久"——核心交易系统 RTO 要 <5 分钟,而报表系统的 RTO 可以是 4 小时。

关键认知:不要一刀切。 我见过太多团队把所有系统都按 RPO=0、RTO<1min 设计,结果容灾建设成本翻了好几倍,非核心系统根本不需要这么高的规格。

按业务重要性分级设计容灾目标:

级别系统类型RPO 目标RTO 目标同步方式
P0核心交易、支付0<5min同步复制
P1用户中心、订单查询<1min<15min半同步复制
P2内容管理、报表<30min<2h异步复制
P3日志分析、离线计算<1h<4h定时备份

RPO=0 的代价不是线性的

很多人觉得"RPO 越小越好",但 RPO 从 1 分钟到 0 的成本跳跃是指数级的:

  • RPO=1min:异步复制,主库写入不阻塞,性能无损
  • RPO=10s:半同步复制,主库等待至少一个从库确认,写延迟增加 2-5ms
  • RPO=0:全同步复制,主库必须等待所有从库持久化后才返回,写延迟取决于最慢的从库

实测数据:在某出行项目的核心订单库上,从异步复制切换到全同步复制后,写 QPS 从 12000 降到 8500(下降 29%),P99 写延迟从 3ms 升到 12ms。这个性能损失是否值得,取决于业务方对"丢一秒数据"的容忍度。

我的建议:P0 级系统用半同步复制(RPO≈0,但极端情况下可能丢毫秒级数据),P1 及以下用异步复制。真正需要 RPO=0 的场景,用同步复制但必须做好性能测试和容量规划。

容灾架构演进:三种模式

主备模式(Active-Standby)

最简单的容灾架构。主机房跑业务,备机房处于热备状态,数据通过异步复制同步。主机房故障时,切换到备机房。

  • 优点:架构简单,成本低
  • 缺点:备机房资源闲置,切换需要人工干预,RTO 通常在 15-30 分钟
  • 适用:P2/P3 级系统,中小规模团队

同城双活(Active-Active)

同城两个机房同时对外提供服务,数据通过同步复制保持一致。两个机房距离通常 <50km,网络延迟 <5ms。

  • 优点:资源利用率高,故障切换秒级,RPO=0
  • 缺点:架构复杂,需要解决脑裂问题,同城无法抵御城市级灾难
  • 适用:P0/P1 级系统

两地三中心

同城双活 + 异地灾备的组合。同城两个机房同步复制,异地机房异步复制。这是金融级系统的标准配置。

  • 优点:兼顾同城高可用和异地灾备
  • 缺点:建设成本高,三中心数据一致性管理复杂
  • 适用:金融、支付等对数据安全和业务连续性要求极高的场景

在 50 节点以下的小型集群中,我推荐同城双活而非两地三中心——异地灾备的运维成本和管理复杂度在小型集群中占比过高。只有当业务规模和合规要求都需要时,才上两地三中心。(相关文章:多地域多活架构的可靠性设计

同城双活:同步复制的性能代价与脑裂防护

同步复制的性能影响

同城双活的核心是同步复制——主库写入后,必须等待备库确认收到数据,才向客户端返回成功。这保证了 RPO=0,但代价是写延迟增加。

实测对比(MySQL 8.0,同城双机房,网络延迟 2.3ms):

复制模式写 QPSP50 写延迟P99 写延迟RPO
异步复制120001.8ms3.2ms1-5s
半同步复制105002.5ms5.8ms≈0(极端情况丢毫秒级)
全同步复制85004.1ms12.3ms0

全同步模式下 P99 写延迟从 3.2ms 升到 12.3ms,增加了 284%。这意味着所有依赖该数据库的写操作都会变慢,上游服务的超时设置需要同步调整。

踩坑记录 1:同步复制导致上游超时

在一次容灾切换演练中,将 MySQL 从异步复制切换到半同步复制后,上游订单服务的写超时从 50ms 频繁触发告警。根因是订单服务的 RPC 超时设置为 30ms,而半同步复制后数据库 P99 写延迟到了 5.8ms,叠加网络和序列化开销,总耗时超过 30ms。

修复方法:将写操作的 RPC 超时从 30ms 调整到 100ms,同时对非核心写操作(如日志记录)使用异步写入队列,不占用同步复制的连接。

-- MySQL 半同步复制配置(主库端)
-- 安装半同步插件
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';

-- 开启半同步并设置超时(毫秒)
SET GLOBAL rpl_semi_sync_source_enabled = 1;
SET GLOBAL rpl_semi_sync_source_timeout = 3000;  -- 3秒超时,超时降级为异步

-- 查看半同步状态
SHOW STATUS LIKE 'Rpl_semi_sync_source_status';
SHOW STATUS LIKE 'Rpl_semi_sync_source_yes_tx';  -- 成功的半同步事务数

注意 rpl_semi_sync_source_timeout 这个参数:半同步复制在超时后会自动降级为异步复制,这是一种保护机制,防止备库故障拖垮主库。但也意味着在极端情况下 RPO 不再是 0。

脑裂问题与仲裁机制

脑裂(Split-Brain)是同城双活最危险的故障模式。当两个机房之间的网络中断时,两个机房各自认为对方已故障,各自选主,开始独立写入。网络恢复后,两套数据冲突,合并几乎不可能。

脑裂的根因是仲裁机制失效。常见的防护方案:

方案 1:第三方仲裁节点(Quorum Node)

在第三个独立机房部署一个仲裁节点,两个机房都与仲裁节点保持心跳。网络分区时,仲裁节点通过多数派投票决定哪个机房继续服务。

机房A ←→ 仲裁节点 ←→ 机房B
  ↕                    ↕
  └─── 同步复制 ──────┘

仲裁节点的位置选择很关键。如果仲裁节点与机房 A 在同一物理网络中,那机房 A 故障时仲裁节点也跟着挂,无法发挥仲裁作用。我推荐仲裁节点部署在第三个独立机房或云上 VPC,与两个生产机房物理隔离。

方案 2:Fencing 隔离

当检测到脑裂风险时,主动隔离其中一个机房——通过 STONITH(Shoot The Other Node In The Head)机制,强制关闭故障机房的数据库实例。这比仲裁投票更暴力,但更可靠。

#!/bin/bash
# fencing 脚本:检测到脑裂时,强制隔离备机房
# 通过 API 关闭备机房数据库实例 + 切断流量入口

STANDBY_HOST="10.0.2.10"
STANDBY_API="http://${STANDBY_HOST}:8080/db/failover"

# 检测心跳:连续 3 次超时才执行隔离
HEARTBEAT_FAIL=0
for i in 1 2 3; do
    if ! ping -c 1 -W 2 ${STANDBY_HOST} > /dev/null 2>&1; then
        HEARTBEAT_FAIL=$((HEARTBEAT_FAIL + 1))
    fi
done

if [ ${HEARTBEAT_FAIL} -ge 3 ]; then
    echo "[$(date)] 脑裂检测:备机房连续3次心跳失败,执行隔离"
    # 1. 切断备机房 DNS 入口
    curl -s -X POST http://dns-api.internal/failover?disable=standby
    # 2. 强制关闭备机房数据库
    curl -s -X POST ${STANDBY_API}
    echo "[$(date)] 备机房已隔离,流量全部切到主机房"
fi

方案 3:应用层双写校验

不依赖底层仲裁,在应用层实现双写校验。每次写入前检查两个机房的数据库连接状态,如果只能连上一个机房,拒绝写入。这种方案牺牲了可用性换一致性,适用于 P0 级金融系统。

我的推荐:仲裁节点 + Fencing 组合使用。仲裁节点负责决策,Fencing 负责执行隔离。单一方案都有失效场景——仲裁节点自己也可能故障,Fencing 也可能执行失败。两个方案组合,即使一个失效,另一个还能兜底。

异地灾备:异步复制的窗口期与数据补偿

异步复制的本质

异地灾备用异步复制,因为跨城网络延迟通常在 10-30ms,同步复制会让写延迟高到不可接受。异步复制的本质是:主库写入后立即返回成功,不等待备库确认。数据通过 binlog/WAL 日志异步传输到备库。

这意味着 RPO > 0——主库故障时,备库还没收到的日志对应的数据会丢失。RPO 的大小取决于日志传输的延迟,通常是秒级到分钟级。

日志补位机制

主库故障后,备库需要追赶日志才能达到一致状态。这个过程叫"日志补位"(Log Catch-up):

时间线:
  主库:  T1(写入100条) → T2(写入200条) → T3(故障)
  备库:  T1(已同步)     → T2(同步中)     → T3(还差150条)

切换流程:
  1. 冻结主库写入(通过中间件拦截或 DNS 下线)
  2. 等待备库追平日志(监控 GTID 差值归零)
  3. 校验关键表行数和校验和
  4. 切换流量到备库

踩坑记录 2:GTID 断点导致补位失败

在一次异地灾备演练中,主库故障后切到异地备库,发现备库报 GTID gap 错误——备库缺少主库已提交的某些 GTID 事务。根因是主库的一个大事务(批量更新 50 万行)在传输过程中被截断,备库只收到了部分日志。

修复方法:

  1. 主库开启 binlog_transaction_compression=ON,减少大事务的日志分片
  2. 设置 slave_pending_jobs_size_max=256M,增大备库接收缓冲区
  3. 在切换前增加 GTID 完整性校验步骤
-- 主库:检查 GTID 是否完整
SHOW MASTER STATUS\G
-- 查看已执行和待执行的 GTID 集合

-- 备库:检查 GTID 同步状态
SHOW SLAVE STATUS\G
-- 关注 Retrieved_Gtid_Set 和 Executed_Gtid_Set
-- 如果两者不同,说明有未执行的 GTID

-- 备库:计算 GTID 差值
SELECT @@global.gtid_executed;
-- 与主库的 gtid_executed 对比,差值就是未同步的事务

-- 大事务检测:超过阈值的单个事务
SET GLOBAL binlog_transaction_compression=ON;
SET GLOBAL binlog_transaction_compression_level_zstd=6;

数据校验:别信复制状态

异步复制的"同步成功"只代表日志传输成功,不代表数据一致。日志在备库重放时可能出错——类型不兼容、字符集问题、唯一键冲突,这些问题在日志传输阶段不会报错,但备库数据已经和主库不一致了。

定期校验是必须的。我推荐每小时自动比对主备关键表的校验和:

#!/usr/bin/env python3
"""主备数据一致性校验脚本"""
import hashlib
import mysql.connector
import sys

def table_checksum(host, user, password, database, table):
    """计算表的校验和"""
    conn = mysql.connector.connect(host=host, user=user, password=password, database=database)
    cursor = conn.cursor()
    cursor.execute(f"SELECT COUNT(*), COALESCE(SUM(CRC32(CONCAT_WS('#', {get_columns(cursor, table)}))), 0) FROM {table}")
    count, checksum = cursor.fetchone()
    cursor.close()
    conn.close()
    return count, checksum

def get_columns(cursor, table):
    """获取表所有列名"""
    cursor.execute(f"SHOW COLUMNS FROM {table}")
    return ', '.join([f'`{row[0]}`' for row in cursor.fetchall()])

if __name__ == '__main__':
    primary = {'host': '10.0.1.10', 'user': 'check_user', 'password': sys.argv[1], 'database': 'orders'}
    standby = {'host': '10.0.2.10', 'user': 'check_user', 'password': sys.argv[1], 'database': 'orders'}
    
    tables = ['t_order', 't_order_detail', 't_payment', 't_user']
    for table in tables:
        p_count, p_checksum = table_checksum(**primary, table=table)
        s_count, s_checksum = table_checksum(**standby, table=table)
        
        if p_checksum != s_checksum:
            print(f"[ALERT] {table}: checksum mismatch! primary={p_checksum}, standby={s_checksum}")
            print(f"  primary count={p_count}, standby count={s_count}")
        else:
            print(f"[OK] {table}: checksum matched, count={p_count}")

这个脚本每小时跑一次,校验和不匹配时触发告警。真实生产中,我通过这个脚本发现了 3 次静默数据不一致——都是异步复制重放时遇到字符集问题导致的。

故障切换:从决策到执行的完整流程

切换不是一条命令

很多人以为容灾切换就是"把 DNS 从机房 A 切到机房 B",实际上切换是一套受控操作链,涉及数据库、缓存、消息队列、定时任务等多个组件。

完整切换流程:

阶段1:故障检测与决策(0-2分钟)
  → 告警触发,SRE 确认故障范围
  → 决策:是否需要切换?切到哪个机房?

阶段2:切换准备(2-8分钟)
  → 冻结主库写入(中间件拦截 + DNS 下线)
  → 等待备库日志追平(GTID 差值归零)
  → 校验关键表数据一致性
  → 通知上下游依赖系统

阶段3:流量切换(8-15分钟)
  → DNS 权重调整,先切 5% 流量验证
  → 验证通过后切全部流量
  → 更新服务注册中心,刷新连接池

阶段4:验证与观察(15-30分钟)
  → 监控核心指标:错误率、延迟、吞吐量
  → 人工验证核心业务流程
  → 确认稳定后通知业务方

DNS 切换的陷阱

DNS 是最常用的流量切换手段,但它有天然的延迟问题——客户端 DNS 缓存、浏览器 DNS 缓存、操作系统 DNS 缓存,每一层都会导致部分流量继续打到故障机房。

踩坑记录 3:DNS 缓存导致流量切不干净

一次切换中,DNS TTL 设置为 60 秒,但切换后 15 分钟仍有 5% 的流量打到故障机房。根因是部分 Java 服务的 JVM 缓存了 DNS 解析结果(默认缓存 30 秒,但有些框架会永久缓存)。更坑的是,某些移动端 App 的 HTTP 客户端也会缓存 IP。

解决方案:

  1. DNS TTL 在切换前提前 24 小时降到 10 秒
  2. 应用层使用智能 DNS 客户端(如 dnsjava),定期刷新解析结果
  3. 在 LB 层面做健康检查,故障机房的 LB 直接拒绝连接,强制客户端重试到新机房
// Java 应用:解决 JVM DNS 缓存问题
// 在应用启动时设置 DNS 缓存 TTL 为 10 秒
import java.security.Security;

public class DnsCacheConfig {
    static {
        // 设置 JVM DNS 缓存 TTL(秒)
        Security.setProperty("networkaddress.cache.ttl", "10");
        Security.setProperty("networkaddress.cache.negative.ttl", "0");
    }
}

连接池不感知的问题

踩坑记录 4:数据库连接池不感知主从切换

切换后,应用的数据库连接池还连着旧主库的 IP。连接池发现连接断开后自动重连,但重连的还是旧 IP——因为连接池初始化时缓存了 IP 地址,不会重新做 DNS 解析。

解决方案:

  1. 使用支持动态刷新数据源的连接池(如 Druid 的 DynamicDataSource
  2. 切换时通过 API 通知应用刷新连接池
  3. 使用数据库代理(如 ProxySQL、ShardingSphere)做透明切换,应用无感知
# ProxySQL 配置:故障切换时自动更新后端
# proxysql.cnf
mysql_servers:
  - hostgroup_id: 1        # 写组
    hostname: "10.0.1.10"   # 主库
    port: 3306
    max_connections: 200
  - hostgroup_id: 2        # 读组
    hostname: "10.0.2.10"   # 备库
    port: 3306
    max_connections: 200

mysql_galera_hostgroups:
  writer_hostgroup: 1
  backup_writer_hostgroup: 2
  active_writer: "10.0.1.10"  # 当前主库
  # 当主库不可用时,ProxySQL 自动切换到 backup_writer

消息队列消费位移丢失

切换后 Kafka 消费者组可能连到新机房的 Kafka 集群,但 offset 信息存在旧集群的 ZooKeeper/KRaft 中。新集群不知道消费者上次消费到哪了——要么从头开始消费(重复),要么从最新位置开始消费(丢消息)。

解决方案:

  1. 消费者 offset 存在数据库而非 ZooKeeper 中,与数据同步链路绑定
  2. 消费者实现幂等处理——即使重复消费也不会产生错误结果
  3. 切换前记录当前 offset,切换后从记录位置继续消费
# Kafka 消费者 offset 持久化到数据库(切换后可恢复)
import json
from kafka import KafkaConsumer

consumer = KafkaConsumer(
    'order_events',
    bootstrap_servers='kafka-primary:9092',
    group_id='order-processor',
    enable_auto_commit=False  # 关闭自动提交,手动管理 offset
)

def save_offset_to_db(topic, partition, offset):
    """将 offset 持久化到数据库,切换后可恢复"""
    cursor.execute(
        "INSERT INTO kafka_offset (topic, partition_id, consumer_group, offset_val, updated_at) "
        "VALUES (%s, %s, %s, %s, NOW()) "
        "ON DUPLICATE KEY UPDATE offset_val=VALUES(offset_val), updated_at=NOW()",
        (topic, partition, 'order-processor', offset)
    )

def load_offset_from_db(topic, consumer_group):
    """从数据库恢复 offset"""
    cursor.execute(
        "SELECT partition_id, offset_val FROM kafka_offset "
        "WHERE topic=%s AND consumer_group=%s", (topic, consumer_group)
    )
    return {row[0]: row[1] for row in cursor.fetchall()}

# 消费时手动保存 offset
for message in consumer:
    process_message(message.value)
    save_offset_to_db(message.topic, message.partition, message.offset)
    consumer.commit()  # 同时提交到 Kafka,双保险

切换自动化:从手动到自愈

上面描述的切换流程涉及多个步骤和组件,完全依赖人工操作太慢。RTO<30min 的目标下,切换必须自动化。

但完全自动化也有风险——如果故障检测误判,自动切换反而制造了不必要的停机。我的方案是"半自动":系统自动检测故障并执行切换准备(冻结写入、数据校验),但最终切换决策需要人工确认。

# 切换编排配置(Ansible Playbook 片段)
---
- name: 容灾切换编排
  hosts: localhost
  vars:
    primary_dc: "dc-a"
    standby_dc: "dc-b"
    switch_reason: "{{ switch_reason }}"

  tasks:
    # 阶段1:故障确认
    - name: 检查主机房健康状态
      uri:
        url: "http://{{ primary_dc }}-health:8080/health"
        method: GET
        return_content: yes
      register: health_check
      failed_when: health_check.status != 200

    - name: 确认需要切换(需人工确认)
      pause:
        prompt: "主机房健康检查失败,确认切换到 {{ standby_dc }}?(yes/no)"

    # 阶段2:切换准备
    - name: 冻结主机房写入
      uri:
        url: "http://{{ primary_dc }}-proxy:8080/maintenance"
        method: POST
        body: '{"action": "freeze_writes"}'

    - name: 等待备库追平日志
      shell: "mysql -h {{ standby_dc }}-db -u monitor -e 'SHOW SLAVE STATUS\\G'"
      register: slave_status
      retries: 30
      delay: 10
      until: "'Seconds_Behind_Master: 0' in slave_status.stdout"

    - name: 校验关键表数据一致性
      shell: "python3 /opt/dr/checksum_verify.py --primary {{ primary_dc }}-db --standby {{ standby_dc }}-db"
      register: checksum_result
      failed_when: "'MISMATCH' in checksum_result.stdout"

    # 阶段3:流量切换
    - name: 切换 DNS(先切5%验证)
      shell: "python3 /opt/dr/dns_switch.py --to {{ standby_dc }} --weight 5"

    - name: 验证5%流量正常
      uri:
        url: "http://{{ standby_dc }}-monitor:9090/api/v1/query?query=up"
      register: monitor_check
      retries: 6
      delay: 10
      until: monitor_check.status == 200

    - name: 全量切换DNS
      shell: "python3 /opt/dr/dns_switch.py --to {{ standby_dc }} --weight 100"

    # 阶段4:通知
    - name: 发送切换完成通知
      uri:
        url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={{ webhook_key }}"
        method: POST
        body_format: json
        body:
          msgtype: text
          text:
            content: "容灾切换完成: {{ primary_dc }} → {{ standby_dc }}, 原因: {{ switch_reason }}"

容灾演练:为什么"测过"才算"有"

演练不是走流程

容灾演练最大的误区是"走流程"——按预定的步骤操作一遍,确认能切过去就行。真正的演练应该模拟真实的故障场景,包括预期外的故障和操作失误。

Google SRE Book 中提到一个原则:“Hope is not a strategy”(希望不是策略)。容灾方案写了再漂亮,没实测过就是纸上谈兵。

演练分级

级别场景频率参与方
L1计划内切换,流程演练每月SRE
L2模拟单机房故障,自动切换每季度SRE + 业务方
L3模拟城市级灾难,异地切换每半年全公司
L4混沌工程注入,无预告故障每季度SRE

L4 级别的混沌工程注入最有价值——在不通知任何人的情况下注入故障,观察系统是否能自动检测和切换。(相关文章:系统韧性工程:从被动救火到主动防御的架构演进

演练中的常见问题

在我组织的 20+ 次容灾演练中,最常见的问题排序:

  1. 连接池不刷新(出现率 80%):上面已详述
  2. DNS 缓存(出现率 60%):上面已详述
  3. 定时任务双跑(出现率 40%):上面已详述
  4. 缓存数据不一致(出现率 35%):切换后 Redis 缓存为空,大量请求穿透到数据库
  5. 消息队列消费位移丢失(出现率 30%):切换后消费者组 offset 不对,导致重复消费或漏消费

第 4 个问题特别值得说。同城双活架构下,两个机房各有 Redis 集群。正常情况下通过消息队列同步缓存更新。但切换后,新机房的 Redis 缓存可能是空的——因为消息队列同步有延迟,最近几秒的缓存更新还没同步过来。

解决方案:切换前先预热新机房的缓存。从数据库加载热点数据到 Redis,预热完成后再切流量。预热脚本:

#!/bin/bash
# 缓存预热脚本:从数据库加载热点数据到新机房 Redis
# 在切换前执行

REDIS_NEW="10.0.2.20:6379"
MYSQL_HOST="10.0.2.10"

echo "[$(date)] 开始缓存预热..."

# 预热用户信息缓存(Top 10000 活跃用户)
mysql -h ${MYSQL_HOST} -u cache_loader -p${DB_PASS} -e "
  SELECT CONCAT('user:', user_id) AS key, 
         JSON_OBJECT('id', user_id, 'name', nickname, 'level', level) AS value
  FROM t_user 
  WHERE last_login_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
  ORDER BY last_login_at DESC 
  LIMIT 10000
" --batch --raw | while IFS=$'\t' read -r key value; do
    redis-cli -h ${REDIS_NEW} SET "${key}" "${value}" EX 3600
done

echo "[$(date)] 用户缓存预热完成"

# 预热商品信息缓存
mysql -h ${MYSQL_HOST} -u cache_loader -p${DB_PASS} -e "
  SELECT CONCAT('product:', product_id), product_name, price, stock
  FROM t_product WHERE status=1 LIMIT 5000
" --batch --raw | while IFS=$'\t' read -r key name price stock; do
    redis-cli -h ${REDIS_NEW} HSET "${key}" name "${name}" price "${price}" stock "${stock}"
    redis-cli -h ${REDIS_NEW} EXPIRE "${key}" 1800
done

echo "[$(date)] 商品缓存预热完成"
echo "[$(date)] 缓存预热全部完成"

架构权衡:什么场景选什么方案

决策框架

容灾架构选型不是选"最好的",而是选"最匹配业务需求和成本约束的"。我的决策框架:

业务需求分析
  ├─ 数据丢失容忍度(RPO 要求)
  │   ├─ RPO=0 → 同步复制(同城双活)
  │   ├─ RPO<1min → 半同步复制
  │   └─ RPO<30min → 异步复制
  ├─ 业务中断容忍度(RTO 要求)
  │   ├─ RTO<1min → 自动切换(双活)
  │   ├─ RTO<15min → 半自动切换
  │   └─ RTO<2h → 人工切换
  ├─ 合规要求
  │   ├─ 金融/医疗 → 两地三中心
  │   └─ 一般业务 → 同城双活或主备
  └─ 成本预算
      ├─ 充足 → 两地三中心 + 全同步
      ├─ 适中 → 同城双活 + 半同步
      └─ 紧张 → 主备 + 异步

方案对比

维度主备同城双活两地三中心
RPO秒-分钟级00(同城)/ 秒级(异地)
RTO15-30min秒级秒级(同城)/ 分钟级(异地)
资源利用率50%100%100%(同城)/ 10%(异地)
建设成本
运维复杂度
抗城市级灾难
适用规模小型中大型大型/金融级

我的实战建议

根据我在多个项目中的经验:

  1. 50 节点以下的小型集群:主备模式 + 异步复制。异地灾备的运维成本在小集群中占比过高(管理三中心的开销可能占整个运维团队 30% 的精力),不划算。

  2. 50-500 节点的中型集群:同城双活 + 半同步复制。这个规模下,同城双活的资源利用率和故障切换速度是最优的。异地灾备可选——如果预算允许,加一个异步复制的异地节点做兜底。

  3. 500 节点以上的大型集群:两地三中心。此时业务规模和合规要求都需要完整的两地三中心架构。重点关注同城双活的同步复制性能和异地灾备的切换流程自动化。

  4. 金融级系统:两地三中心 + 全同步复制 + Fencing 隔离 + 每季度混沌工程演练。这是合规要求,不是选择题。

关于云上容灾的选择:如果系统部署在云上,优先使用云厂商的托管容灾服务(如阿里云 DTS、AWS RDS Multi-AZ)。这些服务封装了同步复制、故障检测和自动切换的复杂度,比自己搭建更可靠。但托管不等于甩手掌柜——你仍然需要理解底层机制,在切换策略、监控告警、回滚方案上做配置。(相关文章:微服务限流熔断降级策略

成本模型:容灾不是免费的

容灾建设有一个被回避的事实:每提升一个 9 的可用性,成本翻 3-5 倍。从 99.9% 到 99.99%,不是多加一台服务器的事,而是整个架构层面的重新设计。

成本构成拆解

以一个中型电商系统(200 个微服务,50 个数据库实例,日均 QPS 8000)为例:

成本项主备模式同城双活两地三中心
机房租赁2 机房(主+备)2 机房(同城)3 机房(2同城+1异地)
服务器数量1.5x(备机房半量)2x(双机房全量)2.5x(异地半量)
专线带宽100Mbps1Gbps1Gbps + 200Mbps异地
数据同步软件开源(MySQL原生)半同步插件分布式数据库或DR工具
运维人力2 人4 人6 人
年度总成本(估算)80-120 万200-300 万400-600 万

同城双活比主备模式成本翻倍,但资源利用率也从 50% 提升到 100%。两地三中心比同城双活再翻一倍,但异地机房平时只承担读流量或热备,资源利用率只有 10%-20%。

关键决策点:异地灾备中心的资源利用率是成本效率的瓶颈。如果异地机房只做灾备不做生产,等于花了 2 倍的钱买了 1.1 倍的算力。

成本优化策略

  1. 异地机房做只读分析:异地机房不闲置,承担 BI 报表、数据分析等只读业务。既保持数据同步活跃,又产出业务价值。某电商平台用异地机房跑 T+1 报表,资源利用率从 15% 提到 60%。

  2. 云上按需灾备:异地灾备不建自有机房,使用云厂商的 DRaaS(灾难恢复即服务)。平时只付存储费,灾难时按需启动计算资源。适合中小规模团队。

  3. 分级容灾:P0 级系统两地三中心,P1 级系统同城双活,P2/P3 级系统主备或备份。不是所有系统都需要三中心,按业务重要性分级投入。

某出行项目的实测数据:从"全量两地三中心"改为"分级容灾"后,年度容灾成本从 520 万降到 280 万,降幅 46%。核心系统容灾能力不变,只是非核心系统降级到了主备模式。

监控与告警:容灾系统的可观测性

容灾系统本身也需要监控。很多人只监控业务系统,忘了监控容灾系统的健康状态——数据同步延迟、复制链路状态、备库数据一致性,这些指标一旦异常,说明容灾系统可能已经失效。

核心监控指标

指标告警阈值说明
复制延迟(秒)>30s 告警,>60s 严重主备数据差距
GTID 差异数>0 严重有未同步的事务
备库连接状态断开即告警复制链路是否正常
数据校验和不匹配任意不匹配即严重静默数据不一致
仲裁节点心跳3次失败即告警仲裁机制是否正常
切换脚本健康检查每日执行确保切换脚本可用

PromQL 监控示例

# MySQL 复制延迟监控
mysql_slave_status_seconds_behind_master{job="mysql-exporter"} > 30

# GTID 差异监控(自定义 exporter)
mysql_gtid_diff{job="mysql-exporter"} > 0

# 同步复制半同步状态
mysql_global_status_rpl_semi_sync_source_yes_tx{job="mysql-exporter"} == 0
# 半同步成功事务数为 0,说明降级为异步复制

# 仲裁节点心跳
probe_success{job="blackbox", instance="quorum-node:8080"} == 0

告警规则配置

# Prometheus 告警规则:容灾系统健康监控
groups:
  - name: disaster_recovery
    rules:
      - alert: MySQLReplicationLag
        expr: mysql_slave_status_seconds_behind_master > 30
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "MySQL 复制延迟过高: {{ $value }}秒"
          description: "{{ $labels.instance }} 复制延迟超过30秒,RPO 可能超出目标"

      - alert: MySQLReplicationBroken
        expr: mysql_slave_status_slave_io_running == 0 or mysql_slave_status_slave_sql_running == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "MySQL 复制链路中断"
          description: "{{ $labels.instance }} IO/SQL 线程停止,容灾系统可能失效"

      - alert: SemiSyncDegraded
        expr: mysql_global_status_rpl_semi_sync_source_yes_tx == 0 and mysql_global_status_rpl_semi_sync_source_no_tx > 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "半同步复制已降级为异步"
          description: "最近5分钟无半同步成功事务,可能已降级,RPO 不再为 0"

      - alert: DataChecksumMismatch
        expr: dr_data_checksum_mismatch > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "主备数据校验和不匹配"
          description: "{{ $labels.table }} 表数据不一致,需立即排查"

回切:比切换更危险的操作

故障切换到灾备机房后,业务恢复运行。但故障机房修复后,需要回切(Failback)。回切比切换更危险——因为此时灾备机房是主库,故障机房是备库,回切等于再做一次切换,但数据流向反了。

回切的风险

  1. 数据丢失风险:灾备机房作为主库运行期间产生的新数据,回切时需要同步回原主机房。如果同步不完整,回切后会丢失数据。
  2. 二次故障风险:回切过程中,系统处于切换中的脆弱状态,如果此时再出故障,可能导致两个机房都不可用。
  3. 业务感知风险:回切需要短暂中断写入,业务方需要提前沟通。

回切流程

阶段1:原主机房恢复(验证修复完成)
  → 确认原主机房基础设施正常
  → 建立反向复制链路(灾备→原主机房)
  → 等待数据同步追平

阶段2:回切准备
  → 在低峰期执行(通常凌晨2-4点)
  → 通知业务方即将回切
  → 冻结灾备机房写入

阶段3:执行回切
  → 确认原主机房数据已追平
  → 校验关键表数据一致性
  → 切换 DNS 和流量到原主机房
  → 验证业务正常

阶段4:恢复容灾配置
  → 重新建立正向复制链路(原主机房→灾备)
  → 更新监控和告警配置
  → 通知业务方回切完成

我的原则:回切必须有业务方签字确认,选择在低峰期执行,且回切前必须有完整的数据备份。宁可多花 1 小时验证,也不要在数据一致性上赌博。

总结

跨机房容灾架构设计,核心不是选最贵的方案,而是匹配业务需求、成本约束和团队能力。

几个实战经验总结:

RPO 和 RTO 是业务指标,不是技术指标。 按业务重要性分级设计容灾目标,不要一刀切。P0 级系统用同步复制,P2/P3 级系统用异步复制或定时备份就够了。

同步复制有性能代价。 全同步复制会让写延迟增加 200%-300%,必须做性能测试和容量规划。半同步复制是大多数场景的最优解——RPO≈0,性能损失可控。

脑裂防护用组合方案。 仲裁节点 + Fencing 隔离,单一方案都有失效场景。仲裁节点要部署在第三个独立位置,不能与生产机房共用物理网络。

异步复制要定期校验。 复制状态"正常"不等于数据一致。每小时校验关键表校验和,发现不一致立即排查。

故障切换是一套流程,不是一条命令。 涉及数据库、缓存、消息队列、定时任务、连接池、DNS 多个组件。每个组件的切换状态都要验证,任何一个没切好都会出问题。

容灾演练必须做,且要做真实的。 L4 级混沌工程注入最有价值——无预告故障注入,观察系统是否能自动检测和切换。测过才算有,没测过就是纸上谈兵。

回切比切换更危险。 必须在低峰期执行,必须业务方确认,必须有数据备份。宁慢勿错。

在我经手的项目中,跨机房容灾方案从设计到落地,最终达成 RPO<5min、RTO<30min 的指标。这个数字不极致,但它经过了 20+ 次真实演练验证,每次演练都在发现问题、优化流程。容灾不是一次性工程,是持续优化的过程。

参考资料与致谢

  1. Google SRE Book - Disaster Recovery — Google SRE 团队,灾难恢复与业务连续性的工程方法论
  2. Oracle 灾难恢复介绍 — Oracle 官方文档,RPO/RTO 定义与 DR 架构设计参考
  3. Azure Well-Architected Framework - Architecture strategies for disaster recovery — Microsoft Azure,多区域灾备架构策略
  4. 两地三中心容灾深度拆解 — 腾讯云开发者社区,同步异步复制机制与主流数据库方案对比
  5. 同城双活与异地多活架构设计 — 百度天池社区,双机房协同与 RPC 本地化调用优化
  6. etcd 脑裂故障复盘 — K8s 生产环境 etcd 网络分区导致 Raft 多数派失效的真实复盘
  7. 容灾架构中的脑裂问题详解 — 脑裂成因、检测机制与防护方案