先说结论:这条报错几乎每个维护过MySQL主从复制的人都碰到过,尤其在高并发写入、从库有历史遗留数据、或者做过主从切换的库上,出现的频率相当高。报错文字很典型:
[ERROR] Slave SQL for channel '': Could not execute Write_rows event on table xxx.xxx; Duplicate entry '1023' for key 'PRIMARY', Error_code: 1062; handler error HA_ERR_FOUND_DUPP_KEY; the event's master log ... end_log_pos ...新手看到这一坨容易懵,第一反应是“是不是主库出问题了”“是不是要重做从库”。我的建议是:先别慌,也别一上来就skip-counter一顿跳。这篇文章我会从报错本身的含义讲起,把“Duplicate entry”到底是怎么来的、为什么 SQL 线程会卡住、以及生产环境里用什么思路去处理,完完整整拆一遍。适合所有用 MySQL 主从复制、半同步复制、或者刚接手一套“前人留下的神秘从库”的朋友。
1. 报错拆解:Slave SQL 线程到底在执行什么
想要正确处理一个问题,先得看懂它。这条报错信息看着长,其实拆成几块就清楚了。
1.1 Write_rows event 在复制链路里的角色
MySQL 主从复制的核心机制,是主库把 binlog 传给从库,从库的 IO 线程把它写成 relay log,然后 SQL 线程再把 relay log 里的内容“重放”到本地数据文件里。当 binlog 格式是 ROW 的时候,主库上的每一次数据变更会被记录成对应的事件类型:
Write_rows_event:对应 INSERT 操作,表示插入新行。Update_rows_event:对应 UPDATE 操作,表示修改已有行。Delete_rows_event:对应 DELETE 操作,表示删除行。
你看到的Could not execute Write_rows event on table xxx.xxx,就是说 SQL 线程正在执行一条“插入某一行”的记录时失败了。这个操作本身已经是最小粒度的行级写入,MySQL 在执行它的时候不会再去区分你是插了一行还是插了一万行,报错卡住的位置,就是那个没法执行下去的行。
我用一个生活场景来解释:主库是个发货仓库,binlog 就是仓库发出的快递单,从库是收货门店。SQL 线程是门店里的收货员,他按快递单把货一件件摆上货架。现在快递单上写“货号 1023 放货架上”,结果门店货架上已经有一个编号 1023 的货,收货员不敢乱放,于是停下来喊:“这里重复了,我处理不了!”
1.2 Duplicate entry '1023' for key 'PRIMARY' 意味着什么
后半段是根因所在。Duplicate entry '1023' for key 'PRIMARY'的意思是:SQL 线程试图向目标表插入一条主键值为1023的记录,但表里已经存在主键为1023的行,唯一性约束被触发,插入被终止。
这里有个很多人忽略的细节:如果错误发生在二级唯一索引上,报错会写成for key 'uk_name'这样的索引名,而不是PRIMARY。所以看到PRIMARY时,你几乎可以确定是主键冲突,而不是业务唯一键的问题。
从库 SQL 线程在碰到错误后,默认行为是停止复制,等待人工介入。这是 MySQL 的自我保护机制——如果它自动忽略掉冲突继续往后跑,那么后续的更新、删除事件都可能作用在错误的数据状态上,主从数据会越差越远,最终变成一个“看起来在跑、实际数据全错”的脏库。很多线上事故就是这么来的:DBA 不在,同事顺手stop slave; set global sql_slave_skip_counter=1; start slave;跳了一次两次,当时看着恢复了,一个月后对账才发现有几十万行数据对不上。
1.3 channel '' 到底在提示什么
报错里还有一段容易被一带而过的信息:for channel ''。这里的 channel(复制通道)是 MySQL 5.7 之后引入的概念,用来支持多源复制——即一个从库同时从多个主库拉取数据。每个复制通道有独立的 relay log、独立的 SQL 线程和 IO 线程。
channel ''表示当前报错来自默认的复制通道,也就是没配置多源复制的普通主从链路。这个信息在排查时是有用的:如果你配置了多源复制,那么需要在处理报错时指定具体的 channel 名,否则命令只作用于默认通道,问题可能根本没被处理到。在 GTID 模式下,处理多通道复制故障的姿势和传统位点方式也有差异,后面我会单独讲。
2. 为什么从库会出现主键冲突:四个真实场景
理解了报错本身之后,下一步是想清楚“从库为什么会有这条记录”。这是处理问题的核心——如果你不搞清楚数据是从哪来的,就算跳过这一次,下一次大概率还会撞上,只是换个表、换一行而已。
2.1 场景一:从库被直接写入过
这是最常见的原因,没有之一。很多团队会拿从库应付报表业务、跑定时任务,开发为了方便直接在从库执行INSERT ... SELECT或者UPDATE,还有的把从库当成“测试环境”随手造数据。一旦主库上恰好也有相同主键的写入,主从复制立刻被卡住。
我见过一个真实案例:某报表库专门用来给数据分析师跑临时查询,有人为了补数据,在从库上手工插入了近三个月的历史订单记录。结果第二天主库一个业务补偿任务重放了同一批订单号,从库 SQL 线程当场停止,而且是连续卡了三天没人发现,因为监控只做了“主从是否延迟”的告警,没有做“SQL 线程是否 Running”的告警。最后重做从库花了大半天,白白背了一次 P2 故障。
2.2 场景二:relay log 重放导致重复执行
在传统位点复制模式下,如果从库的 SQL 线程执行完一组事务之后、还没有来得及更新relay-log.info文件时,从库突然宕机或断电,那么重启之后 SQL 线程会从上一个已记录的位点重新开始扫描。这意味着已经执行过的事务会被再执行一遍。如果事务里的 INSERT 是天然幂等的——比如REPLACE INTO——不会报错;但如果是普通INSERT,第二次执行就会触发主键冲突。
MySQL 官方把这种情况称为“复制中断后的重复执行”,在设计上它是不安全,但现实就是这样:宕机不可怕,可怕的是宕机后留下了“已经执行了一半”的复制状态。
2.3 场景三:数据初始化时的遗漏或不一致
另一种常见情况是从库不是通过标准方式搭建起来的,而是拿 mysqldump 导了一份数据,或者用 xtrabackup 恢复到某个备份点再配置复制,但备份数据和 binlog 的链接触不上,导致从库上的数据状态偏离主库在 binlog 中表达的逻辑。
打个比方:主库 binlog 里写的是“订单表目前有 1000 行,现在插入 1001 行”,但从库上实际已经有 1001 行了,还带着另外一条业务手工写入的记录。那么在重放这条插入事件时,如果主键恰好是 1001,就会直接撞车。
这类情况在逻辑备份恢复后尤其明显,因为逻辑备份的点、GTID 集合、日志位点之间的对应关系非常容易错位。新手拿mysqldump --master-data=2导出后,直接在目标库执行再配置复制,经常遇到这种坑。
2.4 场景四:主从切换/故障转移后的遗留问题
主从切换时,如果切换流程没有做好数据一致性校验,或者业务侧在主库降级为从库后、仍在一段时间内向原主库写入数据,那么两个节点都会产生新数据。当复制恢复时,原本的双主拓扑就会互相把对方的数据再同步一遍,冲突几乎是必然的。
我从生产环境观察到的规律是:几乎所有主从切换相关的主键冲突,都不是最近几分钟写进去的,而是切换之前的一个“时间窗口”(通常几十分钟到几小时)内,两边各自写入了重叠范围的数据。
3. 修复实操:从确认到处理的完整步骤
说完了原因,接下来是处理动作。我先声明一句:处理主从复制报错没有银弹,每一步都要围绕“确保主从数据最终一致”这个目标展开,而不是仅仅让报错消失。
3.1 第一步:确认错误和当前复制状态
看到报错后,不要急着操作,先跑一组命令摸清现状:
-- 查看复制状态 SHOW SLAVE STATUS\G重点看这几个字段:
Slave_IO_Running: Yes Slave_SQL_Running: No Last_SQL_Error: Duplicate entry '1023' for key 'PRIMARY' Last_SQL_Error_Timestamp: 2024-06-18 10:23:45 Exec_Master_Log_Pos: 439807134这里要同时确认两件事:IO 线程是否还正常(Slave_IO_Running: Yes),以及 SQL 线程停下来的位点。如果 IO 线程也已经断开,那么处理完当前报错后还要先排查网络或主库 binlog 传输问题,否则 SQL 线程恢复了也会因为拿不到新日志而停在原地。
接下来确认报错表的主键情况:
SELECT * FROM xxx.xxx WHERE 主键字段 = 1023\G SELECT binlog 里记录的该行内容第二句需要查看 binlog 内容,在从库上执行再说。如果主键值1023对应的是主库在该时刻写入的一行业务数据,而表里已存在主键为1023的、内容不一样的记录,那这就不是简单跳过能解决的——需要把从库上现有的、多出来的这条记录处理掉。
3.2 第二步:备份现场,这一步绝对不能省
很多人犯的致命错误,就是在没做任何备份的情况下,直接对从库执行了删除或修改操作。如果判断失误,删掉了一行“其实后面还会被同步用到”的数据,那这个从库基本就废了,只能重做。
在处理从库报错前,我习惯先把冲突行备份出来:
-- 在从库执行,把冲突行先存到备份表 CREATE TABLE xxx.xxx_backup_20240618 LIKE xxx.xxx; INSERT INTO xxx.xxx_backup_20240618 SELECT * FROM xxx.xxx WHERE 主键字段 = 1023;顺便记录一下当前复制位点:
SHOW SLAVE STATUS\G -- 记录 Exec_Master_Log_Pos 和 Relay_Master_Log_File做完这两步,再往后走你心里就有底多了——处理错了也能退回去。
3.3 第三步:比较主从数据,确定处理策略
处理策略取决于一个关键问题:这条冲突记录,到底是主库的多,还是从库的多?
- 如果从库多出来的是脏数据(手工插入、测试数据),而主库的数据才是真实的,那就删除从库的多余行,然后继续复制。
- 如果日志里要重放的行才是真实数据,从库上的现有行是误操作写入的,那同样要删除从库行。
- 如果这条记录在两个库都不是最新的——比如两边各写了不同的值——那就需要把两边的数据合并成一条正确记录,或者干脆两边都清理掉,再从备份或应用侧重新写入。
判断方法很简单:用SELECT到主库和从库各查一次这行的内容,你就能看出到底是谁对谁错。
-- 主库执行 SELECT * FROM xxx.xxx WHERE 主键字段 = 1023; -- 从库执行 SELECT * FROM xxx.xxx WHERE 主键字段 = 1023;如果两行内容不一样,就以主库为准,因为从库的职责就是和主库保持一致。
3.4 第四步:根据场景执行修复
场景A:冲突行是从库上的脏数据,主库上没有这一行,或者主库的行才是正确数据,那么处理方式如下:
-- 从库执行 STOP SLAVE; DELETE FROM xxx.xxx WHERE 主键字段 = 1023; START SLAVE;执行后观察:
SHOW SLAVE STATUS\G -- Slave_SQL_Running 应该恢复为 Yes如果还有下一个冲突,重复上面的流程,直到没有报错。
场景B:主库上根本没有这条数据,从库上也确认是脏数据,且确认对业务无影响,也可以直接删除。这里注意:如果你明确知道这条数据是某个应用在从库里创建的、业务上还需要它,那么你不能简单删除,而应该先把它补写到主库,再处理从库的冲突行。
举个例子:从库上的1023是一条报表用户手工登记的数据,主库没有。如果直接删了,业务方会来找你。正确做法是把这条记录写入主库,然后从库报错时跳过或者删除重复行,让数据重新对齐:
-- 主库执行 INSERT INTO xxx.xxx VALUES (...对应的1023行内容...); -- 从库执行 STOP SLAVE; DELETE FROM xxx.xxx WHERE 主键字段 = 1023; START SLAVE;这样复制恢复后,这条数据会通过 binlog 再次同步到从库,从库上就有了这条记录,且和主库一致。
场景C:做误删恢复时是从主库导出,从库本身已经丢失了部分数据,那“只删重复行”就不够了,可能需要较大范围的对账和补齐。这种场景下,我会直接把从库数据目录备份后重做,比逐行纠错快得多。
3.5 第五步:跳过事务(临时方案,必须谨慎)
如果你的判断是“这部分 binlog 里有事务,但从库数据已经是对的,不需要重放”,那可以跳过报错的事务。我一直强调,这只适合临时恢复复制,不治本。
传统位点复制模式下(没有开启 GTID),可以用:
STOP SLAVE; SET GLOBAL sql_slave_skip_counter = 1; START SLAVE;sql_slave_skip_counter = 1的含义是跳过 SQL 线程要执行的下一个事务。注意,是事务,不是事件。如果一个事务里有 100 条 INSERT,这个参数会直接跳掉整个事务,而不是只跳过那条报错的记录。这一点很容易被忽略,也是很多 DBA 在处理大批量写入冲突时跳了 N 次还没恢复过来的原因。
GTID 模式下的跳过方式则完全不同。你需要手动构造一个空事务,去占用掉那个冲突的 GTID,让 SQL 线程认为这个事务已经执行过了。具体做法我放到后面的常见问题里写。
注意:跳过事务之后,一定一定要在接下来的几天里做一次数据校验。跳过的代价是“从库放弃了这笔变更”,它不保证主从一致,只是保证复制不中断。
4. 这类故障的长期解法:能不能根治
我见过太多团队,在一个从库上报错就重做,重做完过一个月又坏,坏了再重做,形成了一个循环。复制报错的根本原因如果不解决,你永远在救火。下面是我认为对这种故障真正有效的防复发手段。
4.1 核心思路:从架构上杜绝“从库被写”
杜绝从库被直接写入,最彻底的办法是数据库账号权限控制。从库的复制账号、报表账号都应该是只读权限,任何写操作(INSERT、UPDATE、DELETE、DDL)都要走主库。
-- 在从库上创建只读报表账号 CREATE USER 'report'@'%' IDENTIFIED BY 'xxx'; GRANT SELECT ON *.* TO 'report'@'%';这样即使开发或者数据分析师想改从库数据,也会因为权限不足而失败,或者在报错后主动来找你处理。这是架构层的第一道防线。
4.2 全程开启 GTID,让复制更健壮
对比传统位点复制,开启 GTID 的复制在处理故障时更优雅,尤其是跳过事务时逻辑更清晰,主从切换时也不需要去定位 binlog 文件和位点。
检查是否开启:
SHOW VARIABLES LIKE 'gtid_mode'; -- 如果是 ON,表示已开启GTID 模式下,如果遇到同样的主键冲突,跳过事务的步骤是:
-- 查看冲突的 GTID SHOW SLAVE STATUS\G -- 找到 Retrieved_Gtid_Set 或 Executed_Gtid_Set 中最新的事务号 -- 停止复制 STOP SLAVE; -- 注入一个空事务,占用该 GTID -- 注意:请把 uuid:事务号 替换成实际值 SET GTID_NEXT='xxxx-xxxx-xxxx-xxxx:1023'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC'; START SLAVE;这个做法的原理是:SQL 线程启动后,看到这个 GTID 已经在Executed_Gtid_Set里存在,就会认为事务已经执行过,直接跳过。相比sql_slave_skip_counter,GTID 模式下跳过的是精确的、可追溯的单个事务,而不是模糊的“下一个事务”。
不过要说一句实话:即便在 GTID 模式下,跳过事务后你依然要关注主从数据一致性,GTID 只是让跳过的动作更精确,不会修复已经不一致的数据。
4.3 建立主从一致性巡检机制
比“修好一次报错”更重要的是“提前发现不一致”。推荐定期做以下巡检:
- 主从复制状态监控:
Slave_IO_Running和Slave_SQL_Running必须都是 Yes,任何一个变成 No 都要能立刻产生告警。 - 延迟监控:
Seconds_Behind_Master持续大于阈值时,先不要动,但要排查原因。 - 表级数据校验:对核心表定期执行
CHECKSUM TABLE,比对主库和从库的 checksum 是否一致。MySQL 的CHECKSUM TABLE在表数据量大时会比较耗性能,建议在业务低峰期执行,或者用 pt-table-checksum(Percona Toolkit 里的校验工具)分批校验。
以 Percona Toolkit 为例,主库执行:
pt-table-checksum h=主库IP,u=checksum_user,p=xxx \ --databases=xxx --tables=xxx \ --replicate=percona.checksums \ --chunk-size=1000然后在从库上查询percona.checksums表,找出differences不为 0 的表。这个工具的核心价值在于它可以分块校验大表,不会把主库 IO 打满,也是在从库追不上主库时的保命工具。
如果你不想引入额外工具,也可以用一条 SQL 对比结构一致的表:
-- 主库执行 SELECT COUNT(*) FROM xxx.xxx; -- 从库执行 SELECT COUNT(*) FROM xxx.xxx;计数一致是最低标准,但不是充分条件。两个库行数一样但内容不一样的情况很容易发生,所以最终可靠方案还是 checksum 级别的比对。
4.4 兜底:保留一个可以快速重建的从库
就算前面都做对了,生产环境不确定性依然存在。一个非常实用的习惯是:保留一份最近的基础备份(物理备份或者逻辑备份),并且验证过它可以成功恢复成一个可用的实例。这样当从库因为某种原因混乱到无法修复时,你的重建时间可以从半天降到一个小时以内。
我见过一个比较规范的团队,他们每周日晚上用 xtrabackup 做一次全量物理备份,保留最近 7 份,备份完成后再用脚本验证 backup 目录的完整性。万一需要重建从库,一条命令就能在十几分钟内拉到最新:
# 从备份节点把全量备份同步到新从库 xtrabackup --prepare --target-dir=/data/backup/20240618 xtrabackup --copy-back --target-dir=/data/backup/20240618 # 配置复制 CHANGE MASTER TO MASTER_HOST='主库IP', MASTER_USER='repl', MASTER_PASSWORD='xxx', MASTER_AUTO_POSITION=1; START SLAVE;5. 常见问题速查与避坑指南
最后整理一份我踩过坑之后总结的速查表,送给所有运维和 DBA 朋友。
5.1 问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| SQL 线程报 Duplicate entry on PRIMARY | 从库已有相同主键的行 | 比对主从数据,删除多余行或补写主库后跳过事务 |
| SQL 线程报 Duplicate entry on 二级唯一索引 | 从库已存在相同业务唯一键 | 按业务语义处理,确认哪条数据该保留 |
| STOP SLAVE; SET GLOBAL sql_slave_skip_counter=1; START SLAVE; 后依然报错 | 连续多个事务冲突 | 检查 binlog 事件数量,改用循环跳过或改成精确处理冲突行 |
| IO 线程也断开,SQL 线程恢复后延迟巨大 | 网络或 binlog 拉取异常 | 先确认 IO 线程,再处理 SQL 线程 |
| GTID 模式下不知道跳过哪个事务 | 没理解 Executed_Gtid_Set | 先从 SHOW SLAVE STATUS 中提取 Last_SQL_Error 对应事务的 GTID |
5.2 一个最容易踩的坑:sql_slave_skip_counter 跳多了
很多教程会告诉你SET GLOBAL sql_slave_skip_counter = 1;是跳过一条 SQL,实际上它是跳过下一个“事务组”。在传统位点模式下,SQL 线程以事件为单位执行,但 skip counter 是以事务为单位跳过的。如果一个事务里包含多个事件(比如一个存储过程调用里有多次 INSERT),你跳一个事务,等于把这几次插入全部忽略了。
我见过一个真实案例:一个事务里循环插入 10 万行,第一次执行到一半撞到主键冲突,复制中断。DBA 发现后执行了 skip counter = 1 并 start slave,结果复制立刻成功——因为这个事务被整体跳过,再也没有冲突了。表面看是恢复了,但这 10 万行里可能有 3 万行在从库上其实已经有了(只是行内容和主库不同),剩下 7 万行则彻底丢失了。后来做数据校验的时候才发现少了。这个操作本身就相当于告诉 MySQL:“我确认这个事务不需要同步”,有点像是抄作业时对老师说“我这一页不用写了”,老师如果信了,那你就真的少了这一页。
所以,sql_slave_skip_counter一定要慎用。如果不是非常确定事务的内容无关紧要,就不要用跳过方案。规范一点的做法是:先把冲突行备份,再处理冲突行,再让 SQL 线程继续跑。哪怕多做几步,也比事后校验数据发现对不上要强。
5.3 我个人的习惯:冲突处理三步法
处理这类复制故障这么久,我总结了一套自己的节奏,分享给你:
第一步,永远先STOP SLAVE,避免 SQL 线程停在报错现场后继续有新的并发介入。
第二步,备份冲突行到备份表,并记录位点信息。这一步五分钟内能做完,但能保证几乎所有误操作都能回滚。
第三步,才开始分析要不要删、要不要补、要不要跳。分析清楚了再操作,永远不要在“还没搞清数据关系”时就动手。
这套节奏看起来保守,但非常稳,尤其适合线上事故处理。你越急,越容易踩坑。
提示:执行
DELETE或INSERT操作前,记得确认当前连接的是主库还是从库。我见过不止一次,有人明明要在主库补数据,结果连的是从库,补完才发现复制又卡住了,而且这次卡得比之前更难看。
5.4 关于 Duplicate entry 的最终提醒
处理了多少次我都想强调:Duplicate entry从来不是一个 MySQL 故障,而是数据不一致的报警器。它的出现意味着主从之间早就存在数据差异,只是在这一刻被复制链路撞上了。好多人只盯着“从这个错误里恢复出来”,忽略了去追问“为什么从库里会有这一行”。如果你不问,下次换张表、换个主键、换个时间点,它还会再来。