数据库异地容灾经常被简化成把主库拖到另一个城市做从库,但真正发生机房级故障时,复制链路、切换决策、应用连接、数据补偿四个环节只要有一个没想清楚,容灾就可能变成二次事故。同机房主从切换通常依赖心跳和VIP漂移,跨机房后网络抖动会放大误判概率,数据同步也会因为几十毫秒的延迟产生新的不一致窗口。

一、跨地域复制链路:异步、半同步与存储复制的取舍
跨机房网络RTT通常在20毫秒到60毫秒之间,这个延迟对数据库事务提交的影响非常大。异步复制是最轻量的方案,主库提交事务后不会等待异地从库确认,只将Binlog通过网络持续发送。它的吞吐量几乎不受地理距离影响,但一旦主库机房整体断电,尚未传输到异地的Binlog就是永久丢失的数据。很多金融和订单业务不能接受这种不确定的RPO,但又不想被同步延迟拖垮写入性能,半同步复制于是成为一种折中。
MySQL半同步复制的逻辑是,主库事务提交前必须等待至少一个从库确认收到Binlog,确认动作可以是写入从库的relay log后返回,不要求从库完成回放。这样主库的写入延迟会增加一个网络往返时间,但能保证已提交事务至少有一份异地持久化副本。需要注意的是,半同步有一个超时退化机制,如果等待确认超过参数设定时间,主库会自动降级为异步复制,避免单个从库故障阻塞整个写链路。这个退化点必须在容灾设计里被监控,否则团队以为数据在异地实时保护,实际上链路早已悄悄变成异步。
-- 主库启用半同步复制 INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so'; SET GLOBAL rpl_semi_sync_master_enabled = 1; SET GLOBAL rpl_semi_sync_master_timeout = 10000; -- 从库启用半同步复制 INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so'; SET GLOBAL rpl_semi_sync_slave_enabled = 1; -- 查看半同步状态,确认没有被退化为异步 SHOW STATUS LIKE 'Rpl_semi_sync_master_status';
存储层复制是另一种思路,通过底层磁盘阵列或分布式存储把数据块同步到异地。它对数据库实例透明,不需要配置主从关系,也不挑数据库类型。但块级复制不知道事务边界,可能把主库存储损坏的坏块一起复制过去,而且无法做逻辑层的数据过滤和回放控制。对于需要跨版本升级、跨云厂商迁移,或者只保护核心表而不是整库的场景,基于Binlog的逻辑复制更灵活。实际选型时可以把半同步复制作为主链路,存储层复制作为兜底,但两者共存时要明确哪一个才是切换依据,避免两套数据不一致时无法决策。
二、切换决策与防脑裂:异地仲裁不是多一个节点那么简单
异地容灾最危险的时刻不是主库宕机,而是主库机房和备机房之间的网络发生分区。此时主库可能仍然活着并继续接受业务写入,备机房却检测不到主库心跳。如果备库立即提升为新主库,两个机房同时对外提供写服务,就会产生脑裂。脑裂带来的数据冲突通常比一次干净的停机更难修复,因为需要从两份已经分叉的数据里人工裁决策略。
要避免脑裂,切换决策不能只依赖备库自己的探测结果。常见做法是在第三个可用区放置仲裁节点,主库和备库都必须定期向仲裁节点续租,谁持有有效租约谁才具备写入资格。主库如果失去与仲裁节点的连接,即使还能响应客户端,也必须主动停止写入。备库在提升前等待主库租约过期,再通过加锁或选主流程获取新的主库角色。这个机制可以用etcd、ZooKeeper或云厂商的分布式锁实现。
# 创建15秒租约 etcdctl lease grant 15 # 假设返回 lease 为 694d8c2a3e4f5 # 主库用租约写入角色标识 etcdctl put --lease=694d8c2a3e4f5 /db/mysql/primary 10.1.2.3 # 备库提升前确认旧角色是否仍有效 etcdctl get /db/mysql/primary
租约时间设置有讲究。太短,网络抖动会导致主库频繁失去写入资格;太长,机房故障后的恢复时间被拉长。一般建议租约TTL设置为跨机房网络超时的三倍以上,同时配合数据库只读锁使用。比如TTL为15秒,备库在连续两次租约检查都失败后再提升,可以有效降低误切概率。另一个容易被忽略的点是应用连接切换,数据库切换完成后,如果应用仍使用旧主库的连接串或连接池里的长连接,写入还是会打到故障机房。因此切换流程要把DNS、配置中心或服务发现的更新纳入自动化,并用连接池的强制刷新把旧连接清空。
三、GTID与数据校验:旧主库恢复后怎么安全接回
故障切换后的数据修复往往比切换本身更耗时。比如异地备库提升为新主库后,原主库机房恢复供电,旧主库重新上线。如果直接把旧主库作为从库指向新主库,可能出现两类问题:一是旧主库在故障前还有部分事务没有传到异地,二是旧主库在网络分区期间可能又接收了少量写入。重复执行和反向覆盖都会导致数据错乱。
MySQL的GTID给每个事务打上全局唯一标识,从库可以通过集合对比找出自己缺失或多出的事务。开启GTID后,复制可以自动定位断点,不需要手动维护Binlog文件名和位置。旧主库挂回时,先比较自己的gtid_executed和新主库的差异,再决定是丢弃多余事务还是导出补偿。对于核心业务表,最好在挂回前用pt-table-checksum做一次逻辑校验,确认旧主库没有隐藏的不一致。
-- 在主库开启GTID SET GLOBAL gtid_mode = ON_PERMISSIVE; SET GLOBAL gtid_mode = ON; SET GLOBAL enforce_gtid_consistency = ON; -- 查看已执行事务集合 SELECT @@GLOBAL.gtid_executed; -- 旧主库重新接入新主库 CHANGE REPLICATION SOURCE TO SOURCE_HOST='new-primary', SOURCE_USER='repl', SOURCE_PASSWORD='repl_pass', SOURCE_AUTO_POSITION=1;
如果校验发现某张表有差异,不能简单地全表覆盖,因为新主库可能在切换后已经产生了新的业务数据。可以按主键范围或时间段进行增量同步,把旧主库多出的事务导出成SQL语句人工审核,再把新主库缺失的部分补上。回切流程同理,先让新主库进入只读状态,等待旧主库追平GTID后再把写入角色切回去。这个过程最好写成脚本并经过演练,而不是等线上事故时手动敲命令。
四、容灾演练与RPO/RTO:没有验证过的方案都是纸面方案
很多团队做了异地备份就以为具备了容灾能力,但从未完整演练过切换。等到真实机房故障发生时,要么发现复制链路早已中断,要么发现切换脚本里有一半命令过时。RPO和RTO这两个指标不是设计时拍脑袋写出来的,而是通过演练不断修正出来的。半同步复制配合异地从库,RPO通常可以控制在秒级;异步复制则可能从几秒到几分钟不等,取决于网络带宽和从库延迟。RTO除了数据库切换时间,还要加上应用连接刷新、DNS生效、核心接口验证的时间。
演练方式要尽可能还原真实故障。直接执行正常停库再手动切换,练不出网络分区下的仲裁和防脑裂逻辑。更好的做法是在测试环境注入网络延迟和丢包,或者直接断开主库机房的出口路由,观察自动切换系统能否在预期时间内完成角色迁移。演练后还要检查数据一致性,确认切换期间没有产生丢失或重复写入。
# 在测试环境中模拟机房网络分区 iptables -A INPUT -s 10.1.2.0/24 -j DROP iptables -A OUTPUT -d 10.1.2.0/24 -j DROP # 演练结束后恢复网络 iptables -D INPUT -s 10.1.2.0/24 -j DROP iptables -D OUTPUT -d 10.1.2.0/24 -j DROP
数据库异地容灾最终考验的不是某一条复制命令,而是整个团队对故障场景的理解和自动化程度。复制链路决定数据能丢多少,仲裁机制决定切换会不会造成脑裂,GTID和校验决定恢复后数据能不能对齐,而演练则决定这些设计在真实现网中是否仍然有效。与其追求复杂的异地多活,不如先把单写多读的异地容灾做扎实,把检测、切换、补偿、回切四个环节全部自动化,再考虑向双活演进。