MySQL MGR 全组失联恢复复盘

/ 数据库

监控全绿,集群已死:一次 MySQL MGR 全组失联的 15 小时复盘

业务恢复用了 20 分钟。三节点全部归位,用了 15 个小时。


开局

11-10 晚上,三节点 MySQL MGR 集群全组失联。.130 / .131 / .132,两主一从,全 OFFLINE,组不存在。

这不是某一台宕机,是组的概念本身没了 —— performance_schema.replication_group_members 里空空如也。业务侧表现是全部写请求失败。

一个小时后,.132 + .131 组成 2/3 多数派,业务恢复。 第二天早上 10:40,.130 也 ONLINE,三节点全绿,数据零丢失。

看起来是个还不错的结局。


先别急着修:把故障定性做对

MGR 出问题,第一步不是敲命令,是分清它属于哪一类。因为不同类别的处置方式完全相反。

现象定性走向
全部成员 OFFLINE,组不存在组丢失需要重新 bootstrap
单成员 ERROR / UNREACHABLE单点故障修节点后 START GROUP_REPLICATION
成员 RECOVERING 卡住 + 日志有 purged required binary logsdonor binlog 已被清只能 Clone
出现 2 个 PRIMARY脑裂立即冻结业务,不要盲目 STOP
报 ERROR 3092成员参数不合规通常是 binlog_checksum != NONE

我们这次是第一类。组丢失 ≠ 数据丢失 —— 这一点很重要,它意味着三台机器上的数据都还在,只是"谁说了算"这件事没结论。

判定完类别,才轮到选路线:

组丢失
  │
  ├─ 有节点数据完整、且其余节点能「增量追上」?
  │     ↓ 是 → 【增量恢复】数据零丢失,最快
  │     ↓ 否
  ├─ 有足够磁盘做全量重建?
  │     ↓ 是 → 【Clone】以最完整节点为 donor 重建其余节点
  │     ↓ 否
  └─ 【止血单机】单节点对外,放弃集群一致性,先保业务

我们走了增量,因为两台节点的 GTID 都能被 .132 的 binlog 覆盖。这个判断是对的,但它也把我引向了后面那个 15 小时的坑。


20 分钟让业务回来

第一个坑:binlog_checksum 被注释掉了

START GROUP_REPLICATION 直接报 ERROR 3092。

翻配置文件,发现是这样:

#binlog_checksum=NONE

注释状态 = 没配置 = 用默认值 CRC32。而 MySQL 8.0.x 的 MGR 要求成员必须是 NONE。

更隐蔽的是,performance_schema.variables_info 里显示 VARIABLE_SOURCE = COMPILED —— 它连"来自配置文件"都不是,是编译默认值。所以任何"看配置文件里有没有写"的检查都会漏掉这一项。

SELECT VARIABLE_NAME, VARIABLE_SOURCE, VARIABLE_PATH
FROM performance_schema.variables_info
WHERE VARIABLE_NAME = 'binlog_checksum';

教训:查参数要看 @@变量 的实际值 + variables_info 的来源,不要看配置文件里"有没有那一行"。注释掉的行和没有这一行,在运行时是一回事。

第二个坑:report_host 用了主机名

MGR 成员之间要广播自己的地址。report_host 留空或写主机名时,nss-myhostname 会把它解析成 fe80:: 开头的 IPv6 链路本地地址 —— 这个地址在组里根本不可达。

表现极其阴险:成员能进组,但永久 RECOVERING,error log 里一句错都不报。

-- 改前
report_host = mysql-node130
-- 改后
report_host = 10.111.182.130

空 GTID 注入:必须在 bootstrap 之前

这一步是整个恢复里最容易做错的地方。

两台节点上有 errant 事务(组外事务),MGR 会拒绝有组外事务的成员加入,报 ERROR 3098。标准做法是注入空事务占位,把 GTID 账本对齐。

关键约束:必须在 bootstrap 之前做。bootstrap 之后注入会报 ERROR 3540,而且这个错误无法回退。

-- 在 .132 上,bootstrap 之前
SET GTID_NEXT = 'bb1f5a8c-9b25-11ee-928e-00163e010c73:425038';
BEGIN; COMMIT;
SET GTID_NEXT = 'd14f10ab-9b25-11ee-8c97-00163e011008:1';
BEGIN; COMMIT;
-- ... 共 6 组
SET GTID_NEXT = 'AUTOMATIC';     -- ← 这一行漏了,后面全乱

最后那句 SET GTID_NEXT='AUTOMATIC' 绝对不能漏,漏了之后所有后续事务都会挂在这个 UUID 上,等于亲手制造新的 errant。

Bootstrap:1.13 秒

-- 只在 .132 上,只执行一次
SET GLOBAL group_replication_bootstrap_group = ON;
START GROUP_REPLICATION;
SET GLOBAL group_replication_bootstrap_group = OFF;   -- 立刻关掉

1.13 秒 后 .132 成为 ONLINE / PRIMARY。

接着 .131 加入,1.42 秒 转 ONLINE / SECONDARY。

2/3 多数派成立,业务恢复。此时距离开始改配置,约 20 分钟。

bootstrap_group=ON 只允许在一台上开,且必须马上关。两台同开 = 脑裂,只能清空重来。


后面发现:.130 卡在 RECOVERING

.130 最后一个加入,它落后一天多,需要 distributed recovery 补历史。

19:39 进入 RECOVERING。 19:40 GTID 在推进(393197179 → 393201047),一切正常。 20:00 之后,不动了。

然后就是,第二天早上观察,竟然没有完全恢复。

所有指标都在说"我很好"

这是我这几年见过最迷惑的一组状态:

观察项显示值我当时的解读
SERVICE_STATE(recovery 通道)ON通道正常
LAST_ERROR_MESSAGE空没有错误
COUNT_TRANSACTIONS_RETRIES0没有冲突
8 个 applier worker全 ON、APPLYING在干活
error log无复制相关报错一切正常
gtid_executed零增长← 唯一不对劲的地方

于是我得出了一个看起来很合理的结论:

"recovery 通道只传增量、不传历史事务,.130 缺的那一万多笔永远补不上,必须 Clone 重建。"

这个结论是错的。 它让我准备了整套 Clone 方案,评估了 7–9 小时的窗口,差点在业务低峰期动手。

转折:一句 ERROR 1114

第二天上午,为了确认某个参数,随手敲了一句:

mysql> SHOW PLUGINS;
ERROR 1114 (HY000): The table '/data/mysql-files/#sql9f62_272c_1' is full

is full。

我当时的第一反应不是"磁盘满了",而是"临时表空间有问题"。因为 SHOW PLUGINS 这种查询,正常机器上连临时表都不会建。

然后:

$ df -h /data
Filesystem                  Size  Used Avail Use% Mounted on
/dev/mapper/vg02-lv02_data  2.0T  2.0T   20K 100% /data

/data 满了。只剩 20 K。

清理之后,它自己好了

# 删掉旧备份
rm -rf /data/mysql-backups/2026xxxx      # 释放 126 G

# 清 binlog
mysql> PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 1 DAY);

Avail 从 20K → 126G → 561G。 *然后我没有做任何 STOP/START,.130 在 10:40 自己转成了 ONLINE。

那一万一千多笔缺口,GTID_SUBSET 验证了全部补齐。


为什么"磁盘满"能伪装成"MGR 故障"

这是整件事最值得记下来的一点。

因为 InnoDB 层的空间不足,不以复制错误的形式报出。

relay log 写不进去,applier 线程就在那里等。它等的不是"报错",是"资源"。而 MGR 的状态机只认"通道是否连上"、"事务是否应用",它不知道底下磁盘满了。

于是:

所有监控项都是健康色,只有"进度"这一项是死的。而大部分巡检脚本,恰好不检查进度。

一个反直觉的验证技巧

不要只看 GTID 数字。在权威节点插一行数据,等落后节点 ONLINE 后去查它。

-- .132 上,recovery 开始之前
INSERT INTO mgr_verify.t(note, ts) VALUES ('before-recovery', '2026-11-10 19:54:32');

.130 恢复后,这一行如果在它上面能查到,说明 distributed recovery 真的把历史补全了。

这比 GTID 数字更有说服力:GTID 对齐证明的是"账本对上了",数据能查到证明的是"东西真的落地了"。这两件事在异常场景下可能不一致。

时间账

19:16  开始改配置
19:36  2/3 多数派成立,业务恢复          ← 20 分钟
19:39  .130 进入 RECOVERING
次日 09:01  发现磁盘 100% 满             ← 15 小时,全部耗在这里
次日 10:40  三节点全绿

如果第二天早上第一件事是 df -h /data,这 15 小时就是 15 分钟。


另外 6 个坑,一次说完

前面讲过两个(binlog_checksum、report_host),加上磁盘满,还剩 6 个。这些坑的共同特点是:方案文档里全都没有,全是现场撞出来的。

坑 1:/etc/my.cnf 是符号链接,sed -i 会改坏

/etc/my.cnf  ->  /usr/local/mysql/my.cnf     (23 字节的链接)

sed -i 的实现是"写临时文件 + rename"。结果:

mysqld 可能读不到配置,或者读到一份不同的配置。

正确做法:

cp -L /usr/local/mysql/my.cnf /root/my.cnf.bak.$(date +%m%d%H%M)
sed 's/old/new/' /root/my.cnf.bak.xxx > /tmp/my.cnf.new
cat /tmp/my.cnf.new > /usr/local/mysql/my.cnf    # 用 > 覆盖,保留 inode

坑 2:cp -a 做的备份是假的

cp -a 会保留符号链接属性。所以 cp -a /etc/my.cnf /etc/my.cnf.bak 得到的备份也是一个链接,指向同一个文件。

你改真文件,它跟着变。diff 永远显示"无差异"。

这个坑很致命:你以为有回退点,其实没有。

cp -L /etc/my.cnf /root/my.cnf.bak       # -L 跟随链接,复制实体
md5sum /etc/my.cnf /root/my.cnf.bak      # 双向确认

坑 3:recovery 账号不叫 group_replication_recovery

按默认命名去查:

SELECT user, host FROM mysql.user WHERE user LIKE 'group_repl%';
-- Empty set

查不到,于是判断"账号缺失",准备了一整套 CREATE USER 方案。

实际上账号叫 gr_user@%,凭据早就存在 group_replication_recovery 通道里了,根本不用重建。

正确做法是不猜名字,直接问通道:

SELECT CHANNEL_NAME, USER, HOST
FROM performance_schema.replication_connection_configuration
WHERE CHANNEL_NAME = 'group_replication_recovery';

坑 4:errant 事务是 5 笔,不是 1 笔

肉眼扫 gtid_executed,看到 d14f10ab:1,就以为只有 1 笔。

实际是 d14f10ab:1-5。

如果只注入 1 笔占位,第二台加入时会报 ERROR 3098(还剩 4 笔组外事务)。

而且:393014741 被我看成了 393003156。数字太长,人眼不可靠。

两个正确的工具:

-- 判断「这个段的首笔是否已存在」
SELECT GTID_SUBSET('d14f10ab-...:1', @@gtid_executed);     -- 1 = 存在

-- 拿精确差集
SELECT GTID_SUBTRACT(@@gtid_executed, 'd14f10ab-...:1-999');

返回 0 / 1,不要靠眼睛。

坑 5:grep -q "GTID_NEXT" 永远为真

想在某段 binlog 里找目标事务,写了:

mysqlbinlog binlog.029xxx | grep -q "GTID_NEXT" && echo "found"

任何文件都会命中,因为 mysqlbinlog 会为每个不匹配的事务插入一行:

SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */;

所以循环扫第一个文件就 break,得出"没找到"的假结论。

正确写法是匹配 UUID 前缀:

mysqlbinlog binlog.029xxx | grep -qE "GTID_NEXT= 'd14f10ab"

坑 6:大表 CHECKSUM TABLE 在落后节点上必然不同

ycsmy_virtual_user,736 万行,3.5 GB。两台机器算校验值,差了几亿。

第一反应:数据损坏。

第二反应(正确):落后节点缺一天半的写入,校验值当然不同。

大表校验要换成"断点前旧区间的行数":

SELECT COUNT(*) FROM ycsmy_virtual_user WHERE id <= <落后节点的 max_id>;

两边相同,说明断点之前的数据是完整迁移过来的。


最后几句

这次事故里,我花了最多时间的地方,不是"怎么修",是"为什么所有指标都正常但就是不干活"。

我的三个错误假设,都是被"健康"的指标喂出来的:

  1. ❌ "recovery 通道只传增量不传历史" → 实际是磁盘满,写不进 relay log
  2. ❌ "那一万多笔缺口永远补不上,必须 Clone" → 空间恢复后自动补齐
  3. ❌ "Replica_SQL_Running = Yes 说明复制正常" → 它可能只是在等一个永远不会来的事务

三个假设有个共同点:我把"状态"当成了"进度"。

状态是布尔值,进度是单调递增的计数器。状态可以骗人(通道 ON、线程 Yes、无错误),进度不会。

所以后来我的巡检里多了一条:不看状态,看差值。

-- 每 60 秒采一次,两次相减
SELECT @@GLOBAL.gtid_executed;

10 分钟不动,就是死了,不管它怎么自报健康。