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 巡检变成机制,靠人记是记不住的。
正在加载评论…