MySQL同步报错1032与binlog读取失败:根因排查与预防
凌晨两点多同步链路的延迟曲线直接竖了起来告警群里有人甩出一行日志Could not execute Delete_rows event on table jira.rundetails; Cant find record in rundetails, Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the events master log mysql-bin.000487, end_log_pos 918273645紧接着第二行更让人头大读取 bin 文件失败。这两条信息放在一起说明问题不止一个层面——一个是事件应用层面的 1032 找不到记录另一个是日志读取层面的 I/O 断档。库名和表名我沿用输入里的jira.rundetails作为占位你实际环境里换成自己的名字即可排查思路完全通用。这类问题我处理过不少次最坑的地方在于很多人一上来就去改工具配置、调大超时、加长重试结果越搞越乱因为根因往往躺在数据一致性或者日志保留策略上跟工具本身半点关系没有。下面我把自己踩过的坑、验证过的判断方法、以及最终落地的修复动作按“先看懂报错再定位根因最后堵住复发”的顺序完整过一遍。1. 一条报错里的两件事事件应用失败与日志读取中断1.1 先看清楚这两条错误各自发生的阶段很多人看到一堆英文就慌了其实这条日志的信息密度非常高拆开看每一段都在指认凶手。Delete_rows event说明这是一条 ROW 格式的删除事件on table jira.rundetails指明了目标表Cant find record加上Error_code: 1032和HA_ERR_KEY_NOT_FOUND是存储引擎层给出的明确结论——拿着事件里的定位键去目标表里找没找到那一行。这件事发生的阶段是“应用阶段”也就是事件已经从日志文件里读出来了、解析成结构化行了、准备在目标端落地执行了卡在了最后一步的定位上。而“读取 bin 文件失败”发生在更前面的“读取阶段”是日志文件本身拿不到、读不全或者位点对不上。两个阶段一前一后性质完全不同处理手法也完全不同。我见过有人把 1032 当成文件损坏去修也见过有人把文件读不到当成数据不一致去补数据方向一错排查时间直接翻倍。所以第一步永远是先确定当前是被哪一个卡住了另一个是不是连带现象。1.2 为什么这两个错误总是结伴出现这个组合之所以高频是因为它们共享同一个上游依赖——日志文件的连续性。当源端的 binlog 文件因为保留期到期被清理掉读取端拿不到文件位点就会停在原地甚至报错退出而在这之前已经读到内存或本地缓存里的一批事件仍然会被继续尝试应用一旦目标端数据本来就跟源端对不上1032 就顺势爆出来了。换句话说读取失败是“新数据进不来”1032 是“旧数据落不下去”两个症状同时出现往往意味着这条链路已经带病运行了一段时间只是今天才被放大。我通常会先翻一下这条链路的历史告警记录看看延迟是不是早就在缓慢爬升Last_SQL_Error是不是之前就被清空过几次——这些痕迹能帮你判断这是一次突发事故还是长期积累的债务。注意不要看到 1032 就直接去执行跳过事务。跳过只是把错误藏起来如果根因是数据缺失跳过之后这条链路上的数据会永久性偏差越攒越多最后只能全量重做。2. Delete_rows 事件里到底装了什么镜像、主键与定位逻辑2.1 前镜像与后镜像决定了“凭什么找到这一行”ROW 格式的 binlog 记录的是行的变化删除事件里最关键的是前镜像——也就是删除之前这一行长什么样。应用端就是靠前镜像里的主键或者唯一索引去目标表里精确匹配找到之后执行删除。这里有个容易被忽略的细节如果表没有主键也没有唯一索引MySQL 只能用所有列的组合来做定位。这时候任何一列的细微差异都会导致匹配失败比如浮点数的精度截断、DATETIME的微秒位丢失、字符集转换后的不可见字符差异、TEXT字段的尾部空格处理。这些差异在单机上看不出来一旦跨实例、跨版本、跨字符集就变成了 1032 的温床。所以我看到rundetails这种表名第一反应就是去查它的建表语句有没有主键主键是不是自增整型有没有业务唯一键如果答案是“没有主键”那基本可以锁定这是高危表。2.2 binlog_row_image 设成 MINIMAL 会放大的问题binlog_row_image这个参数有三个取值差别直接决定了事件里带多少列取值前镜像内容后镜像内容对同步链路的影响FULL所有列所有列最安全下游能拿到完整行NOBLOB所有列不含未变更的 BLOB/TEXT变更列 主键下游若需要完整行会缺字段MINIMAL仅主键/定位列仅变更列下游拿不到完整行且无主键表定位极不稳定在 CDC 场景里MINIMAL 几乎是灾难级配置。下游如果要做行级重建、要做宽表打平、要做审计留痕拿到的都是残缺数据。更麻烦的是某些同步工具在 MINIMAL 下需要回查源库补全字段一旦回查时源库那一行已经被删了就会直接报“找不到记录”表现形式和 1032 一模一样。我个人的建议很明确只要这条链路上有任何下游消费者binlog_row_image一律设成FULL。代价是日志体积变大但换来的可观测性和可恢复性完全值得。2.3 用 mysqlbinlog 把事件摊开来看定位这类问题光看报错日志是不够的必须把事件本身解出来看。我常用的命令是这样mysqlbinlog --base64-outputDECODE-ROWS -v \ --start-position918270000 \ --stop-position918276000 \ /var/lib/mysql/mysql-bin.000487 /tmp/evt.sql关键参数是--base64-outputDECODE-ROWS它会把 B64 编码的行事件解码成可读的注释形式-v会带上伪 SQL 注释。打开输出文件你能看到类似这样的段落### DELETE FROM jira.rundetails ### WHERE ### 18821337 ### 22024-11-08 02:14:07 ### 311通常对应第一列也就是主键。把这个值记下来直接去目标库执行SELECT * FROM jira.rundetails WHERE id 8821337;如果查不到而源库能查到或者源库也查不到但事件确实存在那问题就定性了——两端数据在某个历史时刻分叉了。这一步是整个排查的分水岭做完它你就不用再猜了。小技巧--verbose有两级-v输出伪 SQL-vv会额外输出字段类型信息。排查字符集和时间精度问题时-vv很有用。3. 执行端为什么落不下去五类典型根因3.1 目标实例只读或权限不足这条最容易被忽略因为报错文字往往被 1032 盖住。有些环境里目标库开了read_onlyON甚至super_read_onlyON同步账号如果没有SUPER或SESSION_VARIABLES_ADMIN权限写操作会被直接拒绝。表现上可能不是干净的权限错误而是混杂在应用失败里。排查动作很直接SELECT read_only, super_read_only; SHOW GRANTS FOR repl_user%;我一般会确认三件事目标端是否可写、同步账号是否有对jira库的SELECT/INSERT/UPDATE/DELETE权限、账号是否因为密码过期被降权。密码过期这个坑我踩过表现是链路跑着跑着突然全挂日志里一堆看似无关的错误。3.2 表不存在、库名大小写、字符集对不上lower_case_table_names这个参数在不同平台上默认值不同源端是 0区分大小写目标端是 1不区分跨平台迁移时就会出现“源端有RunDetails目标端只有rundetails”这种诡异情况。事件里的表名按源端的写法带过来目标端匹配不上报错信息和“表不存在”高度相似。还有一种情况是分库分表或者多租户环境下同步规则把某些表排除了。比如白名单正则写漏了一个前缀导致jira.rundetails这条链路根本没建表但位点还在往前推等到某天规则改了、表建上了历史位点里堆积的删除事件一次性涌进来全是 1032。我的习惯是先跑一遍结构比对SHOW CREATE TABLE jira.rundetails\G两边输出的差异要逐字看重点看主键定义、字符集与排序规则utf8mb4_0900_ai_ci和utf8mb4_general_ci混用会出问题、字段长度、是否有生成列。3.3 主键缺失导致的“找不到要删的行”前面说过无主键表的定位靠全列匹配。我在一个项目里遇到过一张日志表业务方嫌主键影响写入性能把主键去掉了结果DATETIME(3)字段在源端是毫秒精度经过一次中间层转换后毫秒位被抹掉事件里的前镜像和目标表里的实际值差了 3 位小数删除事件永远定位不到1032 每天准时打卡。修复方式不是改同步工具而是给表补主键。补主键这一步要走正常 DDL 流程先主后从注意大表加主键会锁表或者触发在线 DDL 的拷贝窗口要提前规划。补完之后把历史分叉的数据对齐一次问题才真正消失。3.4 外键、触发器带来的连带失败如果目标表上有外键约束删除父行时子表还有引用删除会被拒绝报错可能是HA_ERR_ROW_IS_REFERENCED但在某些工具封装下会被统一成“执行失败”。触发器同理一个AFTER DELETE触发器里如果做了跨库操作那个库不可用时整个删除事务回滚。这类问题的判断方法是在目标端手工执行同样的 DELETE看会不会复现。复现了就说明是表级约束问题跟同步链路无关不复现说明是链路特有的比如事务边界被工具改写了。3.5 业务代码绕过链路做了二次删除这个最常见也最难查。某些业务逻辑会在目标端直接写数据比如报表修正脚本、运营后台的手工订正把某行提前删掉了等源端的删除事件姗姗来迟目标端已经没有这行了1032 就出现了。这种情况在读写分离不彻底、或者“双写”临时方案还没下线的系统里特别多。我一般的做法是拉一张时间线把 1032 报错的时间点跟目标库的慢查询日志、审计日志对一遍看看报错前几分钟有没有手工 DML。对上了一次根因基本就清楚了。4. “读取 bin 文件失败”的真实成因4.1 文件被 PURGE 掉是最高频的原因源端的 binlog 不是永久保存的binlog_expire_logs_seconds旧版本是expire_logs_days决定保留时长。如果同步链路曾经中断过一段时间——比如目标端磁盘满、网络抖动、进程被重启——位点就停在了旧文件上等它恢复过来想继续读那个文件早被清理了。典型报错是Could not find first log file name in binary log index file或者1236 Got fatal error from master when reading data from binary log。验证方法很简单-- 源端 SHOW BINARY LOGS; SHOW VARIABLES LIKE binlog_expire_logs_seconds; -- 目标端 / 工具侧 SHOW SLAVE STATUS\G -- 看 Relay_Master_Log_File 和 Exec_Master_Log_Pos把Relay_Master_Log_File跟源端SHOW BINARY LOGS的第一行比一下如果前者不在列表里结论就出来了。保留时长怎么定我的经验公式是最大可接受延迟 备份窗口 一次故障恢复所需时间三者取最大值再乘以 2 的安全系数。生产环境我一般不低于 7 天也就是604800秒。日志盘按峰值写入量估算别拍脑袋。4.2 位点漂移与 relay log 断档GTID 模式下位点是uuid:transaction_id的集合比文件位点稳但也不是万无一失。如果gtid_purged在源端被执行过比如做过一次RESET MASTER或者基于 GTID 的重建目标端手里那些 GTID 可能已经被标记为“已清理”再要就去不到了。还有一种隐蔽情况relay log 被手动清理。有些人为了腾磁盘空间直接rmrelay log 文件结果 SQL 线程读到一半文件没了报“读取失败”。relay log 必须通过CHANGE MASTER TO重置或者等它自动轮转手删是大忌。4.3 磁盘、inode、权限与文件句柄这几个属于“不是逻辑问题但一样致命”的类别。磁盘满的时候binlog 写入会中断文件尾部可能是不完整的半截事务inode 耗尽时新文件创建失败权限不对时进程读不了目录句柄数不够时打开文件直接返回错误。我有一套固定的检查清单df -h /var/lib/mysql df -i /var/lib/mysql ls -l /var/lib/mysql/mysql-bin.index cat /proc/$(pidof mysqld)/limits | grep -i open files句柄数这个问题特别阴险因为它的表现是“间歇性读取失败”——平时好好的一到高峰期并发连接多了就报错过一会儿又自己好了。ulimit -n建议不低于 65535。4.4 怎么判断文件是真的损坏了如果磁盘、权限、位点都正常还是读不出来就要怀疑文件本身。校验方法mysqlbinlog /var/lib/mysql/mysql-bin.000487 /dev/null echo $?返回非 0 且输出里有Error in Log_event::read_log_event()之类的字样基本可以确认损坏。损坏的常见诱因是磁盘掉电、文件系统异常、或者max_binlog_size设置得极小比如小于 4KB导致频繁轮转。还有一种是主库崩溃恢复时没写完的最后一个事务这种情况通常只影响一个文件跳过它继续下一个就行。5. 我的排查链路从线程状态到逐事件重放5.1 第一步确认断在哪个线程错误信息全文是什么先看全貌别急着动手SHOW SLAVE STATUS\G重点看六个字段Slave_IO_Running、Slave_SQL_Running、Last_IO_Error、Last_SQL_Error、Relay_Master_Log_File、Exec_Master_Log_Pos。如果 IO 线程是No问题在读取链路如果 SQL 线程是No问题在应用链路两个都是No说明一开始就断了。Last_*_Error要给完整截出来很多人只看前半句漏了后面的end_log_pos而那个位置恰恰是定位的关键。我遇到过一种情况Last_SQL_Error显示的是 1032但Last_IO_Error里也有内容说明两个线程都出过问题只是 SQL 线程的错误覆盖了显示。所以两个字段都要看都不能放过。5.2 第二步把位点对齐确认事件来源拿到Relay_Master_Log_File和Exec_Master_Log_Pos之后去源端确认这个位置还在不在SHOW BINARY LOGS; SHOW MASTER STATUS;如果文件名还在列表里用mysqlbinlog精确解出那一个位置附近的事件如果不在那就是 4.1 说的文件被清理需要走重建流程。这里有个细节值得说end_log_pos是事件结束位置事件本身在它之前。解的时候要往前留一点余量我一般往回退 2000 字节左右确保能看到完整的事务边界BEGIN到COMMIT之间。mysqlbinlog --base64-outputDECODE-ROWS -v \ --start-position$((918273645 - 2000)) \ --stop-position$((918273645 100)) \ /var/lib/mysql/mysql-bin.0004875.3 第三步手工重放验证是不是真的一致性问题把事件里涉及的行取出来源端和目标端各查一次-- 源端 SELECT COUNT(*) FROM jira.rundetails WHERE id IN (8821337, 8821340); -- 目标端 SELECT COUNT(*) FROM jira.rundetails WHERE id IN (8821337, 8821340);源端查得到、目标端查不到说明目标端少了数据两端都查不到说明事件本身是个“幽灵删除”可能是历史遗留的重复事件源端查不到、目标端查得到说明目标端多了数据这个场景下 1032 不会出现但会表现为后续主键冲突 1062。判断清楚之后再决定修复动作。我坚持一个原则先对齐数据再推进位点。顺序反了跳过的那些事务就再也补不回来了。5.4 第四步补齐、跳过还是全量重建三种选择对应三种场景我把自己的判断标准列出来场景推荐动作理由与风险个别行缺失表数据量小从源端导出该行INSERT IGNORE补齐再推进位点影响面最小但要确认没有后续事件依赖这行的中间状态缺失行数量不可控或用 GTID非 GTID 用SET GLOBAL sql_slave_skip_counter 1GTID 用注入空事务只是绕过数据偏差仍在必须配合校验工具事后对齐分叉范围大、延迟已不可追备份恢复 GTID 位点重建全量重做最彻底但需要停机窗口大表耗时按 TB/小时估跳过事务的 GTID 写法是这样SET SESSION.GTID_NEXT 3f9a2b1c-xxxx-xxxx-xxxx-xxxxxxxxxxxx:104857; BEGIN; COMMIT; SET SESSION.GTID_NEXT AUTOMATIC;这个操作必须非常小心GTID_NEXT设错会导致位点乱跳。执行前一定要把GLOBAL.gtid_executed备份一份。还有一种“临时止血”的做法是把slave_exec_mode设成IDEMPOTENT它对 ROW 格式下的 1032 和 1062 都会静默跳过SET GLOBAL slave_exec_mode IDEMPOTENT;但我要明确说清楚这是止痛药不是解药。它会让链路看起来正常实际上数据偏差在持续累积。只适合在“必须先把链路拉起来保证业务读取”的紧急场景下短期使用事后一定要用校验工具做一次全量比对。6. 把这类问题挡在发生之前6.1 参数基线与表设计约定我现在给任何一条 binlog 消费链路做交付都会先钉死几条基线。源端binlog_formatROW、binlog_row_imageFULL、binlog_expire_logs_seconds不低于 604800、max_binlog_size在 256MB 到 1GB 之间这些是底线。sync_binlog1保证掉电不丢事件innodb_flush_log_at_trx_commit1保证事务持久化这两个参数一起开会有性能代价但主库如果扛不住说明该做的是扩容而不是关参数。表设计上我要求每一张会被同步的表必须有主键优先自增 BIGINT。没有业务唯一键的表用自增 ID 兜底也比全列匹配强一百倍。字符集统一utf8mb4排序规则跟着版本走别在一个实例里混用general_ci和0900_ai_ci。6.2 值得盯的几个监控项监控别只盯Seconds_Behind_Master这个值在链路断开时反而会显示 0因为不更新了非常有欺骗性。我实际会盯这几项监控项阈值建议说明Slave_IO_Running/Slave_SQL_Running任一为 No 立即告警最直接的存活信号Last_IO_Error/Last_SQL_Error非空即告警别等它自己恢复源端 binlog 最早文件与消费位点差值超过保留期 60% 告警提前发现消费跟不上binlog 目录磁盘使用率80% 告警磁盘满是文件损坏的头号诱因打开文件句柄数超过 ulimit 70% 告警预防间歇性读取失败我还会额外做一个“心跳表”检查源端每分钟往一张表插一行消费端定期比对最新时间戳。这个比任何参数都直观一眼就能看出链路是不是真的活着。6.3 DDL 变更必须走主从一致的流程很多 1032 的源头是 DDL 顺序错了。正确的顺序是先在目标端加列或改索引再在源端执行删列则相反先源端后目标端。中间要有兼容期让新旧结构共存一段时间。最忌讳的是“源端已经删了列目标端还没删”这期间的所有 ROW 事件列数都对不上解析直接崩。还有个小经验大表 DDL 期间binlog 事件里的行数不变但列定义变了某些同步工具会因为缓存的表结构过期而报错重启一下工具刷新元数据就好。所以我在做大表变更时会把同步工具的重启也排进变更窗口作为标准动作。最后分享一个我自己用了很多年的做法每次排查完这类问题我都会在记录里写清楚三件事——报错原文、根因分类、以及“下次怎么在 5 分钟内判断出是这一类”。积累到十几条之后你会发现 1032 加上读取失败的组合八成以上都落在两个点上数据历史分叉、日志保留不足。把这两个点做成例行巡检夜里被叫起来的次数会明显变少。