各位,我是老张。今天从一次实战出发,往下拆一层。
上周客服群炸了。有用户下单付了钱,刷新订单列表,订单却不见了。他以为没成功,连点三次,扣了三笔。排查下来,问题出在一句老话:写走主库,读走从库。从库慢了半分钟,用户看到的就是半分钟前的世界。
真正难缠的不在这个慢字,而在它不是一直慢。平时延迟就几十毫秒,每隔一阵子却会尖刺到三十秒以上。这种平时没事、偶尔抽风的延迟,才是最不好查的。
今天把主从复制这层拆开。先给结论:主从延迟分两种,一种拉取慢,一种回放慢,生产上九成是回放慢。更坑的是,监控面板上那个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 | 单线程回放SBM | 8线程并行SBM |
|---|---|---|
| 2,000 | 0.1s | 0.05s |
| 8,000 | 3s | 0.1s |
| 20,000 | 35s | 0.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单线程 | 1 | 28分钟 |
| LOGICAL_CLOCK并行 | 8 | 6分钟 |
| WRITESET并行 | 8 | 2分半 |
追平测试的做法,是把从库停掉一小时攒够积压,再放开,量它多少时间追平。三组配置的硬件、数据量完全一样,只改回放方式这一个变量。
提交为什么要两阶段
讲半同步之前,得先补一块地基。一次提交其实是两件事。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。单库业务下,等于八个线程排队干一个人的活。
结语
主从延迟不是玄学,是两段路里有一段堵了。拉取慢看网络,回放慢看并发。
再往深一层,副本之间的一致性窗口,只要走异步,就永远存在。它不可怕,可怕的是你以为它不存在。指标绿着,从库可能已经落后两天;半同步开着,保护可能早已静默降级。这些假象,比延迟本身更值得警惕。
我写这篇,不是让你去堆参数。是想说,遇到读到脏数据,先别急着骂数据库。把真实延迟量出来,看清卡在哪一段,再动手。把看不见的东西变成看得见的,比调对一个参数值钱得多。方向对了,参数才有意义。
各位生产上被主从延迟坑过吗?是慢查询拖累,还是大事务堵路?评论区聊聊,一起排。
我是底层玩家老张。咱不聊概念,只聊源码和压测跑出来的东西。