一次手动主从切换,把从库切成了只读的空壳
去年冬天一个周六的早上,主库所在的那台机器突然连不上了,云控制台显示实例状态异常。我当时的第一反应是「先把从库提起来顶上」,于是照着脑子里记的几条命令敲了下去:从库停掉复制线程、执行 RESET SLAVE ALL、改应用配置指向从库、重启 PHP-FPM。整个过程不到五分钟,页面也确实打开了。
问题在于二十分钟之后,有用户反馈「发评论提示成功,但刷新就没了」。我登录后台一看,评论写入全部报错,日志里一片 The MySQL server is running with the --read-only option so it cannot execute this statement。原来那台从库是我很早以前配过的,/etc/mysql/mysql.conf.d/mysqld.cnf 里留着 read_only=1 和 super_read_only=1,本来就是按「只读从库」设计的。我漏掉了取消这两个参数,还忘了重置 auto_increment_increment,更重要的是,我根本没确认从库的复制延迟,切换瞬间有一段时间的写入在旧主库上,那些数据随着旧主库一起消失了。
这次事故没有造成严重损失,因为那台机器四十分钟后自己恢复了,我赶紧切了回去。但它暴露了一个真问题:手动主从切换不是几条命令,而是一个有前置条件、有检查点、有回退路径的流程。这篇文章就把这个流程完整写出来,包括每一步的判据、容易漏掉的参数、以及切回去时怎么把数据补齐。
先分清三种「切换」,别把故障转移当计划内操作
很多教程把「主从切换」说成一件事,实际运维里至少要分成三类,处理方式完全不同:
| 类型 | 触发条件 | 核心目标 | 关键约束 |
|---|---|---|---|
| 计划内切换 | 主库要升级、迁移机房、换硬件 | 零数据丢失、尽量短的中断 | 可以先让主库停止写入,从容对齐 |
| 主库故障切换 | 主库宕机、磁盘损坏、进程崩溃 | 尽快恢复服务 | 可能丢失最后一段未同步的事务 |
| 只读降级 | 主库 IO 打满但还活着 | 保住读服务,等主库恢复 | 写入排队或丢弃,需要应用侧配合 |
计划内切换可以做到零丢失,因为你可以主动把主库设成只读,等从库追上全部 binlog 之后再提升。而故障切换做不到零丢失,除非你用半同步复制并且 rpl_semi_sync_source_timeout 设得足够大,代价是主库写入延迟变高、一旦从库网络抖动主库会自己退化成异步。
我个人的做法是:能用计划内切换解决的,绝不等到故障切换。主库要维护,就提前发个通知,凌晨两点开始,走完整流程。只有真的宕机才走应急路径,而应急路径也要事先演练过,否则第一次执行一定出错——我上面那次就是第一次。
切换前必须拿到的五个数字
在动手之前,先登录从库把这五个值抄下来,写在纸上或者记事本里,切换完成后要逐一核对。这不是形式主义,最后那条 Exec_Master_Log_Pos 是判断「有没有丢数据」的唯一客观依据。
-- 1) 复制状态与延迟(8.0.22+ 用 SHOW REPLICA STATUS)
SHOW SLAVE STATUS\G
-- 关注字段:
-- Slave_IO_Running: Yes
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 必须是 0,且连续 3 次采样都是 0
-- Master_Log_File / Read_Master_Log_Pos
-- Relay_Master_Log_File / Exec_Master_Log_Pos ← 两者相等才算全部回放
-- Last_IO_Error / Last_SQL_Error 必须为空
-- 2) 未提交的长事务(决定切主时会不会卡住)
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, LEFT(trx_query, 80) AS q
FROM information_schema.innodb_trx
ORDER BY age_sec DESC LIMIT 5;
-- 3) 当前连接来源分布(切主后这些连接要重连到新库)
SELECT SUBSTRING_INDEX(host, ':', 1) AS ip, user, COUNT(*) AS conns
FROM information_schema.processlist
GROUP BY ip, user ORDER BY conns DESC LIMIT 10;
-- 4) 自增与 GTID 相关配置
SELECT @@server_id, @@auto_increment_increment, @@auto_increment_offset,
@@gtid_mode, @@enforce_gtid_consistency, @@read_only, @@super_read_only;
-- 5) 两库的数据校验点(见下一节的一致性核对)
SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('mysql','sys','information_schema','performance_schema');
Seconds_Behind_Master 只有 0 是不够的。这个字段在从库 SQL 线程空转时会显示 0,在网络中断时也可能显示 NULL。更可靠的判据是:连续三次采样(每次间隔 5 秒)Read_Master_Log_Pos 和 Exec_Master_Log_Pos 都不动,且两者相差很小。如果主库此时仍在大量写入,Pos 不动反而说明 IO 线程停了——所以要同时看 Slave_IO_Running。
还有一个隐蔽情况:Relay_Master_Log_File 和 Master_Log_File 不同,说明从库还在回放历史日志,此时虽然 SBM 是 0,实际数据是旧的。这种情况常见于大事务之后,一分钟内回放了几十万行。
第一步:把主库变成只读,让写入停下来
计划内切换的第一件事不是动从库,而是让主库拒绝新写入。顺序永远是「先停写,再确认追上,最后提升」。
-- 在主库上执行
SET GLOBAL super_read_only = ON;
-- super_read_only=ON 会自动把 read_only 也设为 ON,
-- 但反过来不会:设 read_only=ON 之后,拥有 SUPER 权限的账号
-- (比如 root、备份工具、部分 ORM 的迁移进程)仍然能写。
-- 所以生产切主必须用 super_read_only。
-- 确认所有应用连接已停止写入
SELECT id, user, host, db, command, time, LEFT(info, 60)
FROM information_schema.processlist
WHERE command != 'Sleep' AND info IS NOT NULL;
设成只读之后,应用会开始报错。这里的取舍是:让应用报错,还是让应用等待?报错最干净,用户看到几秒钟的「系统维护中」;等待(比如连接池重试)听起来更友好,但如果切换耗时超过重试窗口,重试风暴会在新主库上堆出上千个连接,把新主库也压垮。
我的建议是:切换窗口内由 Nginx 层直接返回 503,页面顶部挂一条维护提示,业务完全停止。这样数据库侧就是静态的,你有一整段时间慢慢操作,不必跟时间赛跑。维护窗口十分钟,比边切边救火要安全得多。
另外要特别注意:定时任务是切主事故的高发区。cron 里的脚本、夜间跑的数据统计、队列消费者,它们往往不读应用配置,或者连接池长期持有连接。切主前先 systemctl stop 掉这些服务,切完再启动。我见过最典型的一次,是切主后两分钟,某个 cron 脚本带着旧连接往旧主库写数据,旧库恢复上线后这些数据就成了孤儿。
第二步:等从库完全追上,并做一次行数核对
主库停写之后,从库很快就能追平。这时候再跑一次前面的复制状态检查,重点确认 Exec_Master_Log_Pos == Read_Master_Log_Pos 且 Relay_Master_Log_File == Master_Log_File。
光看位点还不够,位点相同只说明 binlog 消费完了,不能排除从库上还有未提交的事务、或者某个表因为历史原因本来就不一致。所以在提升之前,对几张关键表做一次行数与校验和比对:
-- 两库分别执行,结果逐行比对
SELECT 'contents' AS t, COUNT(*) c, COALESCE(SUM(cid),0) s FROM typecho_contents
UNION ALL
SELECT 'comments', COUNT(*), COALESCE(SUM(coid),0) FROM typecho_comments
UNION ALL
SELECT 'options', COUNT(*), 0 FROM typecho_options;
-- 单表校验和(会扫全表,只在切换窗口内做,且从表结构小的开始)
CHECKSUM TABLE typecho_contents;
CHECKSUM TABLE 在 InnoDB 上会做全表扫描并占用 MDL 读锁,如果表有几百万行,可能跑几十秒。所以顺序上要把它放在维护窗口内、主库已停写的时候做,此时没有并发写入,结果稳定可比。
如果校验和不一致,先别慌。常见原因是字符集或排序规则不同(collation_server 两边不一样),第二个常见原因是有一边跑过半截的迁移脚本。用 SHOW CREATE TABLE 对比一下表定义,比盲目重灌数据快得多。
第三步:提升从库,三个必须改的参数
确认数据对齐之后,解除从库的复制关系并把它变成可写的主库。这里有三个参数要一起改,缺一个都会出问题:
-- 在(原)从库上执行
STOP SLAVE IO_THREAD;
STOP SLAVE SQL_THREAD;
-- 8.0.22+ 语法:STOP REPLICA; RESET REPLICA ALL;
RESET SLAVE ALL; -- 清掉 master.info / relay-log.info,否则重启后又连回去
SET GLOBAL read_only = OFF;
SET GLOBAL super_read_only = OFF; -- 这个最容易被忘,忘了就是开头的那个事故
-- 如果原来配了自增步长(多主/组复制场景),切主后必须改回 1
SET GLOBAL auto_increment_increment = 1;
SET GLOBAL auto_increment_offset = 1;
-- 确认
SELECT @@read_only, @@super_read_only, @@auto_increment_increment, @@auto_increment_offset;
SHOW SLAVE STATUS; -- 应该为空
RESET SLAVE ALL 和 RESET SLAVE 的区别值得记住:不加 ALL 只清 relay log,保留主库连接信息,MySQL 重启后会自动重新连上旧主库。切换场景下,旧主库一旦恢复上线,从库就会开始从它拉 binlog,而它此时已经是两个独立的主库了——这是最容易造成「数据分叉」的路径。加上 ALL 才能彻底断干净,8.0.22 之后对应的是 RESET REPLICA ALL。
如果旧主库可能被自动拉起(云厂商的自动恢复、Kubernetes 的自动重启、systemd 的 Restart=always),务必在提升从库之前或同时,让旧主库无法上线:删掉它的安全组入站规则、停掉它的 mysqld、或者直接把实例关机。让一个「脑裂的主库」重新接受写入,比停机一小时糟糕得多。更稳妥的做法是给旧主库的配置文件加上 read_only=1 和 super_read_only=1,这样即使它起来了也不会自动接写入。
第四步:改应用指向,并盯住连接池
数据库层切换完成之后,应用层还有几处要改,按顺序来:
- 连接串:如果应用连的是 IP,直接改配置文件;如果连的是域名,改 DNS 记录并等 TTL。这也是为什么我建议数据库连接统一走一个内网域名,切主时只改 DNS 即可。
- 重启方式:PHP-FPM 用
reload不会重连数据库,老 worker 里的持久连接(pconnect)还指着旧 IP。reload之后新连接的 worker 是好的,老 worker 要等pm.max_requests触发才退出,这段时间就是随机报错。所以切主后要么restart,要么临时把pm.max_requests调小逼 worker 快速轮换。 - 只读账号:如果应用里有专门读从库的配置,别忘了把原来的主库地址改成新的只读地址,否则读请求会落到已经断开的旧库上。
- 缓存与队列:Redis 里如果缓存了数据库地址或某个「主库标识」,切完要清掉对应键。队列消费者通常需要重启才能拿到新连接。
改完之后观察三件事:错误日志里 SQLSTATE[HY000] [2002] Connection refused 是否还在增长、SHOW PROCESSLIST 里来源 IP 是否只剩新应用服务器、写入是否正常(发一条测试评论、看自增 ID 有没有往前走)。
第五步:把旧主库变成新从库(反向复制)
这一步很多人跳过,结果就是「回切的时候两边数据都不对」。正确做法是:旧主库修好之后,不要急着回切,而是先把它变成新主库的从库,让两边重新同步起来。
-- 在旧主库上:先确保它不会接受写入
SET GLOBAL super_read_only = ON;
-- 用新主库的备份重建(最干净的方式):
-- 新主库上执行 mysqldump --single-transaction --source-data=2
mysqldump -uroot -p --single-transaction --quick --source-data=2 \
--databases zz1984 > /tmp/after_switch.sql
-- 旧主库上:清空并导入(或在另一台机器恢复后替换)
-- 导入后文件里会有 CHANGE MASTER TO ... 注释行,取其中的位点
CHANGE MASTER TO
MASTER_HOST='新主库IP',
MASTER_USER='repl',
MASTER_PASSWORD='***',
MASTER_LOG_FILE='mysql-bin.000123',
MASTER_LOG_POS=4567890;
START SLAVE;
SHOW SLAVE STATUS\G -- 确认 IO/SQL 都是 Yes,SBS 归 0
用 --source-data=2(旧版本是 --master-data=2)导出的文件里,位点是被注释掉的,需要手动取出来填进 CHANGE MASTER。这个细节很实用:注释形式意味着你恢复数据时不会意外启动复制,恢复了半截也不会报错。
反向复制跑通、延迟归零、并且稳定运行了两三天之后,这场切换才算真的结束。这期间旧主库是只读的,随时可以切回去——这也是我坚持「先反向复制再考虑回切」的原因:回切变成了一次和正向切换完全对称的操作,风险可控。
六个真实踩过的坑
坑一:只改了 read_only 没改 super_read_only。应用用的是普通账号能正常写,但备份脚本、迁移工具、root 会话全被拒。更麻烦的是有人在 root 会话里手动改了数据,造成主从位点继续走但内容不一致。
坑二:忘了 RESET SLAVE ALL。新主库重启之后自己又去连旧主库,出现双向复制,同一条 INSERT 在两个库里循环。这个问题的症状是自增 ID 出现奇怪的空洞,且 binlog 体积异常膨胀。
坑三:只改了配置文件没改运行时。my.cnf 里改成 auto_increment_increment=1,但忘记 SET GLOBAL 或重启,运行时还是 2,新写入的 ID 全是偶数,前端分页直接跳号。
坑四:域名 TTL 太长。连接串用域名,切主时改 DNS,但 TTL 是 3600,一小时内还有大量应用连旧 IP。日常就应该把数据库域名的 TTL 设成 60 秒,成本几乎为零。
坑五:以为 Seconds_Behind_Master=0 就安全。前面说过,SBM 在 IO 线程断开时可能是 NULL 或 0。判据要用「三次采样 + 位点相等」,而不是单看一个字段。
坑六:没有演练。这是最根本的一条。切换流程里每一步都需要判断,而判断依赖熟悉度。我现在的做法是每季度在一套临时环境上完整演练一次,包括故意让旧主库「复活」来验证脑裂防护是否生效。演练的代价是两小时,而第一次真正切换时的错误代价是几小时的数据丢失。
写在最后:把流程写成脚本,但别让脚本替你决策
上面这些步骤可以固化成脚本,但要明确划分:哪些是机械执行,哪些必须人来判断。
机械执行的部分可以脚本化:检查复制状态、输出五个数字、生成 CHANGE MASTER 语句、验证参数是否生效。这些用 bash 加几段 SQL 就够了,关键是脚本里要有 set -euo pipefail 和明确的退出码,任何一步不符合预期就停下来。
必须人来判断的部分不要脚本化:要不要现在切、维护窗口多长、数据不一致时是重灌还是接受、旧主库要不要保留。这些判断依赖业务信息,而业务状态脚本读不到。
最后给一个我自己的检查单,贴在运维笔记第一页,切换时逐条打勾:主库已 super_read_only → cron 与队列已停 → 从库三次采样位点相等 → 关键表校验和一致 → STOP SLAVE + RESET SLAVE ALL → read_only/super_read_only 已关 → 自增步长已改 1 → 旧主库已隔离 → 应用连接串已改 → FPM 已 restart → 写入验证通过 → 旧主库反向复制已跑通。十二条全部打勾,才允许撤掉维护页。