最近手头一套 MySQL 8.0 主从复制环境让我有点上火,主库半夜磁盘写满直接卡死,备库那边数据倒是完整的,可就是没有一个机制能顶上,硬生生等到第二天早上业务方打电话过来才发现。那次事故之后我花了两天时间整理了一套轻量级的主从自动切换脚本,在测试环境跑了差不多一周才敢上生产,期间也踩了几个 MySQL 8.0 特有的坑。这篇就把整个设计思路和坑点摊开来说清楚。
先说一下我这次的适用场景:一主两从的经典 MySQL 复制架构,业务高峰期写流量大概每秒 1400 左右事务,单事务量不大,整体压力在可接受范围内,没有上 MGR、也没有引入 Orchestrator 这类重量级高可用组件。备库主要承担读流量和凌晨的备份任务,对 RPO 的要求是不能丢最近几十秒的事务,RTO 目标定在 1 分钟以内。如果你所在的环境也是这种“比测试环境正式、又没到必须上 MGR 或者 PaaS 平台级别”的状态,可以参考我的处理方式。
1. 为什么还需要自己写主从自动切换脚本
聊自动切换,不少人第一反应是 MySQL 官方有 InnoDB Cluster,社区有 MHA、Orchestrator,为什么还要自己写脚本?我的理由很现实:这些工具在某些环境下反而显得过度设计。
1.1 现有高可用方案与轻量脚本之间的取舍
InnoDB Cluster + MySQL Router 确实可以做到自动切换,但它要求组复制模式,多节点之间需要维护 Paxos 协议,至少三个数据节点才能获得比较好的容错效果,网络分区时对延迟和丢包也比较敏感。一个单机房、三台数据库实例的基础架构,为了一个“自动切换”功能把整个架构升级成组复制,代价并不小。
MHA 是历史遗留环境中很常见的方案,但它本身在 MySQL 8.0 时代的问题不少,比如核心的 masterha_manager 进程对 8.0 的 GTID 模式支持不够优雅,处理半同步复制时也会遇到奇怪的状态残留。另外 MHA 本身已经好几年没有大规模活跃维护,在 8.0 默认 caching_sha2_password 插件下还要额外做兼容配置。配置 MHA 的成本和写一个专用脚本其实已经差不了太多了。
Orchestrator 是功能很强的高可用管理平台,界面、API 都成熟,但它是部署在 MySQL 之外的一整套服务。如果你公司配置管理规范不允许额外跑一个 Java 应用来做拓扑管理,或者团队没有精力长期维护这套平台,那它反而比脚本更累赘。
所以我的判断标准就两条:第一,这套方案是否必须长期有人维护;第二,我的拓扑规模和数据丢失容忍度是否需要那么复杂的探活协议。如果只需要在传统一主多从复制架构里加一个“主库故障后自动从备库中选一个顶上”的能力,自己写脚本是可行且可控的。
1.2 这套脚本适合谁使用
如果你的业务具备以下特点,这套方案会比较适合你:
- 主从关系沿用传统异步复制,但因业务接受“极端故障下丢最后几秒事务”,对 RPO 没有零丢失级要求
- 主从节点部署在同一个机房或延迟极低的内网环境中,网络抖动可控
- 数据库端口不会频繁变更,实例之间可通 SSH
- 有一个独立的跳板机或监控节点,可以执行 MySQL 客户端定时检查主库状态
- 团队希望保留“手动介入”的空间,切换后必须有人确认收尾
如果你的部署已经全部容器化,而且 Kubernetes 里有现成的 MySQL Operator,那当然没必要再看脚本这类方案。但如果你仍然维护着裸机/虚机上的 MySQL 8.0 复制架构,又没有现成高可用组件,那么这份脚本的思路可以直接抄。
1.3 写这套脚本之前我理清的几个边界
一开始我想把脚本写得特别“全”,比如自动处理脑裂、自动把旧主重新加回复制拓扑,这些功能从逻辑上是连贯的。但后来我意识到,安全的故障转移中最大的风险其实不是“切不过去”,而是“切出问题以后没人知道”。因此我的脚本设计有几个明确边界:
- 老主库恢复后不自动加回拓扑,一律人工确认数据与角色后再处理
- 对备库做数据完整性对比以后才允许提升,不允许无条件提升
- 切换动作完成后必须向值班群发送通知记录,包括具体切换的时间、候选节点、GTID 差异大小
- 脚本只承担数据库层切换,不负责改应用连接地址,所以环境必须有 VIP 或代理层配合
明确边界以后,脚本本身的复杂性降下来一大截。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL 8.0 复制架构里对自动切换有直接影响的前置条件
很多老工程师以前写过一主一从的 failover 脚本,可能在 5.7 上跑得挺好,直接搬到 MySQL 8.0 就会遇到参数和认证机制带来的问题。这里把影响自动切换的几个前置项单独列出来讲。
2.1 必须落实到位的 GTID 参数组合
没有 GTID 的情况下做自动切换,最大的痛点是很难自动判断某台备库和旧主之间差了多少事务。传统位点复制需要拿 File 和 Position 做对比,而不同备库可能从不同的 binlog 文件开始同步,人工比较都费劲,更别提脚本判断。
所以我在测试环境统一开启:
ini复制gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
binlog_format=ROW
前两项是 GTID 能安全开启的基础,log_slave_updates=ON 也是关键。它在 MySQL 8.0 中默认是开启的,但如果是从老版本迁移上来出现过手动关闭的配置残留,备库提升为新主以后,其他备库连接过来时会导致 GTID 断档,因为备库在转发事务时没有真实记录到自己 binlog 中。
2.2 MySQL 8.0 对复制账号权限的收紧
MySQL 5.7 时代给复制账号授权通常这样写:
sql复制GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'%';
MySQL 8.0 里官方把这条权限拆成了两个更细粒度的权限,分别是 REPLICATION_SLAVE_ADMIN 和 REPLICATION_CLIENT_ADMIN。直接用老语法也能在 8.0 里通过,但会看到 deprecated 警告,而且某些运维管理操作在最小权限原则下会受限。例如我脚本里有一处需要远程在备库上执行 STOP REPLICA,如果复制账号配置的是 8.0 的 REPLICATION_SLAVE_ADMIN,就没有任何问题。
授权建议这样写:
sql复制CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';
GRANT REPLICATION SLAVE, REPLICATION CLIENT, BACKUP_ADMIN ON *.* TO 'repl'@'%';
除非你的客户端强制走 SSL,否则 8.0 默认的 caching_sha2_password 复制账号在自动切换后通过 CHANGE MASTER TO 连接新主时,第一次握手需要 RSA 公钥交换,很容易报 Authentication plugin 'caching_sha2_password' cannot be loaded 或者 Public Key Retrieval is not allowed。配合 mysql_native_password 能省去很多麻烦。
2.3 半同步复制是否要开
脚本本身并不能解决主库宕机瞬间已提交事务还没传到备库的问题。要压缩数据丢失窗口,数据库层面最直接的方法是启用半同步复制。MySQL 8.0.22 之后的参数改动比较明显,插件还是那一个,但主从两侧的参数名从 rpl_semi_sync_master 换成了新的 rpl_semi_sync_source 体系,如果使用旧的 master/slave 名称,会有兼容层,但还是建议跟上 8.0 的新命名。
半同步复制对自动切换脚本的意义在于:主库提交事务时需要至少一个备库返回 ACK。如果主库在事务 ACK 之后宕机,那么至少有一台备库有数据,脚本在提升前只需要判断哪台备库的 GTID 集合最接近主库就可以。半同步开启状态下,脚本切换后丢失数据的概率大幅降低,但也要知道它存在降级策略:如果等待 ACK 超时,主库会自动退化为异步复制,避免整个数据库被拖死。所以脚本在选主时并不能假设半同步一定起了作用,还是要看 GTID 集合比对结果。
2.4 对外连接方式决定了脚本的落地形态
这是很多复制架构自动切换文章里容易被忽略的一块。MySQL 的一主多从复制架构,对外暴露的写地址通常只指向当前主库的 IP,备库 IP 都是只读。脚本能够让备库角色变成可读写,但它没有办法改变应用代码里已经写死的主库 IP。于是脚本需要和至少其中一种方式配合:
| 对外连接方式 | 自动切换脚本落点 | 注意事项 |
|---|---|---|
| VIP 漂移 | 脚本提升新主后在新主上绑定 VIP,并尝试让旧主释放 VIP | VIP 释放依赖 SSH 可达,若旧主完全失联则 ARP 缓存会延迟切换效果 |
| 域名/负载均衡器 | 脚本切换成功后调 API 更新解析或 LB 后端 | 需要额外的 API 凭据与超时控制 |
| 应用层配置中心 | 脚本通知配置中心剔除不可用节点 | 对应用框架有要求,一般不在纯 DBA 控制范围内 |
我个人用的方案是 keepalived 管理的 VIP。正常情况下 VIP 落在旧主 eth0 上,脚本检测主库故障后执行切换,先把新主 MySQL 提升为可写,再调用 keepalived 的配置文件切换让 VIP 平滑漂移。考虑到 keepalived 本身也可能出问题,我只把 keepalived 当作流量入口切换的最后一跳,核心的数据节点选择由脚本控制。
3. 故障判定与防脑裂:这套脚本最核心的一层
主从自动切换能不能安全落地,三分靠提升逻辑,七分靠故障判定和防脑裂设计。很多初版脚本不敢上生产,不是因为切换动作做不对,而是不能在“主库真宕机”和“主库还活着但网络抖动”之间做出正确判断。
3.1 探活策略不能只依赖单次连接失败
我见过最简单粗暴的探活方式是在监控机器上 mysqladmin ping 一次失败就触发切换。这种方案在测试环境会很灵敏,但真实世界中主库负载高把连接池耗尽是非常常见的事。此时 MySQL 进程本身是活的,只是不能响应新连接,如果因为探活误判就切走,会导致原本一个可以很快恢复的业务中断升级为一次与数据丢失风险相伴的故障转移。
所以我为脚本设计了三段式探活策略:
- TCP 端口连通性检查
- MySQL 协议层执行
SELECT 1,确认实例能够正常响应 SQL - 检查
SHOW REPLICA STATUS中主库角色相关状态,确保节点当前不是只读备库
整个过程连续失败 3 次才进入故障确认阶段,每次间隔 5 秒,也就是至少 15 秒持续异常才会触发后续动作。在 MySQL 进程假死、磁盘 IO hang、负载导致无法接受新连接这三类场景下,SELECT 1 也会超时,所以这套探活能真实反映数据库可用性而不是机器存活状态。
3.2 仲裁机制:避免多台备库同时抢主
假如两台备库同时发现主库失联,并且都认为“自己应该变成新主”,那就出现了两台备库同时打开写权限的最坏情况。MGR 靠 Paxos 投票避免了这个问题,我这样的脚本就得靠文件锁和角色标签避免。
我的设计是在一台单独监控主机(和数据库节点不同机)上放一个状态目录,定时执行检测的进程先尝试在该目录创建 failover.lock,只有拿到锁的检测进程才有资格发起后续切换。备库本身不主动探测主库,只被动等待监控主机指令。
考虑到如果监控主机也挂了,自动切换就无法触发,我额外加了告警。这套架构并不追求监控主机自身的高可用,我更希望它的逻辑保持简单,哪怕故障切换变成人工介入,也不引入分布式选主一致性那套复杂度。
3.3 旧主脑裂防护:切换到新主之前先确认旧主退出
如果旧主没有真正宕机,只是网络分区导致它与备库、监控节点无法互通,那么旧主的 MySQL 线程依然可能接受应用写入。此时如果把某个备库提升为新主并打开写权限,就会出现两个主库同时写入的脑裂场景。
为了防止脑裂,我的脚本不直接在新主上打开写权限,而是先执行这样一套前置动作:
- 通过 SSH 登入旧主,尝试执行
systemctl stop mysql,把 MySQL 进程优雅关闭 - 如果 SSH 无法连通或者 MySQL 无法停止,就利用脚本从监控主机上预先配置的 iptables 规则,在旧主网络层 drop 掉对 3306 端口的访问
- 如果上述动作均失败,则默认策略是保守回退,只发出“疑似主库故障但无法隔离,请人工介入”的告警,不执行后续提升
实际运行中,第 1 步就成功的情况几乎没有,因为主库如果已经卡死,SSH 虽然能通但 systemctl stop mysql 可能会卡在服务停止过程;更常见的是第 2 步生效。如果是物理断电或机器彻底失联,SSH 和 iptables 都不可用,但反过来这种场景下其实没有脑裂风险,因为旧主已经无法对外提供写服务了。真正棘手的就是网络分区:旧主还在写,但新主已看不到它。所以第 3 步的保守回退是安全底线。
3.4 故障判定参数:设置了不可切换的阈值
我在配置里增加了 min_agree_replicas 和 max_allowed_lag_seconds 两个参数。它们分别是参与选举的最低备库数量和备库相对主库的最大延迟秒数。假设只有一台备库可用而它的延迟已经超过 30 秒,哪怕主库挂了也不会切换,因为切换以后数据丢失会超过业务容忍范围。
故障判定中延迟数据不是直接靠 SHOW REPLICA STATUS 里的 Seconds_Behind_Source,因为这个值在网络断开后常常不准,而是通过比较备库 gtid_executed 和主库 binlog 中记录的最新 GTID 集合来计算差额。只要差额里包含的事务时间戳跨度超过了阈值,就拒绝切换。
4. 核心脚本代码拆解:从停止备库到提升新主
弄完原理以后,直接看实际执行的代码逻辑。我用的脚本主体是 Python 3,但主流程中的核心动作全部封装成了 MySQL 原生命令调用,方便移植。下面按执行顺序拆解最核心的那个阶段。
4.1 第一阶段:对所有备库停止复制拉取
主库故障后,备库可能还在尝试从主库拉取 binlog,不停重试会浪费资源并掩盖故障状态。因此切换开始的第一时间,我通过 MySQL 客户端对所有备库执行:
sql复制STOP REPLICA IO_THREAD;
注意不是直接 STOP REPLICA。STOP REPLICA 会同时停掉 IO 线程和 SQL 线程,而 STOP REPLICA IO_THREAD 只停止从主库拉取 binlog 的动作,SQL 线程仍然会把已经拉到本地 relay log 里的事务继续应用。这是提升备库之前尽可能压缩数据差距的关键一步。
接着用一个循环检查备库的 SQL 线程状态,等待 relay log 中残余的事务全部应用完:
sql复制SHOW REPLICA STATUS\G
只要看到 Slave_SQL_Running_State 不再是 Slave has read all relay log; waiting for more updates,就说明还有 relay log 没应用完,继续等待。结合特定环境我给 SQL 线程排空设置了最长 20 秒等待时间,超过时间就直接取消本次自动切换,避免切换过程中卡死。
4.2 第二阶段:对比 GTID 集合选出候选新主
relay log 排空后,逐个登录备库执行:
sql复制SELECT @@global.gtid_executed;
在 GTID 模式下,这串 GTID 集合就是该实例已经执行过所有事务的唯一标识。我可以用 MySQL 自带的 GTID_SUBTRACT 函数比较两个 GTID 集合的差,找出“谁的数据更接近旧主”。比如用当前候选主库的集合去减其他备库的集合,结果为空的说明该备库没有缺失任何事务,数据完整性最好。
实际选主逻辑里有一个优先级:
- 各备库中
gtid_executed包含旧主最新事务的实例优先 - 若多个备库数据一致,则按配置的候选顺序选择,避免任意性
- 如果所有备库数据都不完整,选择数据差距最小的一台,但在日志里注明“切换存在数据丢失”
选出候选主之后,把它的主机名写入状态文件,后续的 VIP 漂移和通知都以这个状态文件为准。
4.3 第三阶段:执行提升并重建新主上的只读开关
新主提升前它是从库角色,配置文件里一般带了:
ini复制read_only=ON
super_read_only=ON
所以提升成主库时必须关闭这两个只读开关:
sql复制SET GLOBAL read_only=OFF;
SET GLOBAL super_read_only=OFF;
顺序上必须先把 super_read_only 关掉再关 read_only。如果反过来,super_read_only=ON 会阻止任何非复制线程写入,那么关闭 read_only 后 DBA 手工执行写入依然会被拒绝,应用连接也一样。你以前在自动切换后遇到过“数据库看着已经可写了但应用一直报只读错误”的诡异问题,十有八九就是 super_read_only 没处理干净。
关闭只读开关后,执行一个简单的写测试确认新主确实可写,比如:
sql复制CREATE DATABASE IF NOT EXISTS failover_probe;
DROP DATABASE failover_probe;
注意需要先把一条包含唯一标识的记录写入,随后由后台巡检任务删除。测试失败则回滚本次提升,并恢复新主为只读状态。
4.4 第四阶段:把其他备库的复制源切到新主
老主故障后,其他备库复制通道仍然指向老主,不处理的话会一直重试连接。等新主提升成功且可写之后,对所有备库执行一次重新指向:
sql复制STOP REPLICA;
CHANGE MASTER TO MASTER_HOST='新主IP', MASTER_AUTO_POSITION=1;
START REPLICA;
MASTER_AUTO_POSITION=1 是 8.0 GTID 模式下的推荐写法。备库和新主会自动协商从哪个 GTID 位置开始补数据,不需要手动指定日志文件与偏移量。执行完以后我习惯再等 2 秒,执行 SHOW REPLICA STATUS 检查 Slave_IO_Running 和 Slave_SQL_Running 是否都为 Yes,如果 IO 线程为 No,多半是复制账号或网络连通性在新主上出了问题。
4.5 阶段,将所有脚本流程串起来的伪码
下面是整个自动切换主流程的 Python 伪码,实际应用时你可以把其中 MySQL 命令替换成对应的数据库驱动,或直接用 pymysql 执行:
python复制def auto_failover():
lock = acquire_lock("failover.lock")
if not lock:
return
if not confirm_primary_down():
release_lock()
notify("主库探活失败但未确认故障,不做切换")
return
if not isolate_old_primary():
release_lock()
notify("无法隔离旧主,请立即人工介入")
return
replicas = get_replica_info()
if len(replicas) < MIN_AGREE_REPLICAS:
release_lock()
notify("存活备库数量不足")
return
stop_replica_io_on_all(replicas)
drain_relay_log_on_all(replicas)
candidate = choose_new_primary_by_gtid(replicas)
if not candidate:
release_lock()
notify("没有可用候选新主,切换终止")
return
promote_replica(candidate["host"])
repoint_other_replicas([r for r in replicas if r != candidate], candidate)
release_lock()
notify("自动切换完成,新主是 %s" % candidate["host"])
把锁的释放放在整个流程最后很重要,不然中间任何一步异常退出后,后续没有检测进程可以重新获得锁,系统会进入无人值守的“假死”状态。我在 finally 块中释放锁并写入切换现场日志,避免这个坑。
5. 演练清单:我在测试环境里用哪些场景验证这个脚本靠谱
脚本写出来以后先别急着接生产,我在测试环境搭了一套和线上几乎一样的复制架构,然后按下面这些故障场景做演练。每一个场景都在脚本和验证逻辑上暴露过问题。
5.1 正常停库场景
第一轮演练最简单,人为在主库上执行 systemctl stop mysql,模拟意外停库。这一步跑下来把整个流程走通的时间也就是 10 秒左右,但由于没有经过网络异常、磁盘卡死这些暴力因素,很多隐藏问题看不出来,这轮只能算验证脚本执行路径完整。
5.2 断网场景
测试时我直接在虚拟化层把主库网卡断开,而不是在系统内禁用网卡,以模拟物理网络故障。这种场景下 SSH 到旧主大概率不可达,脚本会走到“隔离旧主失败”分支。我一开始把失败逻辑写成了直接跳过旧主隔离然后继续切换,结果遗留一个隐患:旧主网络恢复后,由于它上面的写入连接可能仍然存活,就会立刻和新主同时可写造成脑裂。
后来我改成了保守逻辑:SSH 隔离失败后继续尝试用 iptables 规则的 API 从虚拟化层强制断掉旧主 3306 端口的流量。如果虚拟化平台 API 也调不通,则直接终止切换,把现场留给人工处理。虽然这样做牺牲了自动化的完整性,但保证了不会出现双主状态。这是所有自动切换设计中最值得保守处理的部分。
5.3 主库磁盘满导致数据库僵死场景
磁盘满属于比较常见的故障类型。现象是 MySQL 进程还在,SELECT 1 却始终不能返回,因为写入任何 binlog 或 undo 日志都会因为磁盘满阻塞。这种故障与网络分区不同,SSH 到主库是可达的,系统服务也正常。脚本的探活连续失败后会进入切换流程,但旧主隔离这一步可以成功,后续 VIP 漂移也没有问题。
唯一要注意的是切换完成以后,老主磁盘满本身并不会消失,DBA 处理完磁盘空间并重新拉起 MySQL 时,如果直接启动老主,它依然会以普通只读模式运行,并且保留原来没有应用完的 relay log 文件。我的操作建议是等确认新主数据完整以后,直接把老主重新初始化成新主的从库,而不是尝试恢复它的旧身份。
5.4 备库延迟过大时能否可靠拒绝切换
我故意在备库上做了一个大事务的回放测试,让其中一台备库延迟超过 30 秒,然后触发主库故障。脚本检查备库 gtid_executed 与主库差异后,应该把延迟过大的备库排除在候选名单外。如果所有备库延迟都超过阈值,则中止切换并告警。
这里我踩过的坑是:主库故障瞬间,备库可能已经拿到了 binlog 但还没回放,此时如果只看 Seconds_Behind_Source,数值可能为 NULL 导致误判。因此最终选主逻辑依赖 GTID 差集与时间戳,而不是这个便利字段。
5.5 回切操作必须手动处理
自动切换搞定以后,总会有 DBA 问:“能不能让脚本自动把角色切回老主?”我的答案是不建议。自动切换是故障场景下的兜底行为,一定伴随失控状态或环境变化,如果无人确认数据一致性,自动回切几乎必然引发数据丢失。
我的做法是切换后脚本只负责把老主从“停止”状态重新拉起,并让它自动指向新主成为从库,后续是否升级回主库则完全由 DBA 在业务低峰期选择合适时间手动执行。真正想自动回切建议去用 MGR 这类具备一致性子系统的架构,而不是在传统复制上强行自动化。
6. 实测中经常踩的 MySQL 8.0 专属坑
脚本适配的细节很多,但对 8.0 版本而言有几个问题会高频出现,专门列一节。
6.1 caching_sha2_password 让复制通道建立失败
这是 8.0 最容易撞到的复制问题。如果从库指向新主时使用的复制账号是默认的 caching_sha2_password 插件,并且从库与新主之间的连接没有启用 SSL,那么第一条复制通道建立时 MySQL 客户端需要向服务器请求 RSA 公钥,如果客户端无法获取,就会报:
code复制ERROR 2061 (HY000): Authentication plugin 'caching_sha2_password' reported error: Authentication requires secure connection.
MySQL 8.0.4 之后的复制协议中,从库发起连接时可以执行:
sql复制CHANGE MASTER TO MASTER_HOST='新主IP', MASTER_AUTO_POSITION=1, MASTER_SSL=1;
这样可以让复制通道走 SSL,绕开 RSA 公钥交换问题。如果不想引入 SSL 证书体系,最省事的做法就是在初始化复制账号时直接使用 mysql_native_password,缺点是 8.0 后续版本已经将其标记为 deprecated,未来大版本可能会移除。我目前生产账号是双轨制:DBA 人工连接权限走默认插件,复制专用账号仍然保留 mysql_native_password,从切换稳定性出发是值得的。
6.2 新主提升后 relay log 可能残留旧主事务
传统异步复制中,备库从主库拉取的 binlog 写入本地 relay log 后,可能尚未完全回放。如果切换动作直接 STOP REPLICA 或者直接执行 RESET SLAVE,这些未回放的 relay log 会被丢弃,导致新主缺失一部分事务。我处理这个问题的思路在第 4.1 节已经提过:先停 IO 线程,让 SQL 线程继续工作直到 relay log 排空。
但在某些极端场景,比如旧主的 binlog 本身不完整或者网络中断时,备库会处于一个“已拉取部分事务但后续 binlog 不连续”的状态。此时即使等待 SQL 线程排空,也可能因中继日志尾部不完整而卡住。遇到这种情况脚本会在等待超时后触发告警,然后由人工确认这时的数据一致性。自动切换解决不了所有数据不一致问题,这一点最好在设计阶段就接受。
6.3 super_read_only 与 read_only 的优先级理解
有些 DBA 只知道 read_only=ON 就能让数据库拒绝非超级权限账号的写入,却忽略了 super_read_only 在 8.0 中已经能拦截几乎所有用户,包括 SUPER 权限账号的普通写入。当主库提升完成后,如果只关了 read_only,但 super_read_only 仍为 ON,应用账号因为通常没有 SUPER 权限,会被拦下,错误信息看起来像数据库处于只读状态,排查起来很容易被误导。
我在脚本里固定顺序执行两条 SQL:
sql复制SET GLOBAL super_read_only=OFF;
SET GLOBAL read_only=OFF;
并且在最后用前面提到的探活写测试去确认,这样能有效躲开这个坑。还有一个小细节:MySQL 8.0 重启后 read_only 参数值会重新读取配置文件,如果提升后的新主没有同步修改 my.cnf,那么下次 MySQL 重启后它又会变成只读状态。因此脚本会顺带把配置文件中对应项改为 read_only=OFF。
6.4 半同步复制降级状态如何影响切换判定
前面提过半同步复制可能因为等待 ACK 超时降级为异步,如果主库本身已经不可用,备库并不知道该状态。我实测过一个场景:主库半同步复制降级后很快宕机,候选备库上少了主库最后一段事务,但脚本仍能通过对比 GTID 集合并把该备库推举为新主,切换后后续从库继续补齐无问题,但那部分事务已经永久丢失。
如果业务对 RPO 要求很高,建议在主库落库和至少一个备库 ACK 之间加大监控,一旦半同步降级就要第一时间告警,或者配置 rpl_semi_sync_master_timeout 到较小值让事务立即失败,避免业务继续写入但复制已经退化为异步的不一致窗口。自动切换脚本可以配合采集半同步状态,发现降级就主动阻止后续的自动切换,只在告警日志中标记为“需要人工评估”。
6.5 从库的 server_id 和复制拓扑中的主机名
切换后所有存活从库会指向新主,如果这些从库与旧主之间有级联复制,而某些从库的 server_id 与新主产生冲突,复制会立刻报错。 在 MySQL 8.0 中复制链路还会校验复制源是否合法,如果从库的 gtid_executed 中出现了比新主更新的 GTID,那新主会拒绝从库连接并提示 The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged binary logs。这类问题通常只能通过重建从库或清理 GTID 解决,不是脚本能绕过的。
所以我在部署拓扑时强制规定每个实例的 server_id 全局唯一,并维护一份映射表。脚本在选择候选新主和重新指向从库的时候,都会先检查这份映射表,避免出现两个 server_id 相同导致新主通信错乱的情况。
7. 切换后运维动作:通知、状态确认与数据校验
脚本把新主提升完,不代表运维工作结束。真正让故障转移“闭环”的,是切换完成后一系列配套动作能跟上。
7.1 切换完成后的第一个动作必须是状态确认
我用脚本在切换入口记录了一份状态快照,包含切换前旧主是否可写、各备库 GTID 差异、选择候选新主的依据、切换持续时间、最后的探活超时时间。故障切换后 DBA 第一件事就是看这份状态,别急着去落库捞数据,先想清楚切换依据是否站得住脚。
如果发现切换是在没有确认旧主完全隔离的条件下执行的(比如走的是虚拟化 API 强杀那条路径),值班 DBA 需要立即到旧主上执行防火墙 disable 3306 或直接停机,保证它不能再被应用访问。这一条写进操作手册比写进脚本更重要。
7.2 通知内容要包含什么信息
脚本完成切换时发到值班群的消息不能只是“failover done”。我从几次真实故障里总结了一个模板,通知里至少要包含这些内容:
- 触发切换的实例 IP 和端口
- 切换前异常持续时长
- 候选新主主机名、IP
- 提升前各备库 GTID 差异情况
- 数据丢失评估(如果存在丢失事务,需明确列出)
- VIP 是否已完成漂移
- 原有主库是否已隔离
- 脚本下一步建议(是否需要回切等)
这些信息可以让人不用登录服务器就能判断这次切换的基本状态是否正常。
7.3 切换后的数据一致性校验
MySQL 并没有官方提供跨实例比较数据库全量数据一致性的在线工具,我的习惯是用 pt-table-checksum。它在切换后对各个库表分批计算校验和,对比新主和各从库数据是否一致。如果在 pt-table-checksum 中发现差异,需要再进一步定位差异发生的原因。
启动一致性校验会自动往数据库里增加大量查询 SQL,因此我把它放在切换后 5 分钟左右执行,而且放在业务低峰期。如果业务不允许额外负载,可以先用只读应用做一次轻量抽检,确认几条核心业务表的最新记录是否和切换前一致。核心表校验通过后再依赖后台备份任务逐表核查。
7.4 备份基线要跟着角色同步刷新
切换完成以后,新主承担了写入角色,备份任务必须立刻把它加进备份周期。我在部署时就确认备份调度脚本从状态文件中读取当前主库是谁,只要状态文件更新,备份调度下一次运行就会自动指向新主。否则容易出现一种尴尬情况:主库已经切到新实例,备份计划却还往旧主上跑,等到数据膨胀才发现持续数天的备份任务全部失败。
修复老主后,我会把老主重新以从库身份加入复制拓扑,而不是立刻让它继续承担备份任务。这样等它数据补平以后再做备份,不影响新主写入。
7.5 变更记录与复盘模板
脚本会在 /var/log/mysql_failover/ 目录下按日期生成运行日志。故障切换后最好把当时的状态快照、告警通知、执行结果都归档到事故复盘文档,逐条记录“切换前预期是什么、实际结果与预期差异、原因是配置问题还是数据边界问题”。这种复盘做得多了,才能慢慢把故障转移阈值调到一个相对完美的平衡点。
比如我前几次演练时发现:如果心跳间隔设成 3 秒,检测脚本大概率会因为 MySQL 主库负载过高时的瞬断误触发切换;但如果心跳间隔设成 30 秒,一旦出现真正的故障,RTO 又会被拉到 40 秒以上。经过多轮压测,我最终把心跳间隔固定在 5 秒,连续失败 3 次即触发,整体切换时间控制在 40 秒上下,匹配业务 RTO 1 分钟以内的要求。这里没有绝对正确的数值,只有根据自身环境实测出的结果。
8. 关于这套脚本在生产环境部署的最终建议
写完脚本以后,我想了想如果让我在完全不同的一套环境里重新部署一次,我会做哪些调整,写几条最终建议作为参考。
如果你的主从复制架构里一定不能接受任何事务丢失,就不要依赖传统异步复制加自动切换脚本,至少在切换前开启半同步复制并确保它没有降级;如果不愿意维护半同步插件,数据零丢失只能是空谈。
如果备库跨机房部署而两台机器之间网络延迟较高,脚本的探活目标、检测阈值以及切换后 VIP 漂移的响应时间都要重新标定。我的测试环境是同机房内网延迟低于 0.5ms,所有超时设置都按这个前提估算;到跨机房场景直接搬过去大概率会出现探测超时误报。
如果从节点数量只有一台,脚本的自动切换其实是把“单点主库”变成了“单点备库”。虽然解决了故障转移,但并没有摆脱单点风险,切换后新主又会形成新的单点。这种环境下更值得考虑的方案不是脚本复杂度提升,而是是否值得新增一台备库或引入轻量级集群方案。
脚本里所有涉及故障判断的地方,都不要只依赖一种信号。TCP 端口通不等于 MySQL 可写,MySQL 可写不等于主从关系正常,主从关系正常不等于本轮切换动作安全。实际操作中我是一次次在演练里被这些层次不齐的信号坑过后,才逐渐把探活逻辑收敛到“连续失败+写测试+旧主隔离”这三个必要条件的。这三个条件有一点未满足,脚本宁可停机告警也不自动切换,这是我个人对生产安全性的底线。
以上这套脚本现在还在我这边每天凌晨自动巡检一次健康状态,运行了大概两个月,只误触发过一次,原因是有一个值班同事手动在主库上执行了大事务锁表导致探活超时。误触发以后我增加了“主库当前是否有长时间运行事务”的判断,如果存在大事务或锁等待,即便探活失败也会进入观察模式而不是立刻切换。这个细节看情况可以再写一篇单独展开,但核心结论很明确:自动切换脚本要长期稳定,本质靠的是对数据库真实运行状态的深入理解,而不是把简单的外部探活套上去。
