ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL主从复制Duplicate entry报错全解析:从排查到修复

MySQL主从复制Duplicate entry报错全解析:从排查到修复 1. 这不是一句普通日志报错信息的三个关键维度凌晨三点告警群里弹出一条 PostgreSQL 之外的消息——没错是 MySQL 的复制监控。主从延迟告警刚触发紧跟着复制进程直接中断。登录从库执行SHOW SLAVE STATUS\G时Last_SQL_Error那一行写着[ERROR] Slave SQL for channel : Could not execute Write_rows event on table shop.orders; Duplicate entry 1001 for key orders.PRIMARY这条报错在数据库运维圈子里算得上“古典名场面”。它的核心含义并不复杂从库的 SQL 线程正在重放主库同步过来的二进制日志事件这个事件要在shop.orders表里插入或更新一行但这行记录的主键值已经存在于从库表中唯一索引冲突复制进程被迫停下。需要强调的是报错里的Slave SQL指向的是复制拓扑中从库侧的重放线程和业务侧连接、读写分离完全无关这个问题只发生在数据同步链路内部。先说channel这一项。MySQL 从 5.7 开始支持多源复制一个从库可以同时从多个主库拉取数据每个复制来源就是一个 channel。使用CHANGE MASTER TO ... FOR CHANNEL 名字可以指定通道名。没有显式指定通道时所有复制配置都在默认 channel 下而这个默认 channel 的名字在内部就是空字符串所以日志里显示为channel 。换句话说如果你只做了一主一从或者一主多从没有用多源复制看到channel 不需要慌它就是默认复制通道。如果配置了多源日志会明确标出通道名此时要把目光放到对应通道的源库和链路上去不是默认通道的问题。明确了通道概念之后再看报错的主体结构。Could not execute Write_rows event on table xxx.xxx描述的是执行主体和操作类型。Write_rows event 是 MySQL 行格式复制中的一种事件类型对应一次行插入操作。为什么是行格式因为主库的binlog_format设置为ROW时DML 操作会记录成行级变更事件比如插入一行就会生成一个 WRITE_ROWS_EVENT更新一行生成 UPDATE_ROWS_EVENT删除一行生成 DELETE_ROWS_EVENT。从库 SQL 线程读取中继日志中的这些事件按顺序重放到本地表一旦遇到键冲突就会中断。整条链路可以简单理解成主库把每一次增删改记录到 binlogI/O 线程拉到从库形成 relay logSQL 线程再一行行重放。现在问题出在最后一步。1.1 先抓住channel别在多源复制的边缘误判方向很多 DBA 第一次看到for channel 时会怀疑是不是复制配置丢了或者认为这是一个异常通道。实际上在单源复制的场景里MySQL 复制通道名称默认就是空字符串这一点在 5.7 和 8.0 中都一致。即使你只是跑了一主一从错误日志也会带有channel 字样。理解这点很重要排查时不要花时间在“通道有没有配置错”这种方向上打转而是去关注Duplicate entry后面的具体键值。如果实在不放心可以在从库执行SHOW REPLICA STATUS\G查看Channel_Name字段空表示默认通道。如果看到multi-source环境中某个具名通道报错那么排查范围就要锁定到对应主库。方向搞错后面所有的努力都可能白费。1.2 Write_rows event 和 SQL 线程复制链路里谁在报错需要理清复制链路中的角色。主库上binlog_formatROW后每次事务提交会生成一系列行事件。从库的 I/O 线程负责把主库 binlog 拉到本地中继日志SQL 线程负责把中继日志里的这些事件按顺序执行。报错中的Slave SQL就是 SQL 线程也就是回放线程。SQL 线程执行 Write_rows event本质上是想往目标表插入一行。表名xxx.xxx在真实场景中会显示为库名.表名比如上面例子里的shop.orders。如果从库表中已经存在相同主键InnoDB 存储引擎会直接拒绝插入返回错误码 1062提示Duplicate entry 1001 for key PRIMARY。这里的1001就是具体冲突的主键值是整个排查过程中最重要的线索先记下来后面会反复用到。1.3 Duplicate entry 才是真正停摆原因主键冲突让复制进程停下来很多人会把“主从延迟”和“复制中断”混淆实际上这条报错属于后者。SQL 线程在遇到键冲突时不会试图跳过而是停下并记录错误状态。后续所有新到的中继日志事件都会排队等待复制状态变为No从库数据滞后会持续扩大。为什么 SQL 线程不自动跳过因为在默认的STRICT模式下MySQL 会把这种冲突视为数据不一致的严重信号。如果直接忽略主从数据可能悄悄产生漂移后续事故影响面会更大。因此MySQL 选择将错误暴露出来由 DBA 决定是回放、修复数据还是跳过。处理这种问题时一个核心原则是先搞清楚“重复的键是从哪里来的”再决定怎么恢复不要一上来就无脑跳。2. 为什么复制会走到这一步我遇到过的四类重复键根因只有把根因搞清楚才能一劳永逸。运维越久越会发现Duplicate entry 背后不是单一原因而是多种运维习惯叠加导致的结果。下面这四类是这几年我在真实环境里反复见到的高频场景。2.1 从库被手动写入最常见也最隐蔽反复出现 1062 的那个表十有八九是因为从库被人绕过只读限制直接写入过数据。比如某天需要修复一个线上 bug同事临时连到从库执行了几条 INSERT插入的主键刚好和主库后续同步过来的数据重合复制立刻卡住。这类问题最隐蔽的点在于手动写入的动作可能发生得很早但冲突要等主库后续对这个主键写入时才会暴露。举个例子主库在凌晨 1 点插入了一条id1001的记录从库在早上 10 点有人手动插入了同一条id1001到了晚上主库更新这条记录并产生一个 Write_rows event从库 SQL 线程尝试再次写入冲突就发生了。排查时要多留意从库是不是处于完全只读状态不能只看连接层有没有账号限制。2.2 主从初始化不一致很多复制链路是从旧的备份或者某个时间点的快照拉起来的。如果备份本身不完整或者备份前主库存在未落盘的数据从库链路建起来后就会成为一颗“定时炸弹”。平时查询看着没问题一旦主库某些行更新从库找不到或者发现重复就会立刻中断。做过一次全量备份后再搭建从库的都知道稳定做法是先FLUSH TABLES WITH READ LOCK再配合mysqldump --single-transaction --master-data2保证一致性。很多线上事故恰恰是因为省略了锁表或事务参数或者用了一些不靠谱的第三方备份工具最终造成初始化数据与主库不一致。这类问题的特点是刚开始复制一切正常过几天甚至几周才猛然报错不仔细复盘根本想不到是初始化时留下的隐患。2.3 自增主键偏移和手动指定主键叠加一些业务系统会在插入数据时手动指定主键 ID或从外部系统导入带 ID 的数据。如果主库已经写到了自增值 2000而某次手工导入数据时指定了id2100那么接下来业务插入的自增主键可能从 2101 开始。数据本身不冲突但复制链路再叠加一次手工导入或回放就容易撞上。另一种情况是自增步长或偏移配置不一致。有些团队在主从环境里设置了auto_increment_increment和auto_increment_offset防止双写冲突但配置改动后没重启或者只改了主库没改从库一旦发生切换新的主库和旧从库之间就可能产生反复的主键冲突。这种问题只靠排查一个错误日志很难定位得结合表结构和库参数综合分析。2.4 级联复制和双主结构中事件被重复应用在主库到从库再到从库的级联复制链路里如果中间节点开启了log_slave_updates并且配置不当某些事件可能被应用两次。典型场景中间从库同时作为上层节点的从库和下层节点的主库某次操作通过手工跳转或异常恢复导致同一个 Write_rows event 被重复回放。双主结构下这个情况更明显。双主中两个节点都接受写入如果业务没有做严格的分区写入就可能出现两个节点写了同一个主键的记录。对端在复制对方数据时发现这个主键在本地已经有了自然的反应就是抛出 Duplicate entry并且无可自动修复的余地。这类场景的处理必须在应用层就确立清晰的数据分布规则没有唯一入口的双主永远是事故的高发地带。3. 从报警到定位我在一次典型故障中的排查全流程报错只是起点真正的难点在于如何在短时间内定位到“重复键到底多了谁、少了谁、谁是正确的”。下面是我总结的排查全流程以一个真实案例做演示——主库192.168.10.10从库192.168.10.20数据库shop问题表orders冲突主键1001。3.1 第一步通过 SHOW REPLICA STATUS 锁定错误上下文在从库上执行SHOW REPLICA STATUS\G注意MySQL 8.0.22 之后依然兼容SHOW SLAVE STATUS这个传统写法但新版本更推荐使用SHOW REPLICA STATUS。先看这几个关键字段字段名期望值异常解读Slave_IO_RunningYesNo 说明拉取 binlog 也出现了问题Slave_SQL_RunningNo本案例就是这里中断Last_SQL_Errno0非 0 代表 SQL 线程最近一次错误码Last_SQL_Error空本案例显示 Duplicate entry 1001Retrieved_Gtid_Set与主库比对判断 I/O 线程拉取了多少事务Executed_Gtid_Set与上面比对判断 SQL 线程执行到哪个位置Seconds_Behind_MasterNULL 或持续增长中断后通常为 NULL因为主从已不在同一节奏看到Last_SQL_Error里有Duplicate entry 1001后先不要急着操作下一步就是确认这个 1001 到底在主库和从库两个节点上各自是什么状态。3.2 第二步用二进制日志解析还原这条 Write_rows 事件做了什么从库 SQL 线程执行的是 relay log本质上是主库 binlog 的镜像。为了让问题还原得更清楚可以用mysqlbinlog把这个事件解出来。先看错误日志或SHOW REPLICA STATUS里的事件位置比如 relay log 文件名和 position然后执行mysqlbinlog --base64-outputDECODE-ROWS -v --start-position... /data/mysql/relay-bin.000012输出中会看到类似这样的内容### INSERT INTO shop.orders ### SET ### 11001 ### 2some_value这一下就能确认主库当时的操作确实是往orders表插入了一行主键为 1001 的数据。到这里问题已经很清楚了主库有这行数据从库当前也有一行同样的主键才导致插入失败。3.3 第三步主从数据比对确定谁多了、谁少了分别到主库和从库查询同一行SELECT * FROM shop.orders WHERE id 1001;在主库上执行确认这一行是否存在以及列内容是什么。再到从库上执行同样的查询如果从库也有这行说明从库上存在一条多余的记录。接下来要判断哪条数据才是业务需要的正主。如果从库这行是别人手动插入的测试数据直接删除即可。如果从库这行的内容与主库一致那说明之前是有人手动 insert 了同样数据现在主库重复回放自然冲突。如果内容不一致就要进一步判断该保留谁。对于关键业务表绝对不能只凭一台库的查询结果就下结论要和业务方确认行数据的来源及正确版本。我习惯用pt-table-checksum做一次更大范围的差异校验。它能计算主从各个表的数据校验和并对比出差异范围。虽然校验整个实例比较耗时但如果只是想快速验证单表可以加上表名限制pt-table-checksum --databasesshop --tablesorders --replicatepercona.checksums校验结果会告诉你主从哪些行存在差异。结合冲突主键信息基本可以确定修复方向。需要提醒的是大表校验会产生一定的读压力最好安排在业务低峰期或者加--max-load参数做限流。4. 恢复复制链路三种修复方式与我的推荐顺序定位完根因终于进入恢复环节。这一步很考验工程判断因为各种方式都有代价有些“捷径”后患无穷。下面分三种方式来讲同时给出我自己在实际运维中的选择优先级。4.1 跳过错最省事但要清楚你在放弃什么非 GTID 模式下最简单粗暴的恢复方式是STOP REPLICA; SET GLOBAL sql_slave_skip_counter 1; START REPLICA;执行成功后SQL 线程会跳过当前这一个事件继续往下跑。如果冲突只是偶发一次链路确实能恢复。但代价很明确你跳过的这个 Write_rows event 在从库上永远不会补上了。这意味着主从之间至少会存在一行数据差异后续如果业务查询到该行结果可能就不一致。在 GTID 模式下sql_slave_skip_counter不会再生效。需要构造一个空事务来“消耗”掉冲突事务STOP REPLICA; SET GTID_NEXT冲突事务的GTID; BEGIN; COMMIT; SET GTID_NEXTAUTOMATIC; START REPLICA;这种操作对 GTID 复制的影响更大因为它把事务标记为已执行从库上永久缺失这行数据。所以跳过错只适合“确认该行数据无关紧要”或“该行已存在且内容正确”的场景绝不是一个可以重复使用的常规手段。4.2 补齐数据或清掉多余行针对当前冲突行做手术这是我在大多数场景下的第一选择。既然冲突点很明确那就解决这一行。分两种情况处理如果从库有多余行、主库没有这行直接在从库上删除多余行再让复制继续。如果主库有这行、从库没有或内容错误从主库把这一行用mysqldump或 select 导出再导入从库然后恢复复制。示例操作-- 在从库停顿 STOP REPLICA; -- 先备份要处理的行 CREATE TABLE shop.orders_backup_20250101 AS SELECT * FROM shop.orders WHERE id 1001; -- 确认无误后删除冲突行 DELETE FROM shop.orders WHERE id 1001; -- 恢复复制 START REPLICA;主库侧导出的单行数据可以在从库侧通过INSERT或REPLACE导入。这里要注意如果主库后续还有对同一主键行的更新从库最终会通过后续事件把这些更新追平所以手工导入的数据只要主键正确即可其他字段可以不必和主库绝对一致后续事件会自动修正。有些团队喜欢用pt-table-sync来自动修复差异pt-table-sync --sync-to-master h192.168.10.20,upercona,Dshop,torders --execute但我不建议在未理解数据含义的情况下盲目执行。pt-table-sync会生成 SQL自动把从库数据改成主库状态如果误操作可能会覆盖业务还需要的中间数据。它更适合在“确认主库数据是唯一正确版本”的前提下使用。4.3 重建从库当积压严重且整体不一致时的止损手段如果冲突不只这一行而是大量存在或者从库早就被写乱了修复完这一行又冒出下一行那就别一处处打了果断重建从库收益更高。重建流程大致如下在从库上停止 SQL 线程避免继续推进。记录当前复制位置或 GTID 集合。在主库上做一致性备份mysqldump --single-transaction --master-data2 --all-databases /data/backup/full_$(date %F).sql将备份导入从库mysql -h 192.168.10.20 -uroot -p /data/backup/full_20250101.sql重新配置复制关系并启动CHANGE MASTER TO MASTER_HOST192.168.10.10, MASTER_USERrepl, MASTER_PASSWORD***, MASTER_LOG_FILEmysql-bin.000123, MASTER_LOG_POS456789; START REPLICA;GTID 模式下配置更简洁通常只需要MASTER_AUTO_POSITION1。重建期间需要关注从库业务影响如果从库同时承担读流量建议先切换流量或维护窗口操作不能让应用边写边重建。整个重建过程核心步骤就是“导出→导入→补追日志”数据量越大耗时越长需要提前评估。4.4 修复方式对比小结下面这张表是我每次给团队做复盘时都会贴的一张速查表直接拿来评估当前故障该用哪种方式修复方式操作复杂度数据风险适用场景我的推荐等级跳过错低高风险数据永久缺失偶发单条冲突且该行可丢失谨慎使用手工清行/补数中中风险需人工确认正确性冲突行明确、数据量小优先使用pt-table-sync中中风险可能覆盖业务数据数据差异明确且主库为唯一标准确认后使用重建从库高低风险全量重新同步冲突行多、从库数据整体混乱冲突严重时使用5. 让Duplicate entry不再是常态长期运维规范处理完这一次故障是运气能让类似问题不反复发生才见功夫。以下几条是我在多次踩坑后总结出的长期运维规范分享出来给大家参考。5.1 从库只读必须层层设防最简单的防线就是设置从库只读SET GLOBAL read_only ON; SET GLOBAL super_read_only ON;read_only可以挡住普通账号的写操作但超级权限账号依然能写。super_read_only则连超级权限账号也一并限制。只要不是维护窗口这两个参数都应当保持打开。对于线上主从前置建议在从库创建账号时哪怕对业务应用也只给 SELECT 权限避免误连误写。MySQL 8.0 里甚至可以试验SET PERSIST让参数持久化到配置文件防止重启丢失。5.2 变更与初始化之前先把一致性检查纳入流程搭建复制链路时备份、初始化、校验三步缺一不可。备份必须用一致性快照初始化之后马上用pt-table-checksum做一次全量校验确认没有暗伤才接入流量。变更流程上对主库表结构的 DDL 最好走统一的变更平台避免绕过审核直接执行。持续运行期间周期性做校验同样必要。对于千万行以内的业务表可以每周低峰期跑一次pt-table-checksum让复制进入稳定状态后再追一次差异对于超大表则拆表分批次校验避免一次任务拉满集群负载。复制一致性不是一次检查就能一劳永逸的事数据在持续变化差异随时可能悄悄积累。5.3 监控与告警要盯住哪些值只监控Slave_IO_Running和Slave_SQL_Running远远不够。从库状态发生翻转才能收到告警但那时错误已经造成排查成本早已拉满。我建议把以下指标都纳入监控SQL 线程运行状态Replica_SQL_Running断掉立即告警。Last_SQL_Errno和Last_SQL_Error出现非 0 即告警。复制延迟Seconds_Behind_Master持续超过设定阈值比如 30 秒触发中级告警。主从 GTID 集合差异从库Executed_Gtid_Set落后主库超过一定事务数时告警。告警要分级不是任何异常都拉爆炸群。像复制临时中断这种变化直接走电话告警延迟稍微波动可能只是大事务回放短信通知就够了。核心区别在于中断代表复制不可用延迟只是大队列正在消耗两者处理节奏不同。5.4 关于slave_exec_mode和slave_skip_errors我个人的态度有人会把slave_exec_modeIDEMPOTENT当作万能药它可以自动忽略 1062 重复键、1032 找不到行等复制错误让 SQL 线程继续跑。但这个参数启用后MySQL 将不再对冲突做任何提醒主从差异会在没有预警的情况下不断扩大。我见过一个项目开了IDEMPOTENT三个月之后主从对不上账最后只能全量重建。所以我对这个参数非常谨慎只在从库数据可以随时丢弃的场景才考虑使用业务核心库绝对不碰。slave_skip_errors同理它像一个全局开关会把指定错误码全部跳过。这种配置在历史上可能解决过一些问题但代价是脏数据长期潜伏等到业务报表出现异常时影响范围早已不可控。运维过程中保持对异常的感知远比用参数强行“消除噪音”重要。最后分享一条经验。有一次我在凌晨为了快速恢复业务连续执行了十几次sql_slave_skip_counter眼看着复制状态从 No 变成 Yes觉得问题解决了。第二天对账时发现从库少了十几条关键业务数据最后花了一天时间重建从库才追平。那次教训之后我再遇到 1062 报错一定会先问自己让你继续往下跳损失的是什么很多时候慢一点、稳一点才是真的快。
返回列表