☰
主从延迟怎么排查?一次读到脏数据事故拆解MySQL复制原理与并行回放优化
2026/10/15 11:04:20 网站建设 项目流程

各位,我是老张。今天从一次实战出发,往下拆一层。

上周客服群炸了。有用户下单付了钱,刷新订单列表,订单却不见了。他以为没成功,连点三次,扣了三笔。排查下来,问题出在一句老话:写走主库,读走从库。从库慢了半分钟,用户看到的就是半分钟前的世界。

真正难缠的不在这个慢字,而在它不是一直慢。平时延迟就几十毫秒,每隔一阵子却会尖刺到三十秒以上。这种平时没事、偶尔抽风的延迟,才是最不好查的。

今天把主从复制这层拆开。先给结论:主从延迟分两种,一种拉取慢,一种回放慢,生产上九成是回放慢。更坑的是,监控面板上那个Seconds_Behind_Master,很多时候根本不能信。排查第一步不是盯着它看,是先确认它有没有骗你。

关键点说明
复制两段路io_thread拉取binlog,sql_thread回放relay log
延迟主因回放慢,单线程追不上主库并发写入
最阴的坑网络断了SBM归零,面板一片绿,实际早已落后
安全假象半同步超时后静默降级异步,保护悄悄消失
根治手段并行复制 + 拆大事务 + 真实延迟监控

脏读先出在应用层

订单是怎么丢的,先得从应用层说起。代码里是两步,先写订单落主库,再查订单走从库。两步之间没有等待,用户点完就查,从库还没收到这笔binlog,自然查不到。

根子在写后读。写和读被拆到两个连接,甚至两个数据源,各走各的路。主库刚提交,从库还停在半分钟前,用户查到的就是空。

更隐蔽的是事务边界。有些框架按方法名路由,同一个事务里的读,照样被甩到从库。读到的快照和主库不在一条时间线上,脏读就这么来了。

所以再遇到读到旧数据,先别急着查数据库。回头看看服务的读写分离怎么配的。动态数据源也好,MyBatis插件也好,都可能把正常查询引到从库。

复制链路长什么样

先把这条链路的两个环节画出来,后面所有问题都落在这两段上。

【第一段:拉取】 主库 事务提交 → 写入 binlog │ │ binlog dump thread 把 binlog 推过网络 ↓ 从库 io_thread 接收 → 落地 relay log 【第二段:回放】 从库 sql_thread(可并行)读 relay log → 重放 → 写入应用数据

第一段是拉取,dump thread 把 binlog 推过网络,io_thread 接收落地。第二段是回放,sql_thread 读 relay log,重放成真实变更。任何一段堵了,延迟就出来。拉取那段慢,多半是网络带宽;回放那段慢,是重放速度扛不住主库的写入并发,这才是延迟尖刺的大头。

Seconds_Behind_Master会骗人

先说最坑的一点。这个指标算的是sql_thread当前重放位置,和主库现在时间之间的差距。它默认了relay log里总有新东西在等着被放。这个默认,在三种情况下会崩。

第一种,网络断了。io_thread拉不到binlog,relay log被放空了,sql_thread无事可做,Seconds_Behind_Master直接归零。面板绿油油,其实从库早就不同步了,可能落后几个小时。这是最阴的一种,我栽过,后面细说。

第二种,主库没写入。binlog没有新事件,sql_thread追平后指标也是0。这时延迟是真的0。可怕的是,一旦业务恢复写入,积压立刻爆发,指标从0跳到几十秒。你以为是突发,其实是攒着的。

第三种,大事务重放中。一个事务改了几百万行,重放要跑很久。这段时间里,指标跟着relay log的进度来回跳,根本不反映真实进度。

所以真实延迟得另找办法测。一是用GTID对比,看从库到底落后多少事务;二是上心跳表,比如pt-heartbeat,往主库写时间戳,从库读出来一减,就是真实延迟。

-- 主库看已执行的GTID集合SHOWMASTERSTATUS;-- 从库看已接收、已执行的GTIDSHOWSLAVESTATUS\G-- 算出从库还差多少事务没执行SELECTGTID_SUBTRACT('主库executed_gtid','从库executed_gtid');
# 心跳表,最贴近真实延迟 pt-heartbeat --host=master --update --create-table pt-heartbeat --host=slave --monitor

回放为什么慢

根子在sql_thread。老架构里它是单线程的,主库并发几十上百个事务,从库排成一队,一个一个放。主库写并发越高,从库越追不上。这不是配置问题,是架构问题。

主库写入QPS单线程回放SBM8线程并行SBM
2,0000.1s0.05s
8,0003s0.1s
20,00035s0.4s

看中间那列。主库QPS到两万,单线程回放的延迟直接飙到三十几秒,正好对上我们那次事故的量级。并行那列就稳得多。

这批数怎么来的,得交代清楚。8核16G、SSD的机器,sysbench压oltp_write_only,binlog用ROW格式。事务全是单行更新,刻意避开热点,让并行真正跑起来。换一组参数,数就完全不是这么回事了。

并行复制怎么救场

MySQL 5.7开始有MTS,多线程复制。但并行不是乱并行,得保证重放后数据一致。关键是凭什么是哪两个事务能并排?答案藏在binlog里,每个事务都带着判断依据。

-- 从库开启并行回放(8.0里改名叫replica_parallel_workers)SETGLOBALslave_parallel_workers=8;SETGLOBALslave_parallel_type='LOGICAL_CLOCK';-- 8.0加细粒度写集判断SETGLOBALbinlog_transaction_dependency_tracking='WRITESET';-- 保证并行回放后,外部看到的提交顺序不变SETGLOBALslave_preserve_commit_order=ON;

机制在这。每个事务进binlog时,会带两个字段,last_committed和sequence_number。前者是逻辑时钟,相当于“我依赖的最后一个事务编号是多少”。两个事务的last_committed相同,说明彼此没有依赖,就能并排重放。这个时钟哪来的?主库group commit时推出来的,同一批提交的事务本身就不冲突。

往深处看一眼binlog。一个事务在binlog里不是一条记录,是一串事件,last_committed和sequence_number就打在串头那条GTID_EVENT上。

# 一个事务在binlog里的样子(简化) GTID_EVENT → 记 last_committed / sequence_number QUERY_EVENT → BEGIN TABLE_MAP → 定位待改的表 WRITE_ROWS → 真正的行变更 XID_EVENT → COMMIT

从库的协调线程,读的就是串头那两个数,判断这个事务能不能和前一批并排放。而主库的group commit分flush、sync、commit三步,同一批的事务一起写盘,天然没有依赖,于是拿到同一个last_committed。并行复制借的就是这个便宜,把主库的提交批次原样搬到从库来并发。

WRITESET把这套判断做得更细。开启后,每个事务给它改过的每一行算一个哈希,存成写集。两个事务的写集没有交集,才判定独立,允许并行。粒度从“同批提交”细到了“没碰同一行”。

但有个限制得认清,热点行。如果所有事务都在改同一行库存,写集全撞,一个都并行不了,并行度直接退化成1。这时候堆再多worker也没用。所以并行复制对分散写效果拔群,对热点写几乎无效。这也解释了为什么有人开了八个线程,延迟还是降不下来,不是配置错,是业务模型不匹配。

还有个更基础的坑,出在slave_parallel_type上。默认值是DATABASE,按库并行。单库业务下所有事务落在一个库里,并行等于没开。要真正生效,得改成LOGICAL_CLOCK或WRITESET。这个我也踩过,配了八个线程,延迟纹丝不动,就卡在这。

回放方式线程追平1小时积压耗时
sql_thread单线程128分钟
LOGICAL_CLOCK并行86分钟
WRITESET并行82分半

追平测试的做法,是把从库停掉一小时攒够积压,再放开,量它多少时间追平。三组配置的硬件、数据量完全一样,只改回放方式这一个变量。

提交为什么要两阶段

讲半同步之前,得先补一块地基。一次提交其实是两件事。InnoDB写redo日志,MySQL Server写binlog。两份日志,两个写入方,得保证它们一致。

做法是两阶段提交。事务先在redo里记prepare,再把变更写进binlog,最后在redo里记commit。binlog落了盘,这一笔才算真正提交。

异步复制为什么会丢数据,根就在这。binlog还没传到从库,主库先挂了,从库自然缺这一段。而半同步卡的,正是binlog到从库这一步。它等的不是数据落盘,是从库回的那一个ACK。早一步还是晚一步,差别全落在这个两阶段的节点上。

半同步:拿延迟换安全

异步复制下,主库写完就返回,主库一挂,还没同步的binlog就丢了。半同步是主库等至少一个从库确认收到,再返回成功。听起来很安全,代价是主库的响应时间被从库拖着。

MySQL的半同步有个参数rpl_semi_sync_master_wait_point,两个取值。AFTER_COMMIT是主库先提交,再等从库ACK。AFTER_SYNC是先等ACK,再提交。AFTER_SYNC更安全,因为提交前就拿到了从库确认。这里有个反向传染:从库越慢,主库等ACK越久,主库也跟着慢。延迟会顺着复制链路往上爬,最后烧到业务接口上。

还有个更要命的开关,rpl_semi_sync_master_timeout。主库等ACK,超过这个时间还没等到,就自动降级成异步,直接返回成功,默认十秒。这意味着什么?从库一卡,你以为还在半同步的保护下,其实主库早悄悄关掉了这道保险。而降级是静默的,日志里留痕,监控面板上不一定看得到。所以半同步不是一开就高枕无忧,它是有条件的保证。延迟最高的那段时间,恰恰是它最可能失效的窗口。

决策框架

排查主从延迟,我压成四步。

第一步,别信Seconds_Behind_Master。先用GTID对比或者心跳表,量出真实延迟。指标为0不代表没延迟。

第二步,分清真延迟卡在哪段。看从库的Relay_Log_Pos和Exec_Master_Log_Pos,两者差距大就是回放慢,Seconds_Behind_Master不涨但io线断了就是拉取慢。

第三步,回放慢就三件事:开并行复制并把slave_parallel_type配对,拆掉大事务,把从库上的报表查询挪走。

第四步,业务兜底。写后立刻读的场景走主库,缓存顶上读流量,别让用户在延迟窗口里看到脏数据。

场景推荐做法
单库高并发写入并行复制改WRITESET,8线程起步
大批量更新拆成小批,每批几千行
读写分离读脏数据写后读走主库,或加缓存
数据不能丢开半同步,用AFTER_SYNC
从库跑报表迁到独立只读实例,别跟回放抢IO

踩过的坑

框架是纸上的,坑是自己踩出来的。下面这三个,全是我真金白银换的教训。讲出来,让各位少走点弯路。

第一个坑,也是最贵的。我信了Seconds_Behind_Master等于0。那是台边缘业务从库,网络抖动过一下,io_thread静默断了。relay log放空,指标一直绿,没人管。两周后要切主库才发现,它少了两天的数据,最后靠重搭才补回来。指标绿,不等于同步正常。

第二个坑,大事务没拆。运营跑了一次批量更新,一条SQL改了200万行。从库单线程重放这一个事务就花了十分钟,后面的binlog全堵在它后头。延迟尖刺就是这么来的。从那以后,批量操作一律分批。

第三个坑,并行复制开了不生效。slave_parallel_workers设了8,slave_parallel_type还是默认的DATABASE。单库业务下,等于八个线程排队干一个人的活。

结语

主从延迟不是玄学,是两段路里有一段堵了。拉取慢看网络,回放慢看并发。

再往深一层,副本之间的一致性窗口,只要走异步,就永远存在。它不可怕,可怕的是你以为它不存在。指标绿着,从库可能已经落后两天;半同步开着,保护可能早已静默降级。这些假象,比延迟本身更值得警惕。

我写这篇,不是让你去堆参数。是想说,遇到读到脏数据,先别急着骂数据库。把真实延迟量出来,看清卡在哪一段,再动手。把看不见的东西变成看得见的,比调对一个参数值钱得多。方向对了,参数才有意义。

各位生产上被主从延迟坑过吗?是慢查询拖累,还是大事务堵路?评论区聊聊,一起排。

我是底层玩家老张。咱不聊概念,只聊源码和压测跑出来的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询