Seconds_Behind_Master 一直是 0,从库数据却落后 8 分钟

从库延迟显示 0,报表却少了 11 万。心跳表算出真实延迟 421 秒,根因是一个几十万行的大事务。为什么这个指标在空闲、并行复制、时钟偏移三种情况下都会说谎,以及大事务怎么拆。

周二上午十点,运营在群里发了一张截图:报表页面上的昨日成交额,比主库后台查出来的少了 11 万。她特意把刷新时间圈了出来,说已经刷新五次了,数字一直没变。

我先去看监控。从库延迟面板上是一条平得不能再平的绿线,Seconds_Behind_Master 全程 0。再看复制线程,Slave_IO_Running 和 Slave_SQL_Running 都是 Yes,Last_Error 为空。按监控的说法,这套主从是健康的。

但业务不会骗人。我把主库刚写入的一条测试记录(带当前时间戳)插进去,然后在从库上按时间戳倒序查,那条记录压根查不到。等了大概七分钟它才出现。也就是说,从库确实落后了七分钟以上,而监控告诉我延迟是 0。

这篇记录的就是这次「监控说没事、数据说有事」的排查过程,以及为什么 Seconds_Behind_Master 这个指标在特定场景下会彻底失效。

一、先把「到底落后多少」量出来

怀疑指标之前,得有一个独立于 MySQL 自身统计的度量。最土也最可靠的办法是心跳表:一张只有一行、每秒被主库更新一次的表。

CREATE TABLE `heartbeat` (
  `id` tinyint unsigned NOT NULL,
  `ts` datetime(3) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 主库上每 500ms 执行一次
INSERT INTO heartbeat (id, ts) VALUES (1, NOW(3))
ON DUPLICATE KEY UPDATE ts = NOW(3);

从库上直接算差:

SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(3)) / 1000 AS lag_ms FROM heartbeat;

结果是 421000 左右,四百二十一秒。跟业务说的七分钟基本对得上,中间那几分钟是我排查花掉的时间。所以结论很清楚:延迟是真的,指标是错的。

如果机器上装了 Percona Toolkit,pt-heartbeat 是同样的思路,只是把写入、采集、展示都做好了:

# 主库上常驻,负责写心跳
pt-heartbeat --user=repl --password=*** --host=10.0.2.11 \
  --database=monitor --update --interval=0.5 --daemonize

# 从库上单次查看,--master-server-id 指明对应主库
pt-heartbeat --user=repl --password=*** --host=10.0.2.12 \
  --database=monitor --check --master-server-id=1

这一步的价值不只是拿到一个数字。心跳表把「延迟」从一个由 MySQL 内部状态推断出来的派生值,变成了一个可以被外部独立验证的事实。后面所有关于指标可信度的讨论,都建立在它之上。

二、Seconds_Behind_Master 到底在算什么

要理解它为什么是 0,得看它的定义:从库 SQL 线程当前正在执行的那个 event,其时间戳与从库本机时间的差值。

这个定义里藏着三个坑。

第一个坑是「当前正在执行」。如果一个事务已经开始执行但还没提交,SQL 线程长时间停在这一个 event 上,那这个 event 的时间戳是固定的,差值只会随时间单调增大——这种情况它反而是「准」的,只是不准得有点吓人。但如果 SQL 线程恰好处于空闲、正在等主库发新 event 的状态,那它没有「当前 event」,MySQL 就直接返回 0。从库追不上时它在追,返回大数;从库一时没有新 event 可处理时它闲着,返回 0。于是这个曲线会在 0 和几百之间来回跳,把真正的延迟掩盖成一堆毛刺。

第二个坑是并行复制。开了 slave_parallel_workers 之后,有多个 worker 线程在跑,Seconds_Behind_Master 取的仍然是 coordinator 那一侧的状态,多线程之间的进度差它体现不出来。一个 worker 卡在大事务上,其他 worker 早就跑完了,整体看还是 0。

第三个坑是时钟。主从机器的时钟只要有偏差,这个值就整体偏移。我们有台机器 NTP 没配好,慢了 40 秒,于是那台从库的延迟常年显示成一个负数被截断成 0。

那天我抓了一段 SHOW SLAVE STATUS:

mysql -h10.0.2.12 -uroot -p -e "SHOW SLAVE STATUS\G" | \
  egrep "Master_Log_File|Read_Master_Log_Pos|Relay_Log_File|Exec_Master_Log_Pos|Seconds_Behind_Master|Slave_SQL_Running_State"

输出里 Read_Master_Log_Pos 和 Exec_Master_Log_Pos 差了将近 900 MB 的位点。这两个位点的差,才是「还有多少活没干」的直接证据,而 Seconds_Behind_Master 对此一无所知。

三、位点差指向一个大事务

既然位点差是 900 MB,那就要看这 900 MB 里装的是什么。SHOW BINLOG EVENTS 在生产上不能乱跑,会把从库拖得更慢,我用的是 mysqlbinlog 在从库本地解析 relay log,只看 event 头和行数。

# 只看 event 的概要,不输出具体行数据
mysqlbinlog --base64-output=decode-rows -v \
  --start-position=184320117 /var/lib/mysql/relay-bin.000318 2>/dev/null | \
  grep -E "^# at|^### (UPDATE|INSERT|DELETE)" | head -40

结果很干净:一个 BEGIN 之后跟着几十万条 ### UPDATE,然后才是一个 COMMIT。也就是说,主库上有人执行了一条不带 LIMIT 的批量更新,一次性改了几十万行,产生了单个几百 MB 的事务。从库必须把这几十万行全部应用完才能提交,中途不会对外可见,因为 InnoDB 的事务原子性在从库上同样生效。

这里有个容易被忽略的点:从库的 SQL 线程并不是「慢」,它是「没法快」。单事务内部的行变更不能拆开并行应用,slave_parallel_workers=8 这时候一个都用不上——并行复制的最小单位是事务,不是行。这也是为什么升配、加 worker 数对这类延迟毫无帮助。

顺带说一句,这个 UPDATE 是运营后台一个「批量修改商品分类」的功能发出来的,操作人勾了「全选」。功能本身没写错,错在没做分批。

四、并行复制为什么没兜住

我们的配置是 slave_parallel_type=LOGICAL_CLOCK,slave_parallel_workers=8。理论上它按主库的组提交(group commit)来划分并行单元:同一个 binlog 组里的事务可以并行应用。

问题在于,一个事务自己就构成一个组。上面那个几百 MB 的大事务独占一个组,其余 worker 无事可做。LOGICAL_CLOCK 的前提是「主库上有并发」,而这条 UPDATE 在主库上是串行执行的单线程操作,从库自然无从并行。

我核对了一下主库的组提交情况:

SHOW VARIABLES LIKE 'binlog_group_commit_sync_delay';
SHOW VARIABLES LIKE 'binlog_transaction_dependency_tracking';

binlog_transaction_dependency_tracking 是 COMMIT_ORDER,这是 8.0 的默认值,没问题。真正的问题不在复制配置,而在上游 SQL 的写法。并行复制解决的是「多个事务之间」的并行,从来不解决「一个事务内部」的耗时。 想靠调复制参数救大事务,方向从一开始就错了。

五、修复:把大事务拆开,把延迟变成可观测

修复分三层,从根上到面上。

第一层,改业务写法,把批量更新按主键分批。这是唯一能真正消除问题的一层:

-- 每批 2000 行,循环执行直到 affected_rows = 0
UPDATE product
SET category_id = 3021, updated_at = NOW()
WHERE category_id = 1180
  AND id > 0
ORDER BY id
LIMIT 2000;

配合应用层循环(每次把上一批的最大 id 传回去),单事务从几百 MB 降到几 MB,从库的应用时间从分钟级降到秒级。同时 innodb_lock_wait_timeout 的压力也小了很多,主库那边的行锁持有时间同步缩短。

第二层,给从库延迟加一道「业务口径」的保护。我们在数据访问层加了一个开关:当心跳延迟超过阈值时,写后立即读的请求强制走主库,其余读请求降级到主库或直接返回缓存。阈值不是拍脑袋定的,是按下游能容忍的脏读窗口反推的——报表能容忍 30 秒,那就设 30 秒。

第三层,把监控指标换掉。这是我们最后悔没早做的事:

指标 采集方式 能不能反映真实延迟 备注
Seconds_Behind_Master SHOW SLAVE STATUS 差,空闲时为 0,并行复制下失真 只能当「有没有明显异常」的粗筛
心跳表时间差 主库定时写、从库读 好,与 MySQL 内部状态无关 需要额外的写压力,0.5s 一次可接受
Read_Master_Log_Pos - Exec_Master_Log_Pos SHOW SLAVE STATUS 好,反映待应用字节数 不同事务的字节数差异大,不宜直接当时间
复制线程状态 performance_schema.replication_applier_status 中等 能看出 worker 分布,适合定位卡在哪个 worker
应用侧写后读不一致率 业务埋点 最好,直接对应业务体感 成本最高,适合核心链路

最后的告警规则是三条并存:心跳延迟 > 30 秒告警,位点差 > 200 MB 告警,Slave_SQL_Running 不为 Yes 立即告警。单看任何一条都会漏,三条一起基本覆盖了「空闲假 0」「大事务堆积」「线程挂掉」这三种形态。

六、防复发:把「不要写大事务」变成机制

靠人记住「批量更新要分批」是不现实的,尤其当操作来自后台的批量勾选。我们做了两件事。

一是在 ORM 层拦:MyBatis 的 Executor 上加了个拦截器,UPDATE/DELETE 语句如果没有 LIMIT 且预估影响行数超过 5000,直接抛异常,把堆栈打到调用方,让写代码的人在测试环境就撞上。上线三个月,拦下来七次。

二是在 binlog 侧兜底。我们加了一个定时任务,每五分钟解析一次主库最新的 binlog 文件大小增长,单事务超过 100 MB 就告警并记录当时的 thread_id,从 performance_schema.events_statements_history 里反查 SQL 文本。这个任务的成本不高,但它是唯一能在「业务代码已经上线、拦截器被绕过」时仍然生效的一道网。

还有一条是流程上的:把「大批量数据变更」列进了变更评审清单,超过一万行的更新必须附分批脚本和预计耗时。这条比任何技术手段都管用,因为它把问题挡在了发生之前。

小结

  • Seconds_Behind_Master 算的是「当前正在执行的 event 的时间戳差」,SQL 线程空闲时返回 0,并行复制下看不出 worker 之间的进度差,主从时钟不同步时还会整体偏移。它不能单独作为延迟指标。
  • 判断真实延迟,用心跳表或 pt-heartbeat,另外看 Read_Master_Log_Pos 与 Exec_Master_Log_Pos 的位点差,这两个是独立于 MySQL 内部状态的证据。
  • 并行复制的最小单位是事务,不是行。一个大事务只能由一个 worker 串行应用,加 slave_parallel_workers 对它没有任何帮助。
  • 大事务的根治办法是分批:按主键范围 LIMIT 2000 循环,把单事务体积从几百 MB 压到几 MB,同时缩短主库的锁持有时间。
  • 延迟监控要三条并存:心跳延迟、位点差、复制线程状态。只留一条必然在某种形态下失明。
  • 「不要写大事务」要靠拦截器和 binlog 巡检变成机制,靠人记是记不住的。
查看「数据库与缓存」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。