主从切换失败?查查 GTID 集合——MASTER_AUTO_POSITION 的隐性陷阱
场景:MC 自动切换后原主恢复,CHANGE MASTER TO MASTER_AUTO_POSITION=1 时报错 1872——master 已清除从库需要的 binlog 路径:GTID 自动定位协议 → gtid_executed 差集运算 → gtid_purged 边界条件
上篇讲了 Seconds_Behind_Master 的监控谎言——延迟不等于数据落后。这篇我们来看 GTID mode 下切换时一个更隐蔽的坑:自动定位协议在 binlog 被清除后,会直接拒绝连接。
【现象】凌晨 2 点,MC 切换卡住了
一主一从,MySQL 8.0.32,GTID 模式。主库 10 万 TPS 跑了大半天。凌晨从库因硬件故障触发 MC 自动切换——老主被降级,从库提升为新主。这本该是 MC 最常规的操作。
等老主恢复后,DBA 执行 CHANGE MASTER 让它作为新从库重新接入集群——直接报错:
CHANGE MASTER TO
MASTER_HOST='192.168.1.100',
MASTER_USER='repl',
MASTER_PASSWORD='xxx',
MASTER_AUTO_POSITION=1;

Last_IO_Errno: 1872
Last_IO_Error: The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1, but the master has purged the binary logs containing GTIDs that the slave requires.
从库的 IO 线程根本连不上。注意看——SHOW SLAVE STATUS 显示 Slave_IO_Running: No,但 Slave_SQL_Running: Yes。IO 线程断了,SQL 线程还在等。这就是典型的"看起来在复制,实际数据已经断流"。
用 GTID_SUBTRACT 看一眼就知道缺了多少:

Master 有 482917 个 GTID,从库只有 472810,差了 10107 个。其中前 34610 个(438201-472810)已经被 master 的 binlog 过期机制清理了。
半年前我们用 file-based 复制时从来没遇到过这个问题。因为 file-based 复制至少还能通过人工指定 MASTER_LOG_FILE 和 MASTER_LOG_POS 绕过去。GTID 模式下,MASTER_AUTO_POSITION=1 一旦握手失败,连手动覆盖的余地都没有。
【解析】GTID 自动定位不是协商,是单向赠送
GTID 模式下的 CHANGE MASTER 不需要指定 MASTER_LOG_FILE 和 MASTER_LOG_POS。slave 连接 master 时,自动定位协议做了三件事:
1. Slave 把自己的 @@GLOBAL.GTID_EXECUTED 发给 Master
2. Master 计算差集:Master.GTID_EXECUTED - Slave.GTID_EXECUTED
3. Master 把差集中的事务发给 Slave

可以把这个协议想象成一个借书场景:
你去图书馆(Master),告诉管理员"我已经看过这些书了"(GTID_EXECUTED)。管理员从馆藏里减去你看过的,把剩下的借给你。但如果那本书已经下架销毁了(GTID_PURGED),管理员只能说"对不起,你要的书没了"。
这个交集运算的含义是:"master 有的、slave 还没有的,全部发过去"。
但这里隐含一个前提——master 必须还有这些事务的 binlog。
gtid_executed 记录的是"这个实例执行过哪些 GTID",它是一个逻辑索引,不保证数据还在。GTID 对应的实际 binlog 可能已经被 binlog_expire_logs_seconds 或手动 PURGE BINARY LOGS 清理了。如果 master 的 gtid_purged 集合包含 slave 需要的 GTID,那 step 2 算出差集,step 3 却发现 binlog 文件已经不存在了——1872 错误代码就在这个时候抛出。
数据分布
| 指标 | 值 |
|---|---|
| Master GTID_EXECUTED | 3e47a2b1-...:1-482917 |
| Master GTID_PURGED | 3e47a2b1-...:1-438200 |
| Slave GTID_EXECUTED | 3e47a2b1-...:1-472810 |
| 被清除的 GTID 范围 | 438201-472810(约 3.4 万个 GTID) |
| binlog_expire_logs_seconds | 86400(24 小时) |
Slave 需要的 GTID 438201-472810 已经被 master 的 binlog 过期机制清理了。差集算出来 438201-482917,但前 3.4 万个 GTID 对应的 binlog 文件已不存在。
关键在这里:不是 master 没有这个 GTID,而是 master 有这个 GTID 的记录但已经没有对应的 binlog 文件了。
所以 1872 不是 GTID 协议的问题——它是 binlog 保留策略的问题,只是在 GTID 模式下以更隐蔽的方式暴露出来了。
为什么 file-based 复制没这个问题
file-based 复制时,slave 记录的是 "我需要从 mysql-bin.000123 的 458762 位置继续读"。如果这个文件被 purge 了,IO 线程同样报错。但区别在于:
- file-based:错误信息直接告诉你"mysql-bin.000123 文件不存在"——连接失败的原因一目了然
- GTID mode:错误信息说"master 没有你需要的 GTID"——刚接触 GTID 的人会困惑"GTID 不是全局唯一的吗?master 怎么可能没有?"
GTID 给了人一种"智能协商"的错觉——好像 GTID 能自动解决 binlog 不一致的问题。但事实是:GTID 自动定位只解决"位置漂移",不解决"数据不存在"。
版本说明
MySQL 8.0 的 binlog_expire_logs_seconds 默认 2592000(30 天)。但如果 slave 断开超过这个时间——或者 master 配置了 binlog_expire_logs_seconds=86400——slave 重新连接时就会触发 1872。
5.7 行为相同,但 8.0 的错误信息更精确地标出了缺失的 GTID 范围。如果你还在用 5.7,排查 1872 的难度比 8.0 大——错误信息没那么友好。
两种复制模式在切换流程上的核心区别可以用一张图看清楚:

GTID 的自动定位在正常运行时碾压 file-based——不需要对位置、不会偏、自动跳过已执行事务。但故障恢复时,file-based 有一个 GTID 没有的优势:可以手动指定位置重试。GTID 只要握手失败,没有"跳过这个事务继续"的选项。
【排查与修复】三个方向,一个决策树
碰到 Error 1872 时,先按这个逻辑选方案:

核心原则:能用备份解决的不要用空事务,能用空事务解决的不要用手动 pos。
方向一:重新建立同步点
如果 master 还有完整的备份,这是最推荐的方式:在 master 上通过备份重建从库,让 gtid_executed 从当前 master 的最新状态开始。
# Master 上创建一致性备份(带 GTID 信息)
$ mysqldump --all-databases \
--gtid-purged=AUTO \
--master-data=2 \
--single-transaction \
--routines --triggers --events \
> backup.sql
# 检查备份中的 GTID_PURGED
$ head -50 backup.sql | grep GTID_PURGED
SET @@GLOBAL.GTID_PURGED='3e47a2b1-...:1-482917';
# 从库导入
$ mysql -uroot -p < backup.sql
# 建立同步
mysql> CHANGE MASTER TO MASTER_HOST='192.168.1.100',
MASTER_USER='repl', MASTER_PASSWORD='xxx',
MASTER_AUTO_POSITION=1;
mysql> START SLAVE;

--gtid-purged=AUTO 会自动把 master 当前的 GTID_EXECUTED 写入备份。从库导入后 GTID_EXECUTED 与 master 对齐,自动定位协议算出差集为 0(即使 master 有比备份更新的 GTID,也能从当前位置继续)。
适用场景:从库数据量小(< 500GB)、有完整备份窗口。这是最干净的重建方式。
方向二:注入空事务跳过(当重搭不可行时)
如果数据库太大(TB 级),没法重搭:
-- 1. 在从库查看缺失的 GTID 范围(从报错信息提取)
-- Master.GTID_EXECUTED: 1-482917
-- Slave.GTID_EXECUTED: 1-472810
-- GTID_PURGED: 1-438200
-- 缺失: 438201-472810
-- 2. shell 生成注入 SQL
$ for i in $(seq 438201 472810); do
echo "SET GTID_NEXT='3e47a2b1-1234-11ee-aabb-00163e00a1b2:$i';"
echo "BEGIN; COMMIT;"
done > inject_empty.sql
-- 3. 从库执行
mysql> SOURCE inject_empty.sql

-- 4. 验证对齐
mysql> SELECT GTID_SUBTRACT(
'3e47a2b1-...:1-482917',
'3e47a2b1-...:1-472810'
);
-- 5. 重新建立同步
mysql> CHANGE MASTER TO MASTER_HOST='192.168.1.100',
MASTER_USER='repl', MASTER_PASSWORD='xxx',
MASTER_AUTO_POSITION=1;
mysql> START SLAVE;
这个操作的风险必须说清楚:注入空事务意味着告诉 MySQL"这个 GTID 已经处理过了",但实际对应的数据变化并没有在从库上重放。数据的一致性完全依赖你的判断。只有在确认从库数据已经和 master 一致时才能用——比如 crash 前所有 relay log 已全部 apply,只是 master 侧 binlog 被 purge 了。
风险等级:高。如果你不确定从库数据是否完整,千万不要走这条路。
方向三:降级到 file-based 复制(临时方案)
CHANGE MASTER TO
MASTER_HOST='192.168.1.100',
MASTER_USER='repl',
MASTER_PASSWORD='xxx',
MASTER_LOG_FILE='mysql-bin.004372',
MASTER_LOG_POS=154,
MASTER_AUTO_POSITION=0;
但 file-based 的位置需要手动从 master 的 SHOW BINARY LOGS 中找到一个从库已经应用过的位置点。在 master binlog 已被部分清除的情况下,这个方法实操难度大——你可能需要从备份中的 --master-data=2 信息推算。
适用场景:方向一/二都不行时,用 file-based 临时顶上,在低峰期再重建。
【原理升华】真正该问的是什么
从这条案例可以提炼出一条原则:
GTID 自动定位不能解决"没有 binlog"的问题——它只能解决"有 binlog 但位置漂移"的问题。
GTID 做的唯一一件事是把"文件+偏移量"的物理指针换成了"GTID 集合"的逻辑指针。它让 slave 不用关心 binlog 文件名和偏移量了,但如果 binlog 文件本身没了,逻辑指针也指不到数据。
MASTER_AUTO_POSITION=1 给了三个显性便利: - 切换时不用手动指定文件和位置 - 支持任意拓扑切换(主→从、从→主、从→从) - 自动跳过已应用的 GTID,不会重复执行
但也带来三个隐性约束: - 依赖 master 的 binlog 保留策略:本文的核心论点 - GTID 集合不对称时可能被拒绝:从库比主库多 GTID 时可能触发 ER_FOUND_EXISTS(error 1756) - 故障场景下恢复路径更窄:file-based 至少可以手动算偏移量,GTID 一旦握手失败,没有"手动覆盖"的选项
隐性对手线
| 层级 | 认知 | 文章对应段落 |
|---|---|---|
| Naive | "GTID 切换比 file-based 更可靠,因为不需要手动对位置" | 现象段:以为 CHANGE MASTER 就完事了 |
| 有经验 | "知道有 MASTER_AUTO_POSITION=1,遇到过 1872 但不知道怎么彻底避免" | 解析段:理解了交集运算的边界 |
| 正确 | "GTID 自动定位解决的是位置追踪问题,不是 binlog 可用性问题——binlog 保留策略是独立的风险维度" | 原理升华段:binlog 保留策略决定一切 |
如果再遇到类似错误(Error 1756)
1872 是"master 没有 slave 需要的 binlog",还有一个更隐蔽的兄弟错误:ER_FOUND_EXISTS(error 1756)。
1756 的场景是:slave 的 GTID_EXECUTED 里包含了 master 不认识的 GTID——比如 slave 曾经被短暂提升为 master,产生了自己的 GTID,然后又被降级。当 slave 带着这些"私生子 GTID"去连接新 master 时,master 的自动定位协议无法处理这种不对称。
Master.GTID_EXECUTED: a:1-100
Slave.GTID_EXECUTED: a:1-100, b:1-5
↑ 这些是 slave 被提升期间自己生成的
从库的 SQL 线程会直接报错 1756:

注意和 1872 的区别:1872 的 IO 线程报错、SQL 线程还正常运行;1756 的 IO 线程正常运行、SQL 线程报错。一个是连不上,一个是连上了但跑不了。
这种情况下,你需要用 GTID_SUBTRACT 找出不对称的部分:

-- 找出 slave 多出来的 GTID
SELECT GTID_SUBTRACT(
'a:1-100,b:1-5', -- slave 的 GTID_EXECUTED
'a:1-100' -- master 的 GTID_EXECUTED
);
-- 输出: b:1-5
关键区别:Error 1872 修复是往从库 GTID_EXECUTED 里加缺失的 GTID(注入空事务),而 1756 修复是从从库 GTID_EXECUTED 里移除多余的 GTID(RESET MASTER 或重搭)。方向完全相反。
金句:
"GTID 自动定位不是协商——master 不会问 slave'你要什么',而是告诉 slave'我有什么多的'。如果多的已经扔了,协商就崩了。"
【检测】你的环境里什么时候会碰到这个问题
在生产中排查这个问题的触发条件:
#!/bin/bash
# gtid-risk-check.sh — 检测 Error 1872 风险
MASTER_HOST="10.0.1.100"
# 1. 检查 master 的 binlog 保留时间
mysql -h $MASTER_HOST -e \
"SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';"
# 2. 检查 master 的 GTID_PURGED 范围
mysql -h $MASTER_HOST -e "SELECT @@GLOBAL.GTID_PURGED;"
# 3. 按从库检查:GTID_PURGED 是否覆盖从库 GTID
for SLAVE in "10.0.1.101" "10.0.1.102"; do
S_GTID=$(mysql -h $SLAVE -NBe \
"SELECT @@GLOBAL.gtid_executed")
M_PURGED=$(mysql -h $MASTER_HOST -NBe \
"SELECT @@GLOBAL.gtid_purged")
RISK=$(mysql -NBe \
"SELECT GTID_SUBTRACT('$S_GTID','$M_PURGED')")
if [ -z "$RISK" ]; then
echo "$SLAVE: OK(所有 GTID 都在 master 的保留范围内)"
else
echo "$SLAVE: WARNING - 以下 GTID 可能已被清除: $RISK"
fi
done
# 4. 搜索错误日志中的 1872
grep -i "error 1872\|ER_SLAVE_HAS_MORE" \
/var/log/mysql/error.log

三个关键提醒:
① binlog 保留时间必须大于最大切换窗口
如果你的 MC/orchestrator 设计切换窗口是 24 小时,binlog_expire_logs_seconds 至少设 172800(48 小时)。不要只设 86400——24 小时刚好等于切换窗口,slave 断 25 小时就出事了。
② GTID_PURGED 增速要监控
binlog_expire_logs_seconds 设了不代表没事。如果 master 的 TPS 过高,binlog 文件大小增长快,可能触发 max_binlog_size 限制 + 轮换,GTID_PURGED 会以更快速度推进。建议用 Prometheus 采集 gtid_purged 的变化率,在异常增速时告警。
③ 配置自动切换工具时,把 binlog 保留时间加进 SLA MC/orchestrator/MySQL InnoDB Cluster 都有自己的健康检测和切换策略。但它们不会替你检查 binlog 保留策略。如果切换工具发现 master 宕机后立刻提升从库,而原 master 的 binlog 保留期刚好比宕机时间短——1872 是必然事件。
下篇我们聊半同步复制超时导致的数据丢失——主库认为从库已确认的事务,从库其实还没落盘。