上周值班的时候,复制监控突然告警,登录从库执行SHOW SLAVE STATUS\G,看到Slave_SQL_Running: No那一眼,心里反而踏实了——至少问题暴露出来了,没有埋在暗处继续累积数据差异。MySQL主从同步的排错里,Slave_SQL_Running: No是最常见也最容易“手贱”的一类故障:很多人第一反应就是START SLAVE拉起来,但如果不搞清楚 SQL 线程为什么停,拉起一次可能就埋一次雷。这篇就把我从告警出现到最终修复、验证、防复发的完整过程写出来,包含定位思路、修复手段的取舍以及背后的复制原理,给同样被这个报错卡住的同行做参考。
1. 故障现象与第一反应:先把现场信息完整捞出来
1.1 现场信息怎么才算采集完整
Slave_SQL_Running: No只是结果,不是原因。所以第一件事不是START SLAVE,而是把复制状态完整抓一圈。我当时执行的命令和关注点如下:
SHOW SLAVE STATUS\G重点看下面这些字段,而不只是看两个 Running:
Slave_IO_Running: Yes Slave_SQL_Running: No Last_IO_Errno: 0 Last_IO_Error: Last_SQL_Errno: 1032 Last_SQL_Error: Could not execute Delete_rows event on table db1.t_order; Can't find record in 't_order', Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the event's master log db1-bin.000012, end_log_pos 456789 Relay_Master_Log_File: db1-bin.000012 Exec_Master_Log_Pos: 456789 Seconds_Behind_Master: NULLSlave_SQL_Running: No说明IO线程还在正常拉取主库binlog并写入relay log,但SQL线程回放relay log时遇到错误,停止推进。此时Last_SQL_Errno和Last_SQL_Error就是最关键的线索,它们直接告诉你哪条语句在哪个表上失败、报错码是多少。Relay_Master_Log_File和Exec_Master_Log_Pos是SQL线程已经执行到的主库binlog位置,也是后续修复后恢复复制的锚点。
很多人会忽略从库自身的错误日志,但有些情况下SHOW SLAVE STATUS里的报错信息会被截断,或者绕过了复制线程的错误没有完整显示。所以我还会再翻一遍从库错误日志,比如:
tail -n 100 /var/log/mysql/error.log日志里通常会有更完整的上下文,包括relay log文件名、回放线程ID、以及是哪种event类型执行失败。把这些信息采集齐全之前,不要动从库,更不要直接重置复制。
1.2 从报错号出发判断严重程度
Last_SQL_Errno是判断问题严重程度的一个快捷入口。我根据经验把常见错误分成了几类,在处理思路上是完全不同的:
| 错误码 | 错误含义 | 通常原因 | 处理倾向 |
|---|---|---|---|
| 1062 | 主键重复 | 从库已有相同主键记录,回放INSERT失败 | 先查数据来源,再决定删除/跳过 |
| 1032 | 记录不存在 | 回放UPDATE或DELETE时找不到对应行 | 重点怀疑从库被写或主库数据先被删 |
| 1452 | 外键约束失败 | 从库子表引用的父表记录不存在 | 检查主键表数据一致性 |
| 1146 | 表不存在 | 从库缺少该表 | 需要补表或重建 |
| 1054 | 字段不存在 | 从库表结构落后于主库 | 需要同步表结构 |
| 1050 | 表已存在 | 从库已有同名表,回放CREATE TABLE失败 | 对比表结构,确认后跳过 |
这个表不用死记,但它能帮你快速形成判断:如果是1062,说明从库的“多数据”可能来自误写入,影响面相对可控;如果是1032,往往意味着从库缺了主库已经删掉的行,后面可能还有连锁的删除操作要继续。最怕的是操之过急,把所有错误全部跳过,结果跳过了主库的DDL或关键数据变更,从库与主库的差距越来越大。
2. 从Last_SQL_Error顺藤摸瓜:错误SQL的定位方法
2.1 为什么不要瞎猜报错原因
Last_SQL_Error看起来只有一行,但真正的原因往往藏在语句执行前的数据状态里。比如报错信息是Can't find record in 't_order',你就得搞清楚:是主库执行了DELETE,从库本来应该有这一行,但实际却没有。为什么没有?可能是从库被某个人手动删过,也可能是从库压根就没有同步过这一行。
如果凭感觉猜“可能是主库删太快导致从库没跟上”,然后直接跳过错误,很可能掩盖一个更严重的问题:从库的数据已经和主库产生了结构性偏差。所以我的习惯是:先通过relay log把失败的那条SQL原文解析出来,再做判断。
2.2 实际解析命令与判断示例
SQL线程回放的是relay log,所以我直接解析relay log,而不是去主库翻binlog。先确认当前正在用哪个relay log:
SHOW SLAVE STATUS\G -- 找到 Relay_Log_File,比如 relay-log.000011然后用mysqlbinlog定位到报错位置附近。如果binlog格式是ROW,默认输出是base64编码的伪SQL,需要加--base64-output=DECODE-ROWS --verbose才能看到可读的行变更:
mysqlbinlog --base64-output=DECODE-ROWS -v --start-position=456000 --stop-position=456900 relay-log.000011解析出来大概是:
### DELETE FROM `db1`.`t_order` ### WHERE ### @1=10086 ### @2='2024-06-01 12:00:00'这时去从库执行:
SELECT * FROM db1.t_order WHERE id = 10086;发现这一行在从库中依然存在。这说明主库已经把这条记录删掉了,但从库没删掉,因为SQL线程在这里失败停止了。再进一步排查,基本可以确认是之前有人在从库上做了一次手工UPDATE操作,把这条记录的某些字段改掉了,随后主库的DELETE事件过来时,尝试按旧值匹配字段,在从库匹配不到对应的完整行,于是报HA_ERR_KEY_NOT_FOUND。
这个排查链路的意义在于:它把所有决策都建立在实际数据状态上,而不是猜测。否则你无法判断应该“跳过”还是“手工补偿”,更无法判断跳过后是否还会引发下一个报错。
3. 修复方案的取舍:跳过、补偿还是重建
3.1 跳过单条错误的操作与边界
如果你的场景符合下面这些条件:错误只是偶发个位数,不影响关键业务表,且可以确认该条变更对从库数据影响可控,那可以选择跳过单条错误。
操作步骤是:
STOP SLAVE SQL_THREAD; SET GLOBAL sql_slave_skip_counter = 1; START SLAVE SQL_THREAD;注意,我停的是SQL_THREAD,不是整个SLAVE。IO线程继续从主库拉relay log,不会造成日志堆积缺口。sql_slave_skip_counter = 1表示跳过SQL线程遇到的下一组事件,注意是“下一组”不是“下一条”,因为一个事务可能包含多条事件。
跳过的边界在哪里?我的判断标准是:如果错误类型是1062,且从库多出来的那条记录和主库即将写入的数据完全等价或只是冗余数据,跳过是相对安全的;如果错误类型是1032,且错误是一条DELETE,那么跳过意味着“主库删了,从库没删”,从库会永久留存主库已删除的数据,这种差异在业务上往往不可接受。
某个测试环境曾经出现过连续跳过6次错误的情况,每跳一次就出来一个新错误,最后发现整张表的数据和主库差了上千行。所以跳过这种操作,连续跳两次就要停下来,改用更彻底的手段。
3.2 手工补偿的流程与注意点
针对上面t_order的例子,最稳妥的修复是先把从库里那条“多余记录”清理掉,然后让SQL线程继续回放主库的DELETE事件。实际操作如下:
查询从库当前存在的记录:
SELECT * FROM db1.t_order WHERE id = 10086;确认这条记录确实是主库已经删除的,并且在从库上没有任何业务依赖,再删除:
DELETE FROM db1.t_order WHERE id = 10086;删除后不要立刻START SLAVE SQL_THREAD,先对比一下从库当前位点附近还有没有其他可能继续报错的数据:
SHOW SLAVE STATUS\G START SLAVE SQL_THREAD;启动之后观察几秒,再次执行SHOW SLAVE STATUS\G,确认Last_SQL_Errno是否已经清零。如果又出现新错误,继续解析relay log定位,不要滚雪球。
手工补偿需要注意一点:从库上做DML时,理论上应该临时把sql_log_bin关掉,避免多余操作记录进从库自己的binlog。生产环境建议在操作前确认从库是否开启了log_slave_updates,如果开启了,手工DML也会级联同步到下游,必须评估影响面。虽然很多DBA习惯直接在前面加SET SQL_LOG_BIN=0,但也要考虑公司规范,不能一味照搬。
3.3 重建从库的具体路径
如果错误不是个位数,而是几百上千条,或者做完数据比对发现主从差异已经是“结构性”的(整张表数据对不上,部分表结构不一致),那不要再跳了,重建从库才是成本最低的方案。
重建思路说白了就三步:拿到主库一个一致性快照,恢复从库,把复制位点切到快照对应的位置。
先停掉从库复制:
STOP SLAVE; RESET SLAVE ALL;然后在主库做一次全量备份,必须记录备份时刻的binlog位置。如果使用mysqldump:
mysqldump --single-transaction --master-data=2 --all-databases > backup.sql--master-data=2会在备份文件头部写入类似这样的注释:
-- CHANGE MASTER TO MASTER_LOG_FILE='db1-bin.000015', MASTER_LOG_POS=123456;把备份恢复到从库:
mysql < backup.sql然后重新配置复制:
CHANGE MASTER TO MASTER_HOST='主库IP', MASTER_USER='repl_user', MASTER_PASSWORD='******', MASTER_LOG_FILE='db1-bin.000015', MASTER_LOG_POS=123456; START SLAVE;备份方案里,mysqldump的逻辑备份在大数据量下恢复速度比较慢,所以生产环境一般会用在线物理备份工具(比如Percona XtraBackup)或者MySQL 8.0的Clone Plugin,恢复完直接指向备份时的位点。无论用哪种,核心原则是一样的:备份期间产生的binlog必须完整保留,直到从库追平主库,否则重建完照样断层。
4. 修复完成后的验证与防复发
4.1 验证组:不能只看两个Running
修复完成后,我见过太多人看一眼Slave_IO_Running: Yes和Slave_SQL_Running: Yes就宣布“恢复了”。这两个字段只能说明进程在跑,不能说明数据已经追平。
我自己的验证清单是:
SHOW SLAVE STATUS\GSlave_IO_Running和Slave_SQL_Running都为Yes;Seconds_Behind_Master逐渐从一个大值降到0(如果长时间恒定为0,才说明追平);Exec_Master_Log_Pos持续向前推进,最终追到接近Retrieved_Master_Log_Pos;Last_SQL_Errno保持为0,连续观察5到10分钟没有复现。
然后在业务低峰期做一次数据一致性抽样。抽样的方式可以很朴素:选几张大表,分别在主库和从库执行COUNT(*),或者对比分区边界上的聚合值。如果两边数据量不一致,说明还有隐蔽的差异没暴露出来。此时继续查监控,而不是直接放行。
4.2 从源头降低SQL线程停摆概率
既然Slave_SQL_Running: No的根源多半是“从库和主库数据状态不一致”,那预防的核心就是保证从库不受外部写入干扰,以及让主从结构变更流程可控。我总结了几条比较实用的做法:
- 从库强制开启
read_only,必要的时候开super_read_only,让无论普通账号还是管理员账号都无法在从库误写入。这个配置要写进从库的my.cnf,而不是只在线修改,避免重启后失效。 - 所有DDL和DML变更都从主库发起,从库禁止直接创建表或修改字段。如果确实要提前在从库准备表结构,也要和主库变更放在同一个发布窗口,避免两边结构漂移。
- 主库开启
binlog_format=ROW能减少STATEMENT模式带来的不确定性。ROW模式下SQL线程回放的是行级变更,排错时虽然比较繁琐,但不会出现“同样的SQL在两个库执行结果不同”的问题。如果担心解析麻烦,可以在排查时用mysqlbinlog --base64-output=DECODE-ROWS -v把行变更转成可读SQL。 - 从库磁盘空间要纳入监控。relay log需要空间,如果磁盘满了,SQL线程可能直接崩溃,报错信息可能变成“relay log write failure”。
- 对复制状态做秒级监控,并且监控不要只盯着
Slave_SQL_Running,要把Last_SQL_Errno和Last_IO_Errno都采集上报,错误码一旦不为0就要告警。这样能第一时间感知问题,而不是等业务反馈才被动处理。
这类问题的根源,其实往往不在MySQL本身,而在于运维流程有没有给从库“写上保护”。从库一旦被当作备用的读写库使用,哪怕只是被某个后台任务刷了几行数据,后续复制中断就是大概率事件。
5. 这类故障背后值得想清楚的复制原理
5.1 SQL线程的“停”是一种保护机制,不是Bug
很多初学者会把Slave_SQL_Running: No当成MySQL的“故障表现”,但实际上它更像一道安全阀。SQL线程遇到错误立刻停止,不是因为它脆弱,而是因为复制系统不允许一条语句在从库回放失败后继续往下执行。试想一下:如果MySQL自动跳过错误继续执行,从库的数据会带着错误继续累积,等真正需要主从切换的时候,你根本没信心把流量切到从库上。
我用一个不太严谨但很好懂的类比:主从同步就像在抄一份账本,SQL线程是负责抄写的人,relay log是原始单据。如果抄到某一页发现“借”和“贷”对不上,负责任的抄写员应该停下来喊人核对,而不是硬着头皮继续抄后面的内容。跳过错误相当于“假装这笔对不上也没事”往下走,短期内看起来账本抄完了,长期对账会出大问题。
所以,看到No不要先骂它为什么停,先感谢它替你保守了一致性底线,再想想怎么让它安全地重新跑起来。
5.2 binlog格式决定了你的排错成本
MySQL的binlog有三种格式:STATEMENT、ROW、MIXED。不同格式下,同样一个变更在relay log里的表现完全不同。
STATEMENT格式记录的是SQL原文,排错时一眼就能看出主库执行了什么,这是它的优势。但它有天然的不确定性:比如DELETE FROM t WHERE create_time < NOW()这条语句在主库执行时是一个时间点,在从库回放时是另一个时间点,两个库删除的行可能不一样,最终导致数据分叉。这类语句如果恰好触发了从库的报错,往往比较难复现。
ROW格式记录的是每一行的前后镜像,Semantic上更安全,回放结果和主库完全一致。但代价就是排错时默认看到的是base64伪SQL,必须加参数解析。我刚开始接触ROW格式的报错时也觉得麻烦,直到遇到几次STATEMENT模式下的隐性问题,才理解为什么很多生产环境坚持用ROW。
GTID模式的普及让排错更轻松了一些。开启GTID后,每个事务都有全局唯一ID,报错信息里会直接告诉你GTID或者LAST_TRANSACTION,重建从库时也不需要再手动记binlog文件名和position,直接用CHANGE MASTER TO MASTER_AUTO_POSITION=1就能对齐位点。如果你的环境还在用老式的binlog+position方式,建议在新集群里逐步迁移到GTID,能省不少事。
6. 这次排错留下的几条实操体会
处理完这套Slave_SQL_Running: No,复盘时最深的感受是:整个过程中的关键决策不是“怎么把它拉起来”,而是“要不要跳过、值不值得补偿”。
第二次遇到类似问题时,我处理得明显更顺:先把错误日志和relay log定位结果发给同事一起判断,再决定方案;如果是跳过,我会把跳过的错误号、位点、时间点记录在变更记录里,方便后续对账;如果是补偿,我会在从库操作前先确认read_only已经打开,避免操作期间又有其他写入混进来。
还有一个容易被忽略的小技巧:sql_slave_skip_counter设置之后,可以用SHOW SLAVE STATUS\G看到Last_SQL_Error已经清空,但不要急着庆祝,先看一眼Exec_Master_Log_Pos是否真的往前跳了。如果跳了,说明SQL线程确实消费了relay log;如果没跳,说明还是没救活。有几个版本对skip counter的处理逻辑略有差别,实测以Exec_Master_Log_Pos是否推进为准,比看状态字段更可靠。
最后一点建议:每次排完这种错,都值得把完整的SHOW SLAVE STATUS输出存成一份快照,作为下次排查时的对比基线。条件允许的话,可以用一致性校验工具定期对主从做抽样比对,而不是等到复制线程停了才去想办法。主从同步这件事,防永远比修划算。