问题背景
这是《数据库高可用与容灾实战》系列的第一篇。后面十篇里反复出现的选举、切换、半同步、PITR、异地容灾,全部建立在一个最基础的事实上:主库的每一次提交,要经过"生成 binlog、dump 线程推送、从库 IO 线程落 relay log、SQL 线程回放"这条四段流水线,才会变成从库里可查的数据。这条链路上任何一段慢了,就是你看到的复制延迟;任何一段断了,就是你担心的数据丢失。线上最常见的两类告警——大促时"从库延迟 300 秒、读到了旧订单状态",以及主库宕机切换后"少了最后几笔交易"——本质上都发生在这一篇的范围内。所以先把链路拆开看清楚:每个线程手里拿的是什么、瓶颈通常在哪一段、异步复制到底在什么时刻会丢数据。
复制链路的机制拆解
第一段是主库生成 binlog。事务提交时,InnoDB 走两阶段提交,binlog 侧则是三阶段提交(flush 写入文件缓存、sync 落盘、commit 通知引擎),组提交把多个事务在同一轮里合并刷盘,这是binlog_group_commit_sync_delay等参数存在的原因。binlog 不是"SQL 语句的日志",而是一个事件流:一个行格式事务通常依次是 GTID_LOG_EVENT、QUERY_EVENT(BEGIN)、TABLE_MAP_EVENT 加若干 WRITE/UPDATE/DELETE_ROWS_EVENT、最后是 XID_EVENT 标记提交边界。DDL 不进事务,单独以事件形式直接落盘——这个特性后面讲切换和 PITR 时会反复用到。
第二段是dump 线程。每个接入的副本对应主库一个(或一组)binlog dump 线程,它从副本报告的位点开始读 binlog、持续把事件推送出去。dump 线程只保证"我发出去的字节序",不关心对端何时消化;副本断线重连靠MASTER_POS_WAIT/GTID 集合补齐,所以副本本地保留哪些 binlog/relay log 就直接决定了断线后能不能"原地续传"。
第三段是从库IO 线程。它把收到的事件原样写入 relay log(本质是 binlog 的本地副本,元信息记录在mysql.slave_relay_log_info),然后向主库确认读到的位置。IO 线程几乎不做解析,正常情况下它的处理能力远高于主库产出速率——relay log 侧很少成为延迟来源,这一点很快会用实验证明。
第四段是SQL 线程回放。它从 relay log 逐事务重放,默认单线程时,所有写压力要在一个线程上串行执行,这就是延迟的主战场。8.0 起replica_parallel_type默认 LOGICAL_CLOCK:主库提交时在同一组内的事务,从库可以并行回放(replica_parallel_workers控制并发),并由replica_preserve_commit_order保证从库提交顺序与主库一致。并行度的上限不由 worker 数决定,而由主库事务的"组结构"决定——这同样有实验佐证。
还有一个必须澄清的指标:Seconds_Behind_Source不是"落后多少秒的真实时钟差",而是"正在回放事件的源端时间戳"减"从库当前时间"。主从时钟不同步、跨时区、回放空转(无事件时该值失真)都会让它说谎,工程上更该看performance_schema.replication_applier_status_by_worker的队列水位与 binlog 位点差。
实验一:把四段流水线建成排队模型
下面的模拟用三个单服务台级联(主库落盘、网络传输、relay 落盘)加上一个固定速率的 SQL 线程回放,事务到达按"日常 20/s、大促峰值 150/s、回落 30/s"三段排布,全部确定性、无随机数。它回答两个问题:延迟到底堆积在哪一段;主库在"写完 900 笔"那一刻宕机,异步复制的欠账是什么形态。
TXN_BYTES=380# 一个事务在 binlog 里的平均字节数MASTER_BW=95000# 主库 fsync+append 折算带宽(bytes/s): 250 txn/s 上限NET_BW=625000# dump 线程网络带宽, 8 倍于主库落盘IO_BW=190000# 从库 IO 线程写 relay log 带宽APPLY_RATE=60# SQL 线程单线程回放速率(txn/s), 行锁/二级索引拖慢phases=[("日常",180,20.0),("大促峰值",600,150.0),("回落",120,30.0)]timeline=[]t=0.0forname,n,rateinphases:foriinrange(n):t=round(t+1.0/rate,6)timeline.append((name,t))rows=[]srv_m=srv_n=srv_io=0.0# 三个单服务台的"服务结束时刻"apply_done=0.0foridx,(name,commit)inenumerate(timeline):svc_m=TXN_BYTES/MASTER_BW srv_m=max(srv_m,commit)+svc_m# 主库 binlog 落盘完成svc_n=TXN_BYTES/NET_BW srv_n=max(srv_n,srv_m)+svc_n# dump 线程网络传完svc_io=TXN_BYTES/IO_BW srv_io=max(srv_io,srv_n)+svc_io# 从库 relay log 落盘apply_done=max(apply_done,srv_io)+1.0/APPLY_RATE# SQL 线程回放rows.append((name,idx+1,commit,srv_io,apply_done))print("阶段 累计笔数 relay落后(s) 回放落后(s)")marks=[180,780,900]forname,i,commit,relay,adinrows:ifiinmarks:print("%-12s %6d笔 %10.3f %10.3f"%(name,i,relay-commit,ad-commit))peak=max(rows,key=lambdar:r[4]-r[2])print("最大回放延迟出现在第%d笔(%s阶段), 落后主库 %.3f 秒"%(peak[1],peak[0],peak[4]-peak[2]))last_commit=timeline[-1][1]backlog=[rforrinrowsifr[4]>last_commit]drain=rows[-1][4]-last_commitprint("主库 %.3fs 写完全部 %d 笔, 从库还要追 %.3fs 才清空 relay log"%(last_commit,len(rows),drain))lag2=[rforrinrowsifr[3]>last_commit]print("其中 relay log 侧欠账 %d 笔(IO线程未落完), SQL线程欠账 %d 笔"%(len(lag2),len(backlog)))print("若此刻主库宕机且未开半同步: 从库完全没收到 %d 笔已提交事务(约 %.1f KB binlog)"%(len(lag2),len(lag2)*TXN_BYTES/1024))运行输出:
阶段 累计笔数 relay落后(s) 回放落后(s) 日常 180笔 0.007 0.023 大促峰值 780笔 0.007 6.023 回落 900笔 0.007 4.023 最大回放延迟出现在第780笔(大促峰值阶段), 落后主库 6.023 秒 主库 17.000s 写完全部 900 笔, 从库还要追 4.023s 才清空 relay log 其中 relay log 侧欠账 1 笔(IO线程未落完), SQL线程欠账 242 笔 若此刻主库宕机且未开半同步: 从库完全没收到 1 笔已提交事务(约 0.4 KB binlog)三个读数值得记住。第一,relay 落后恒定在 0.007 秒,回放落后却在峰值结束时冲到 6 秒:IO 线程近乎免费,延迟的账要算在 SQL 线程头上,真实环境同理——网络与 relay 落盘是微秒级字节搬运,回放要执行行更新、维护二级索引、拿行锁。第二,主库写完 900 笔的时刻,"从库没收到"只有 1 笔,"收到了没回放"却有 242 笔:异步复制宕机丢数据的窗口,其实只等于"主库已落盘但未传出"的那一小截;把数据完整性做透的前提,是让最后一笔在提交前得到持久化确认,这是第 3 篇半同步的主题。第三,如果主库磁盘还能抢救,那 1 笔可以从 binlog 文件里捞回来;真正难捞的是主库整机报废且没开 binlog 持久化确认的场景。
实验二:并行回放为什么到不了 8 倍
SQL 线程的官方解药是 MTS 并行回放,但 worker 数不等于并行度。下面按 LOGICAL_CLOCK 的语义生成 8 个提交组(组内事务互不冲突可并行,下一组必须等上一组全部完成——即栅栏),用固定耗时的贪心调度分别按 1/4/8 个 worker 回放,并与理论下界对比。
GROUP_SIZES=[6,3,8,4,7,2,9,5]APPLY_SEC=0.02# 单事务回放耗时(秒)txns=[]# (sequence_number, last_committed)sn=0forsizeinGROUP_SIZES:prev_end=sn-1# 本组依赖上一组最后一条事务for_inrange(size):txns.append((sn,prev_end))sn+=1print("8 个提交组, 组大小 %s, 共 %d 笔事务"%(GROUP_SIZES,len(txns)))print("并行窗口被最小组卡住: 理论最大并行度 = %d"%min(GROUP_SIZES))defsimulate(workers):free_at=[0.0]*workers done={}# sn -> 完成时刻fors,lcintxns:dep=max((done[x]forxindoneifx<=lc),default=0.0)w=min(range(workers),key=lambdai:free_at[i])done[s]=max(free_at[w],dep)+APPLY_SEC free_at[w]=done[s]returnmax(done.values())forwin(1,4,8):ms=simulate(w)bound=sum(-(-g//w)forginGROUP_SIZES)*APPLY_SECprint("%d worker: makespan %.3fs, 调度下界 %.3fs"%(w,ms,bound))t1,t4,t8=simulate(1),simulate(4),simulate(8)print("加速比: 4 worker %.2fx, 8 worker %.2fx (并非 8x)"%(t1/t4,t1/t8))print("纯工作量 %.3fs, 8 worker 也只压到 %.3fs: 组间栅栏 %d 次, 每次全停"%(len(txns)*APPLY_SEC,t8,len(GROUP_SIZES)-1))运行输出:
8 个提交组, 组大小 [6, 3, 8, 4, 7, 2, 9, 5], 共 44 笔事务 并行窗口被最小组卡住: 理论最大并行度 = 2 1 worker: makespan 0.880s, 调度下界 0.880s 4 worker: makespan 0.280s, 调度下界 0.280s 8 worker: makespan 0.180s, 调度下界 0.180s 加速比: 4 worker 3.14x, 8 worker 4.89x (并非 8x) 纯工作量 0.880s, 8 worker 也只压到 0.180s: 组间栅栏 7 次, 每次全停注意"理论最大并行度等于最小组"这句:一个大小为 2 的栅栏组能把 8 个 worker 瞬间打回 2 倍并行,8 worker 的加速比只有 4.89x,且继续加 worker 收益趋零。组的大小与形状由主库提交时刻的并发度决定——主库低峰期串行提交的业务,从库开再多 worker 也并行不起来。要改善粒度,方向是让主库组提交更松、或上 8.0 的binlog_transaction_dependency_tracking=WRITESET(按行级写集冲突判组,栅栏明显变碎)。另外一个大坑是大事务:一条 UPDATE 改 800 万行是一个不可拆分的回放单元,单个 rows event 再大也只能一个 worker 干,延迟曲线会呈现"追平一半、卡死在巨事务上"的形状。
常见陷阱与落地清单
- 从库没开
log_bin且没开log_slave_updates:级联复制(从库再挂从库)时 binlog 断链,第 4 篇故障切换捞增量会直接抓瞎;两参数必须同时开。 relay_log_purge=1(默认)让从库自动删 relay log:MHA/gh-ost 这类要"读旧主或读 relay 补洞"的工具就无洞可补,托管高可用架构下改成 0 并配max_relay_log_size控制水位。- 把 statement 格式配
INSERT ... SELECT、LIMIT无 ORDER BY 的更新复制出去:主从各自执行、结果发散,这是老式"主从不一致"的头号来源;统一binlog_format=ROW,且binlog_row_image=FULL给后面的闪回留路。 Seconds_Behind_Source在从库空转或时钟漂移时严重失真:容量告警请用位点差或binlog/relay未消化字节数。- 无 ORDER BY 大 DELETE/UPDATE 当成"一条 SQL 的事":它在从库是一个几十秒起步的巨型回放事务,期间所有并行 worker 排队等栅栏。
链路看清了:binlog 是事件流,从库的账几乎都欠在 SQL 线程上,而"位点"(文件名 + 偏移量)是这条流水线唯一的游标。问题来了:主库宕机后,把从库指到另一台机器的"某个位点",为什么经常对不上账、甚至丢洞重放?这就是下一篇《数据库高可用与容灾实战(2):GTID 与主从切换:传统位点切换为什么会不一致》要算的账。
参考来源
- MySQL 8.0 Reference Manual:Replication:https://dev.mysql.com/doc/refman/8.0/en/replication.html
- MySQL 8.0 Reference Manual:Binary Log:https://dev.mysql.com/doc/refman/8.0/en/binary-log.html
- MySQL 8.0 Reference Manual:Replication Formats:https://dev.mysql.com/doc/refman/8.0/en/replication-formats.html
- MySQL 8.0 Reference Manual:Replica Options and Variables:https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html
- Wikipedia:Write-ahead logging:https://en.wikipedia.org/wiki/Write-ahead_logging