监控全绿,集群已死:一次 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 logs | donor 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_RETRIES | 0 | 没有冲突 |
| 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 的状态机只认"通道是否连上"、"事务是否应用",它不知道底下磁盘满了。
于是:
- 通道
ON✓ - 无错误信息 ✓
- 无重试计数 ✓
- worker 都活着 ✓
- 事务一个都不应用
所有监控项都是健康色,只有"进度"这一项是死的。而大部分巡检脚本,恰好不检查进度。
一个反直觉的验证技巧
不要只看 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"。结果:
- 软链接被替换成普通文件,指向关系断了
- 属主变成
root:root - SELinux 上下文丢失(
ls -Z权限位后面那个.消失)
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>;
两边相同,说明断点之前的数据是完整迁移过来的。
最后几句
这次事故里,我花了最多时间的地方,不是"怎么修",是"为什么所有指标都正常但就是不干活"。
我的三个错误假设,都是被"健康"的指标喂出来的:
- ❌ "recovery 通道只传增量不传历史" → 实际是磁盘满,写不进 relay log
- ❌ "那一万多笔缺口永远补不上,必须 Clone" → 空间恢复后自动补齐
- ❌ "
Replica_SQL_Running = Yes说明复制正常" → 它可能只是在等一个永远不会来的事务
三个假设有个共同点:我把"状态"当成了"进度"。
状态是布尔值,进度是单调递增的计数器。状态可以骗人(通道 ON、线程 Yes、无错误),进度不会。
所以后来我的巡检里多了一条:不看状态,看差值。
-- 每 60 秒采一次,两次相减
SELECT @@GLOBAL.gtid_executed;
10 分钟不动,就是死了,不管它怎么自报健康。