MySQL 主从延迟监控的顶级谎言:Seconds_Behind_Master

场景:从库 Seconds_Behind_Master=0,业务反馈刚写入的数据查不到。主从同步监控显示一切正常,实际数据延迟了 3 分钟。 路径:SBM 更新机制 → 时钟偏差多线程复制/relay log 堆积 → GTID 双指标 → pt-heartbeat 方案 → 监控代码替换

上篇我们聊了 MySQL 隔离级别选型——RC 和 RR 在并发写入场景下的血泪教训。这篇从主从复制说起:一个几乎所有 MySQL 用户都踩过的坑——Seconds_Behind_Master。

凌晨 2 点,告警平台响了。 不是 CPU 爆了,不是 OOM,是业务线反馈:用户在 APP 下单后查不到订单,持续了接近 3 分钟。等 DBA 登录从库一看,复制状态全是 Yes,SBM=0。每个看起来都正常,但数据就是没到。 这种"正常但数据不一致"的幽灵问题,比明确告警更难排查——因为所有监控工具都说它没问题。

mysql> SHOW SLAVE STATUS\G
*************************** 1. row ***************************
             Slave_IO_State: Waiting for master to send event
                Master_Log_File: mysql-bin.000123
            Read_Master_Log_Pos: 458762
               Relay_Log_File: relay-bin.000456
                Relay_Log_Pos: 458762
        Relay_Master_Log_File: mysql-bin.000123
             Slave_IO_Running: Yes
            Slave_SQL_Running: Yes
          Seconds_Behind_Master: 0

Seconds_Behind_Master=0,一切正常——对吗? 但这时候在主库 INSERT 一条数据,从库 3 分钟后才查得到。

如果你是刚入门的 DBA,看到 SBM=0 你会放心。如果你有几年经验,你知道 SBM 不完全靠谱。但如果你真的踩过这个坑,你会发现:SBM 不是"不完全靠谱",是大多数场景下完全不能信。

以下分析基于 MySQL 8.0.32,5.7 和 8.0 的差异会单独标注。

SHOW SLAVE STATUS 输出

SBM 的计算逻辑听起来极其简单:从库 SQL 线程执行完一个 relay log event 后,拿当前系统时间减去该 event 在 Master 上记录的时间戳。

公式:SBM = now() - event_timestamp

但这条公式在源码层面的执行路径,每一环都有坑。

时间戳从哪来:binlog event header 的 4 个字节

每个 binlog event 的头部都有一个固定格式,由 MySQL 源码 libbinlogevents/include/event_header.h 定义:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       timestamp (4 bytes)                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|      event_type       |      server_id (4 bytes)              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     event_length (4 bytes)                     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ... payload (variable length) ...                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

前 4 个字节是 event 创建时的 Unix timestamp(秒级精度)。由 Master 在写入 binlog 时打上,Slave 用这个时间戳和当前系统时间做差。

这里已经有第一层精度损失:timestamp 精度只有秒级。一个事务提交在 10:00:00.001,event header 里写的是 10:00:00。Slave 在 10:00:01.999 执行完这个 event,SBM 可能是 0 或 1——因为两边都是向下取整,时间差被吞掉了最多 2 秒。

第二层:不是每个 event 都有独立的时间戳。在一个事务内部,BEGIN、行变更、COMMIT 等 event 共享同一个 timestamp(事务开始的时间)。如果一个事务包含 1 万行变更,最后一行变更的 event 和第一行的时间戳一样——但实际它们跨越了可能几秒的执行时间。

这里我已经给了两层精度损失,但 SBM 还有更大的问题。

SQL 线程 event loop:SBM 只在特定时刻更新

SBM 不是实时指标。它只在 SQL 线程从 relay log 取出一个 event 并执行完成后才计算一次。这段逻辑集中在 MySQL 源码的 sql/rpl_slave.cc 中。简化后的 event loop:

while (running) {
    event = read_next_event_from_relay_log();   // ← 可能阻塞(relay log 已空)
    apply_event(event);                          // ← 执行这个 event
    calc_seconds_behind_master();                // ← 刷新 SBM —— 只有这步才更新
    update_positions();                          // 更新位点
}

关键:SBM 的更新频率 = SQL 线程执行 event 的频率。如果 SQL 线程因为 relay log 为空而阻塞在 read_next_event_from_relay_log(),SBM 不会更新——它保持上次执行完的值。

这个"保持旧值"的行为在实践中比你想的更危险:

T0: SQL 线程执行完最后一个 event,SBM=0
T0~T3: relay log 为空,SQL 线程等待
T3: IO 线程拉取到一个新 binlog event,写入 relay log
T3~T5: SQL 线程执行这个 event,SBM=now()-event.timestamp=0(因为 event 时间戳接近当前时间)
T5: relay log 再次为空,SQL 线程等待

T0~T5:SBM 始终显示 0
但 Master 在 T0~T5 之间可能已经写入了 50 个新事务。

SBM 不测量 IO 线程从 Master 拉取了多少数据,它只测量 SQL 线程还有多少没执行完。

Seconds_Behind_Master 更新时序

上图中的关键时间窗口: - T0:Master 写入 binlog - T1:IO 线程收到并写入 relay log(SBM 盲区) - T2:SQL 线程执行 event 并刷新 SBM - T3~T5:SBM=0,但新的 binlog 还在网络上

SBM=0 只代表 SQL 线程无积压,不代表 IO 线程无积压,更不代表 Master 和 Slave 数据一致。

Seconds_Behind_Master 不是延迟测量工具——它是一个 SQL 线程空闲指示器。

时钟偏差:SBM 对系统时钟的脆弱依赖

SBM 的计算依赖主库和从库的系统时间差,但这两个服务器的 clock 几乎不可能精确同步。

# 主库时间比从库快 30 秒
# event timestamp = 2026-07-26 10:00:30
# slave 当前时间   = 2026-07-26 10:00:00
# SBM = -30  → MySQL 把负数处理为 0

# 主库时间比从库慢 30 秒
# event timestamp = 2026-07-26 10:00:00
# slave 当前时间   = 2026-07-26 10:00:30
# SBM = 30  → 虚高 30 秒

MySQL 官方文档明确写了:如果主从时钟不一致,SBM 不可信。

你可以直接检验你的环境:

-- 主库时间
SELECT NOW() AS master_time, @@GLOBAL.server_id;

-- 从库时间
SELECT NOW() AS slave_time, @@GLOBAL.server_id;

-- 看差值

大部分团队的 MySQL 服务器没有统一 NTP,或 NTP 配置了不同的时间源。两个服务器之间的时钟差几分钟到几十分钟很常见。在这种环境下谈 SBM 没有任何意义。

但还有一个更隐蔽的问题:即使 NTP 同步了,NTP 本身的同步机制也有误差。 ntpdate 做时间跳跃(可能导致 SBM 瞬间跳变),ntpd 做时间渐变(在渐变期间 SBM 持续不准确)。云服务器的 chrony 通常误差在 1-10ms 级别——对大多数应用来说够了,但对秒级精度的 SBM 来说,这个误差会被放大到不可接受的水平。

多线程复制下的语义漂移

MySQL 8.0 默认启用 slave_parallel_workers=4(MTS——Multi-Threaded Slave)。

单线程复制时,SBM 的语义是清晰的:最后一个执行完的 event 离现在多久。

MTS 下,多个 worker 并行重放 relay log event。SBM 的计算方式变了——它不再取最后一个 event,而是取所有 worker 中 last_event_timestamp 的最小值:

# MTS 模式下 SBM 的实际计算逻辑
workers_last_time = [
    worker_1.last_event_timestamp,   # 10:00:00
    worker_2.last_event_timestamp,   # 09:59:50
    worker_3.last_event_timestamp,   # 09:59:40  ← 最慢的 worker
]
sbm = now() - min(workers_last_time)  # = now() - 09:59:40

看到问题了吗?按这个公式,SBM 会显示最慢 worker 的滞后时间。

但在实际实现中,slave_preserve_commit_order 参数加剧了这个问题。当这个参数为 ON,worker 必须按 Master 上的事务提交顺序提交到 relay log。如果 worker-3 先执行完但必须等 worker-1 的提交完成才能提交自己的,这段时间 worker-3 已经更新了自己的 last_event_timestamp,但 SBM 的最小值依然卡在较早的时间点上。

worker-1: event@10:00:00(正在等待 worker-2 提交)
worker-2: event@09:59:50(正在重放)
worker-3: event@09:59:40(已执行完,但等待 worker-1 先提交)

SBM = now() - 09:59:40  → 这个值看起来正常(有延迟),但不反映哪个 worker 导致了延迟

与之相关的还有 slave_parallel_type 参数:

类型 MySQL 版本 逻辑 对 SBM 影响
DATABASE 5.6+ 同一 database 内的 event 串行执行,不同 database 并行 SBM 波动相对小
LOGICAL_CLOCK 5.7+ 基于事务的锁信息判断并行度 SBM 波动大,依赖 workload

LOGICAL_CLOCK 模式下,SBM 的不确定性更高:如果两个事务在 Master 上没有任何锁冲突,它们可以在 Slave 上完全并行。但如果 workload 突然变成串行密集型(大量 DDL 或行锁争用),并行度急剧下降,SBM 可能瞬间跳高。

直接检查每个 worker 的实际进度:

SELECT WORKER_ID, LAST_APPLIED_TRANSACTION_END_TIMESTAMP,
       LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP,
       APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP
FROM performance_schema.replication_applier_status_by_worker
ORDER BY LAST_APPLIED_TRANSACTION_END_TIMESTAMP ASC;

MTS 模式下,SBM 的计算语义从"一个游标的位置"变成了"多个游标的最小值"——这不是同一个指标,也不能用同样的方式来理解。

Relay log 堆积:SBM 的盲区

当 IO 线程拉取 binlog 的速度远超 SQL 线程消费速度时,relay log 持续堆积。但 SBM 不受 relay log 大小影响。

Relay_Log_Space=2GB(严重堆积)
SQL 线程当前在处理 event@10:00:00(10 分钟前的 event 还没执行)
SBM = now() - 10:00:00 = 30 秒

30 秒的 SBM,2GB 的 relay log 堆积——这 30 秒告诉你"轻度延迟",但 2GB 告诉你"快撑不住了"。

Relay_Log_Space 高但 SBM 低

relay_log_space_limit 参数是一个容易被忽略的陷阱。它的默认值是 0(不限制),但如果有设置,当 relay log 大小触达限制时,IO 线程会停止拉取新 binlog,直到 SQL 线程消费释放空间。在这个阶段,SBM 不会突然跳高——它只是缓慢增长,看起来像轻度延迟,但整条复制链路实际上已经半瘫痪了。

SBM 的问题不是"它有时候不准",而是你没法知道它什么时候准、什么时候不准。

大事务场景:SBM 的一个独特表现

有一种场景会让 SBM 出现完全没有意义的数值:大事务。

假设 Master 执行了一个 DELETE FROM orders WHERE create_time < '2025-01-01',删除了 500 万行。在 binlog_format=ROW 模式下,这会产生一个巨大的单个事务,所有变更写入一个或多个 binlog event。

Master 端:
BEGIN  → event@10:00:00
变更 100 万行 → 写入 binlog event
...
变更 500 万行 → 写入 binlog event
COMMIT  → event@10:02:30(事务实际结束时间)

SBM 在这个事务中表现:
从库收到第一个 event(10:00:00),开始重放
在重放过程中,SBM 持续增长(now() - 10:00:00)
等到所有 500 万行重放完成,SBM 可能显示 3 分钟甚至更高

关键误导:SBM 的 3 分钟不等于"从库落后主库 3 分钟"
它只说明"这个事务重放了 3 分钟"——而 Master 上这个事务也执行了 2.5 分钟

更常见的例子是 ALTER TABLE 在 pt-online-schema-change 下,触发 trigger 产生的批量更新。一个大 DDL 引起的 SBM 飙升,80% 的情况下不代表从库真的"落后"了——它只是在如实执行这个大事务。

大事务场景下,GTID 缺口检测同样会受影响:如果这个大事务是一个 GTID 编号,那么在它完成前,GTID 缺口始终存在。但 relay log 堆积量是可靠信号——IO 线程可能几秒就拉完了,但 SQL 线程需要几分钟来重放。

那为什么大家都在用 SBM?

这不是设计失误,是设计假设和实际使用的错位。

Seconds_Behind_Master 在 MySQL 4.0/5.0 时代被引入,那时: - 单线程复制是唯一模式(无 MTS 语义模糊) - 主库 QPS 在几百到几千,远低于今天的上万甚至几十万 - 网络链路简单,relay log 堆积很少出现 - 服务器数量少,NTP 同步相对容易保证

那个场景下,SBM 基本靠谱。

但在今天: - MTS 是默认配置,SBM 语义已变 - 主库 QPS 上万,大事务频繁,relay log 堆积成为常态 - 云原生架构下主从跨可用区甚至跨地域,网络延迟波动更大 - 大部分团队连主从时钟同步都没检查过

SBM 没有变——是数据库架构变了,它被时代抛下了。

MySQL 官方也一直在提示 SBM 的局限性。MySQL 8.0 引入 performance_schema.replication_applier_status 等更精确的表,但默认的 SBM 字段无法轻易替换。它仍然是 SHOW SLAVE STATUS 中最显眼的字段,仍然是大多数监控工具的默认告警指标。

正确的延迟检测方案

放弃 SBM 作为唯一指标。建立双指标体系。

指标一:基于 GTID 的精确延迟检测

MySQL 5.6+ 引入 GTID(全局事务标识符),格式为 server_uuid:transaction_id。每个事务分配一个全局唯一 ID。通过比较主库和从库的 gtid_executed 集合,可以直接算出从库还缺哪些事务——不依赖时钟,不依赖 event 执行速度,纯粹的事务级精确测量。

-- Master 端
SELECT @@GLOBAL.gtid_executed;
-- +-------------------------------------------+
-- | 3E11FA47-1FCA-11EC-B2A8-0242AC130002:1-100 |
-- +-------------------------------------------+

-- Slave 端
SELECT @@GLOBAL.gtid_executed;
-- +-------------------------------------------+
-- | 3E11FA47-1FCA-11EC-B2A8-0242AC130002:1-95  |
-- +-------------------------------------------+

从库缺少事务 96-100,共 5 个事务。这就是真实延迟——不受时钟偏差影响,不受 MTS 排序影响,不受 SBM 刷新时机影响。

gtid_executed 会包含多个 server_uuid:
3E11FA47-1FCA-11EC-B2A8-0242AC130002:1-5000,
A7B23C11-2DCA-12EC-C3B9-0342AC130005:1-200

GTID_SUBTRACT 会同时处理所有 uuid 的交集和差集

GTID 延迟检测脚本

补充指标:gtid_purged 的边界

gtid_purged 记录了已从 binlog 中清理的事务 GTID。在大多数场景下 GTID_SUBTRACT(master_gtid, slave_gtid) 已足够,这个函数内部已处理了 gtid_purged 的边界。但如果你的主库在从库连接到它之前已经 purge 了不少 binlog,gtid_purged 需要作为参考。

-- 检查主库 GTID 范围
SELECT GTID_SUBTRACT(@@GLOBAL.gtid_executed, @@GLOBAL.gtid_purged) AS active_gtid_set;

GTID 监控方案生产注意事项

几个在真实环境中会遇到的问题:

1. GTID_SUBTRACT 在超大规模 GTID 集合上的性能

如果你的 Master 已经运行了好几年,gtid_executed 可能包含几十个 server_uuid,每个 uuid 的事务编号范围可能达到亿级。GTID_SUBTRACT 函数需要在内存中解析这些集合,当两个集合差距很大时,计算可能耗时几百毫秒甚至几秒。

经验值:在包含 15 个 server_uuid、事务编号到 5000 万的 GTID 集合上,GTID_SUBTRACT 的典型耗时是 50-200ms。这对监控采集中可以接受(每分钟执行一次),但不适合高频(每秒执行)的实时检测。

2. 非 GTID 环境的替代方案

如果 gtid_mode=OFF(大量现存 MySQL 5.5/5.6 集群不上 GTID),GTID 检测不可用。替代方案是文件+位点对比:

-- Master 端
SHOW MASTER STATUS;
-- +------------------+----------+--------------+------------------+
-- | File             | Position | Binlog_Do_DB | Binlog_Ignore_DB |
-- +------------------+----------+--------------+------------------+
-- | mysql-bin.000123 | 458762   |              |                  |
-- +------------------+----------+--------------+------------------+

-- Slave 端:看 Relay_Master_Log_File + Exec_Master_Log_Pos
SHOW SLAVE STATUS\G
-- Relay_Master_Log_File: mysql-bin.000123
-- Exec_Master_Log_Pos: 458762

-- 如果两者一致 → 从库执行到了 Master 的最新位点
-- 但这仍然不精确(无法判断从库是否真的执行了所有事务)

文件+位点方式比 GTID 落后一代,但在非 GTID 集群上是唯一可用的方案。

3. 通过 Prometheus mysqld_exporter 获取 GTID 指标

mysqld_exporter 默认不暴露 GTID 指标。需要启用 --collect.global_status 并添加 PromQL:

# recording rule: 计算 GTID 缺口的事务数量
- record: mysql_gtid_gap_count
  expr: mysql_global_status_gtid_executed{instance=~"slave.*"}
  # 需要 exporter 端处理 GTID 集合的解析,或通过外部脚本暴露

实际部署中更常用的方式是通过 cron 脚本计算缺口,push 到 Prometheus Pushgateway。

指标二:Relay log 堆积量

Relay log 堆积是一个简单有效的延迟信号——如果 SQL 线程消费速度跟不上 IO 线程拉取速度,relay log 持续增长。

-- 从 performance_schema 直接获取
SELECT VARIABLE_VALUE AS relay_log_space_bytes
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Relay_log_space';

-- 从 SHOW SLAVE STATUS 间接获取
SHOW SLAVE STATUS\G
-- Relay_Log_Space: 524288000   ← 500MB

关键监控规则: Relay_Log_Space 持续增长(每 5 分钟上涨超过 100MB),即使 SBM=0,也从库存在复制延迟。

-- relay log 文件数量(多文件堆积也是信号)
SHOW VARIABLES LIKE 'relay_log_space_limit';
-- 默认 0 = 不限制

建议:在从库配置 relay_log_space_limit=4GB,同时监控当前使用率超过 80% 时告警。

但 relay log 堆积检测也有盲区:当 IO 线程拉取速度本身就很慢(比如网络带宽不足),relay log 也可能一直很小,但延迟已经很大了。所以 relay log 堆积必须配合 GTID 缺口一起使用。

指标三:pt-heartbeat(最推荐的生产方案)

Percona Toolkit 的 pt-heartbeat 是目前生产环境最靠谱的复制延迟检测方案。

原理:

  1. 在主库创建心跳表,定期写入带主库时间戳的记录
  2. 从库读取这条记录(通过复制同步过去),用从库当前时间减去主库时间戳
  3. 差值就是真实延迟
Master: "我 10:00:00.002 写了这条记录"
       └── binlog ──→ relay log ──→ SQL thread ──→ Slave table
Slave: "读到记录是 10:00:03.150,差值 3.148 秒"

为什么不依赖时钟同步: 从库读到的 tsv 是主库写入时打的时间戳。计算在从库上完成:now() - tsv。虽然用了从库的 now(),但 tsv 是主库的时间——它们是同一个参考系下的两个时间点,不跨机器比较。

# 主库启动心跳写入(每秒一次,后台运行)
pt-heartbeat --update --daemonize --interval=1 \
  -h master-host -u monitor -p '<password>' -D percona

# 从库检测延迟
pt-heartbeat --check -h slave-host -u monitor -p '<password>' -D percona
# 0.02s

# 大事务批量写入后
pt-heartbeat --check -h slave-host -u monitor -p '<password>' -D percona
# 12.47s

# 同时段的 SBM
mysql -h slave-host -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master
#         Seconds_Behind_Master: 3

pt-heartbeat 的心跳表结构:

CREATE TABLE percona.heartbeat (
  id        INT PRIMARY KEY,
  master_id VARCHAR(64) NOT NULL,
  file      VARCHAR(255) DEFAULT NULL,
  position  BIGINT DEFAULT NULL,
  relay_master_log_file VARCHAR(255) DEFAULT NULL,
  exec_master_log_pos   BIGINT DEFAULT NULL,
  tsv       DECIMAL(26,6) NOT NULL  -- ← 微秒级精度的时间戳
);

核心字段 tsv 存储主库写入时的精确时间戳(微秒精度)。从库程序计算 now() - tsv。这个计算在从库上完成,tsv 来自主库,所以不受时钟偏差影响。

pt-heartbeat 的性能影响:

--interval=1(每秒写入 1 条),对主库的写入负载几乎可忽略不计。但如果你有 100 个从库,每个从库都通过 pt-heartbeat --check 读同一张表,读负载会集中在从库上。

经验值:100 个从库同时 --check,从库的 percona.heartbeat 表会产生约每秒 100 次查询。这个负载对现代 MySQL 实例来说微不足道(< 0.1% QPS)。

高级用法:--check 也支持 --master-host 参数,可以直接在主库上检查任意从库的延迟,适用于集中式监控架构。

pt-heartbeat 检测结果

三种方案的优缺点对比

方案 优点 缺点 适用场景
GTID 缺口 精确到事务级;零额外写入负载 需要 GTID 模式;大集合上计算慢;不能反映执行时间 所有 GTID 集群的基础检测
Relay log 堆积 简单;反映 IO 线程积压 不能反映执行延迟;盲区(IO 线程本身慢) 配合 GTID 使用
pt-heartbeat 毫秒级精度;不依赖时钟;反映真实执行延迟 额外写入负载;需要安装 Percona Toolkit 生产环境首选

最佳实践:GTID 缺口作为事务级基础检测 + pt-heartbeat 作为秒级实时延迟检测 + Relay log 堆积作为 IO 线程积压检测 = 零盲区。

半同步复制下的 SBM 表现

半同步复制(Semisynchronous Replication)在 MySQL 5.5+ 引入,5.7+ 通过插件形式支持。在半同步模式下,Master 提交事务后,必须等待至少一个 Slave 确认收到 binlog event 后才返回客户端成功。

这会影响 SBM 吗?直接影响不大,但间接影响值得注意。

半同步复制不影响 SBM 的计算逻辑——SBM 仍然由 SQL 线程在 event 执行后计算。但半同步复制保证 IO 线程的 relay log 积压不可能太大(因为 Master 会等 Slave 确认后才提交)。

这会创造一个新的陷阱:半同步复制 + SBM=0 会让你觉得"复制的延迟肯定很小",但半同步只保证 binlog 传输到了 Slave 的 relay log,不保证 SQL 线程已经执行了。如果 Slave 上恰好有一个大事务在执行,半同步模式下的 SBM=0 和异步模式下的一样不可信。

MySQL 5.7 新增的 rpl_semi_sync_master_wait_for_slave_count 参数可以配置 Master 等待多少个 Slave 确认。但这同样不解决 SQL 线程消费延迟的监控问题。

大事务场景的复制定性

大事务是复制延迟最常见的诱因,但也是 SBM 最容易被误解的场景。

一个常见的误解:DBA 看到 SBM 飙高,执行 SHOW PROCESSLIST 发现 SQL 线程在 Running,以为是复制延迟。但实际查看 performance_schema.replication_applier_status_by_worker,发现 worker 正在执行一个来自 3 分钟前的事务——SBM 的 3 分钟不是"落后",是"这个事务正在执行"。

区分标准: - 如果 GTID 缺口包含多个事务编号 → 确实有积压 - 如果 GTID 缺口只有 1 个编号且 SBM 很高 → 大事务执行中,不是积压 - 如果 Relay_Log_Space 持续增长 → IO 线程积压 - 如果 Relay_Log_Space 稳定且 GTID 缺口为空 → 无延迟

大事务在 binlog_format=ROW 模式下对 relay log 的影响也值得注意:ROW 模式下,一个大的 UPDATE/DELETE 可能产生几 GB 的 binlog event。传输这个 event 到 Slave 的过程本身就需要时间。如果网络带宽是 1Gbps,传输 2GB 的 event 至少需要 16 秒。这段时间内 IO 线程持续活跃,但 SQL 线程可能已经空闲——SBM 显示 0。

告警策略如何设置

解决了用什么指标的问题,还需要回答阈值设多少。

场景 指标 阈值 响应
正常复制 pt-heartbeat < 1s 信息级 无操作
轻度延迟 GTID 缺口 1-5 个事务 警告级 检查大事务
中度延迟 pt-heartbeat 1-10s 或 relay log 持续增长 警告级 DBA 介入
严重延迟 pt-heartbeat > 30s 或 relay log 超过 2GB 告警级 立即处理
灾难 GTID 缺口 > 1000 或 relay log 超 limit P0 告警 停读从库

不要设置单一的 SBM 阈值。SBM 在 0 和 300 之间动态波动太普遍了,用它做告警会导致大量误报和漏报。

推荐的三指标汇合告警规则:

groups:
- name: mysql_replication_alerts
  rules:
  - alert: MySQLReplicationGap
    expr: mysql_gtid_gap{instance=~".+"} > 50
    for: 1m
    labels:
      severity: warning
    annotations:
      summary: "从库 GTID 缺口已超过 50 个事务"

  - alert: MySQLRelayLogGrowing
    expr: rate(mysql_global_status_relay_log_space_bytes[5m]) > 1000000
    for: 2m
    labels:
      severity: warning
    annotations:
      summary: "relay log 持续增长,速率超过 1MB/s"

  - alert: MySQLReplicationStopped
    expr: mysql_slave_status_slave_io_running != 1
           or mysql_slave_status_slave_sql_running != 1
    for: 0m
    labels:
      severity: critical
    annotations:
      summary: "从库复制线程已停止"

GTID 缺口告警 + relay log 增长告警 + 复制线程状态告警,三者配合才能覆盖复制延迟的全场景。

进阶:基于 Machine Learning 的异常检测

如果你的集群规模较大(50+ replicas),可以考虑基于历史数据的基线告警。GTID 缺口数在不同时段波动很大——凌晨低峰期可能一直是 0,白天高峰期 10 个缺口算正常,50 个才算异常。

使用 Prometheus 的 predict_linear 函数可以预测 relay log 的增长趋势:

- alert: MySQLRelayLogWillFill
  expr: predict_linear(mysql_global_status_relay_log_space_bytes[1h], 3600) > 4000000000
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "relay log 预计 1 小时内将超过 4GB"

这个告警在 relay log 快速增长时可以提前预警,而不是等它满了再通知。

从 SBM 迁移到新方案的步骤

如果你的监控系统目前基于 SBM,替换需要分 3 步走:

第一阶段:双写兼容(第 1 周)

保持现有 SBM 告警不变,部署新指标。在 Grafana 中并排放 SBM 仪表盘和新指标仪表盘。收集 SBM 和新指标之间的差异数据。

* * * * * /usr/local/bin/check-gtid-gap.sh 2>&1 | logger -t gtid-gap

第二阶段:切换告警源(第 2 周)

将告警规则从 SBM 切到新指标。SBM 降级为 info 级观察。

- alert: MySQLSBMDeviation
  expr: mysql_slave_status_seconds_behind_master > 30
  labels:
    severity: info  # 从 warning 降级

第三阶段:移除 SBM(第 3 周)

确认新指标稳定一周后,从所有监控仪表盘和告警规则中移除 SBM。

如何改造你的监控系统

# grep 你的监控代码——找到所有用 Seconds_Behind_Master 的地方
grep -rn "Seconds_Behind_Master\|sbm\|seconds_behind_master" \
  --include="*.py" --include="*.sh" --include="*.yaml" \
  /path/to/monitoring/

# 预期发现在:
# - prometheus mysqld_exporter 的告警规则
# - 自研监控脚本(shell / python)
# - 自动化巡检平台的告警配置
# - Grafana 面板的查询语句

替代策略速查表:

原指标 替代方案 数据类型 精确度
SBM < 30 → 正常 GTID_SUBTRACT 为空 整数 精确到事务级别
SBM > 300 → 告警 pt-heartbeat > 5s 浮点数 毫秒级
SBM 单告警 GTID 缺口 + relay log 体积 + 复制状态 三指标汇合 无盲区
SBM 仪表盘 pt-heartbeat 趋势 + relay log 增长率 时序图 秒级刷新

判断是否已替换完毕:如果报警群里还有 SBM 相关的告警,说明你还没改完。如果它消失了,你成功了。

下篇我们聊 GTID 模式下主从切换失败——GTID 集合不一致导致切换后数据损坏的完整排查过程。

附:完整命令清单

# 1. 查看 SBM 和相关字段
SHOW SLAVE STATUS\G

# 2. 查看 GTID 集合(主从分别执行)
SELECT @@GLOBAL.gtid_executed;
SELECT @@GLOBAL.gtid_purged;
SELECT GTID_SUBTRACT(@@GLOBAL.gtid_executed, @@GLOBAL.gtid_purged) AS active_gtid;

# 3. 检查 relay log 堆积趋势
mysql -e "SHOW SLAVE STATUS\G" | grep -E "Relay_Log_Space|Seconds_Behind_Master"
watch -n 30 'mysql -e "SHOW SLAVE STATUS\G" | grep -E "Relay_Log_Space|Seconds_Behind_Master|Executed_Gtid"'

# 4. 查看从库复制线程状态
SELECT * FROM performance_schema.replication_applier_status\G
SELECT WORKER_ID, LAST_APPLIED_TRANSACTION_END_TIMESTAMP,
       LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP
FROM performance_schema.replication_applier_status_by_worker\G

# 5. GTID 缺口计算
mysql -e "SELECT GTID_SUBTRACT('MASTER_GTID', 'SLAVE_GTID')\\G"

# 6. pt-heartbeat 检测
pt-heartbeat --check -h slave-host -u monitor -D percona

# 7. 检查主从时钟偏差
mysql -h master -e "SELECT NOW()" && mysql -h slave -e "SELECT NOW()"

# 8. relay_log_space_limit 参数
SHOW VARIABLES LIKE 'relay_log_space%';

# 9. 检查大事务(binlog 中事务大小)
mysqlbinlog --base64-output=DECODE-ROWS mysql-bin.000123 |
  grep -E "^# at |GTID.*last_committed|^COMMIT" | tail -30

# 10. 半同步复制状态
SHOW STATUS LIKE 'Rpl_semi_sync%';

🔗 个人博客: https://opencao.cn 📺 公众号: Ai拆代码的曹操 🌟 知识星球: Ai拆代码的曹操