
上周值班的时候复制监控突然告警登录从库执行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重点看下面这些字段而不只是看两个 RunningSlave_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; Cant find record in t_order, Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the events 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看起来只有一行但真正的原因往往藏在语句执行前的数据状态里。比如报错信息是Cant find record in t_order你就得搞清楚是主库执行了DELETE从库本来应该有这一行但实际却没有。为什么没有可能是从库被某个人手动删过也可能是从库压根就没有同步过这一行。如果凭感觉猜“可能是主库删太快导致从库没跟上”然后直接跳过错误很可能掩盖一个更严重的问题从库的数据已经和主库产生了结构性偏差。所以我的习惯是先通过relay log把失败的那条SQL原文解析出来再做判断。2.2 实际解析命令与判断示例SQL线程回放的是relay log所以我直接解析relay log而不是去主库翻binlog。先确认当前正在用哪个relay logSHOW SLAVE STATUS\G -- 找到 Relay_Log_File比如 relay-log.000011然后用mysqlbinlog定位到报错位置附近。如果binlog格式是ROW默认输出是base64编码的伪SQL需要加--base64-outputDECODE-ROWS --verbose才能看到可读的行变更mysqlbinlog --base64-outputDECODE-ROWS -v --start-position456000 --stop-position456900 relay-log.000011解析出来大概是### DELETE FROM db1.t_order ### WHERE ### 110086 ### 22024-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_BIN0但也要考虑公司规范不能一味照搬。3.3 重建从库的具体路径如果错误不是个位数而是几百上千条或者做完数据比对发现主从差异已经是“结构性”的整张表数据对不上部分表结构不一致那不要再跳了重建从库才是成本最低的方案。重建思路说白了就三步拿到主库一个一致性快照恢复从库把复制位点切到快照对应的位置。先停掉从库复制STOP SLAVE; RESET SLAVE ALL;然后在主库做一次全量备份必须记录备份时刻的binlog位置。如果使用mysqldumpmysqldump --single-transaction --master-data2 --all-databases backup.sql--master-data2会在备份文件头部写入类似这样的注释-- CHANGE MASTER TO MASTER_LOG_FILEdb1-bin.000015, MASTER_LOG_POS123456;把备份恢复到从库mysql backup.sql然后重新配置复制CHANGE MASTER TO MASTER_HOST主库IP, MASTER_USERrepl_user, MASTER_PASSWORD******, MASTER_LOG_FILEdb1-bin.000015, MASTER_LOG_POS123456; 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都为YesSeconds_Behind_Master逐渐从一个大值降到0如果长时间恒定为0才说明追平Exec_Master_Log_Pos持续向前推进最终追到接近Retrieved_Master_Log_PosLast_SQL_Errno保持为0连续观察5到10分钟没有复现。然后在业务低峰期做一次数据一致性抽样。抽样的方式可以很朴素选几张大表分别在主库和从库执行COUNT(*)或者对比分区边界上的聚合值。如果两边数据量不一致说明还有隐蔽的差异没暴露出来。此时继续查监控而不是直接放行。4.2 从源头降低SQL线程停摆概率既然Slave_SQL_Running: No的根源多半是“从库和主库数据状态不一致”那预防的核心就是保证从库不受外部写入干扰以及让主从结构变更流程可控。我总结了几条比较实用的做法从库强制开启read_only必要的时候开super_read_only让无论普通账号还是管理员账号都无法在从库误写入。这个配置要写进从库的my.cnf而不是只在线修改避免重启后失效。所有DDL和DML变更都从主库发起从库禁止直接创建表或修改字段。如果确实要提前在从库准备表结构也要和主库变更放在同一个发布窗口避免两边结构漂移。主库开启binlog_formatROW能减少STATEMENT模式带来的不确定性。ROW模式下SQL线程回放的是行级变更排错时虽然比较繁琐但不会出现“同样的SQL在两个库执行结果不同”的问题。如果担心解析麻烦可以在排查时用mysqlbinlog --base64-outputDECODE-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_POSITION1就能对齐位点。如果你的环境还在用老式的binlogposition方式建议在新集群里逐步迁移到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输出存成一份快照作为下次排查时的对比基线。条件允许的话可以用一致性校验工具定期对主从做抽样比对而不是等到复制线程停了才去想办法。主从同步这件事防永远比修划算。