换个场景:内容社区的"发出去的文章不见了"
上一篇库存服务的对账实验里,我们默认"读 DB"读的是同一个 DB——现实里高并发系统的读压大部分被路由到了从库,于是出现裂缝的另一半:作者 15:00:00 发布文章,DB 提交成功,自己刷新个人主页却看不到;10 秒后运营在后台也看不到,判定"发布失败"又点了一次发布。这就是主从延迟治理要接的锅。读写分离本身不难——难的是把"从库可能旧多久"变成一个有界、可观测、业务可预期的量。本篇分两刀:第一刀切路由(哪些读必须回主库、哪些可以等位点),第二刀切容量(延迟是怎么从"复制拓扑问题"变成"回放速率问题"的)。
路由层的三条军规
现代接入层(ProxySQL、RDS 代理、ShardingSphere 类客户端)做读写分离,规则都是三条的变体:事务内的读永远走主(自己没提交的更改只有自己看得见);写后短时间内该会话的读走主(会话粘性,治"读己之写");其余只读流量走从(接受有界陈旧)。第二条是事故最多发的一条,因为"短时间"通常被写成一个常数——写后 5 秒读主。问题是 5 秒的假设对手是变化的延迟:复制积压 1 秒时它绰绰有余,积压 12 秒时它一触即溃。把三条策略放到同一段"写高峰期 lag 爬升"的虚拟时间轴上对比:无脑从库 / 写后粘主 5 秒 / 按位点等待(等不起就降级读主):
# 作者 150s 发布文章(DB 提交于 t=150). 从库可见性 = t - lag(t)# 写高峰期 lag 线性爬升, 之后回落: 三段确定性折线deflag(t):ift<100:return2.0ift<=200:return2.0+0.1*(t-100)returnmax(2.0,12.0-0.05*(t-200))COMMIT,OFFSETS=150,[1,2.5,5,6.5,12,50]defp1(t_r):# 无脑从库ok=(t_r-lag(t_r))>=COMMITreturn"SLAVE","OK"ifokelse"读不到!"defp2(t_r,sticky=5):# 写后 5 秒会话粘主ift_r<=COMMIT+sticky:return"MASTER","OK"returnp1(t_r)defp3(t_r,cap=4.0):# 按位点等待(上限 cap 秒), 超时降级读主t=t_rwhilet-lag(t)<COMMIT:t+=0.5wait=t-t_rifwait<=cap:return"SLAVE(等%.1fs)"%wait,"OK"return"MASTER(降级)","OK"print("写入提交 t=%d, 从库 lag 随写高峰在 t=200 前爬升到 12s"%COMMIT)print("%-6s %-6s | %-12s %-8s | %-12s %-8s | %s"%("读时刻","lag","策略1 从库","结果","策略2 粘主5s","结果","策略3 位点等待"))v1=v2=m2=m3=0rows=[]foroffinOFFSETS:t_r=COMMIT+off r1,o1=p1(t_r)r2,o2=p2(t_r)r3,o3=p3(t_r)rows.append((t_r,r1,o1,r2,o2,r3,o3))fort_r,r1,o1,r2,o2,r3,o3inrows:v1+=o1!="OK"v2+=o2!="OK"m2+="MASTER"inr2 m3+="MASTER"inr3print("t=%5.1f %5.1fs | %-12s %-8s | %-12s %-8s | %s"%(t_r,lag(t_r),r1,o1,r2,o2,r3))print("读不到次数: 策略1=%d 策略2=%d 策略3=0 | 主库承担的读: 2=%d 3=%d (共6次读)"%(v1,v2,m2,m3))运行输出:
写入提交 t=150, 从库 lag 随写高峰在 t=200 前爬升到 12s 读时刻 lag | 策略1 从库 结果 | 策略2 粘主5s 结果 | 策略3 位点等待 t=151.0 7.1s | SLAVE 读不到! | MASTER OK | MASTER(降级) t=152.5 7.2s | SLAVE 读不到! | MASTER OK | MASTER(降级) t=155.0 7.5s | SLAVE 读不到! | MASTER OK | SLAVE(等3.0s) t=156.5 7.7s | SLAVE 读不到! | SLAVE 读不到! | SLAVE(等1.5s) t=162.0 8.2s | SLAVE OK | SLAVE OK | SLAVE(等0.0s) t=200.0 12.0s | SLAVE OK | SLAVE OK | SLAVE(等0.0s) 读不到次数: 策略1=4 策略2=1 策略3=0 | 主库承担的读: 2=3 3=2 (共6次读)(注:模拟步长 0.5 秒,等待 2.5 与 3.0 显示为同类,不影响结论。)三行结论自己走出来:策略 1 四次踩空,证明"延迟非零即事故";策略 2 在 t=156.5 破防——粘主窗口 5 秒到期时 lag 还有 7.7 秒,固定粘主时长只在"lag < 窗口"的世界里成立,而这个世界并不存在;策略 3 把"读己之写"彻底治好,代价是两次小延迟(3 秒内)和两次降级读主,主库多扛 2/6 的读。位点等待在 MySQL 里就是SELECT WAIT_FOR_EXECUTED_GTID_SET('uuid:seq', timeout)(或 GTID 集对比gtid_executed):写入成功后把位点存进会话(Redis,带 TTL),此后的强一致读先问从库"追到位点了吗",追得上就等、追不上就回主。它是三者中唯一"语义正确且自适应延迟波动"的,前提是你的复制位点是单调可比较的——这也是 GTID 模式相对文件位点模式在治理上的真实价值。
容量层:延迟不是玄学,是回放速率的账
大多数团队把主从延迟归因于"网络慢/磁盘慢",但生产事故里两个最常见的真凶都在回放速率上:其一是(旧版本)单线程 SQL 线程按序回放,主库并发的写洪峰到从库排队泄洪;其二是大事务——一条 UPDATE 改 500 万行的语句在主库执行 40 秒,其 binlog 要在提交后一次性传给从库回放,从库再执行几十秒,瞬时 lag 直接跳到分钟级(8.0 的并行回放/MTS 按库表甚至 WRITESET 并行度缓解了其一,治不了其二)。用"积压 = 写入事件速率 − 回放能力"的积分模型看三种画像:
# 从库回放能力模型: 220 事件/秒; 三种上游写入画像, 300 秒虚拟回放CAPACITY=220.0defreplay(name,backlog0,inflow_fn,seconds=300):backlog,peak,drain_at=backlog0,backlog0,Noneforsinrange(seconds):backlog=max(0.0,backlog+inflow_fn(s)-CAPACITY)peak=max(peak,backlog)ifs>30anddrain_atisNoneandbacklog<=backlog0+1:drain_at=sprint("%-28s 峰值延迟 %5.1fs | 积压峰值 %6.0f 事件 | 回基线耗时 %s"%(name,peak/CAPACITY,peak,("%ds"%drain_at)ifdrain_atelse"窗口内未回"))defconst(v):returnlambdas:vdefburst(start,end,v,base):returnlambdas:vifstart<=s<endelsebaseprint("从库回放上限 %d 事件/秒, 初始积压 %d 事件"%(CAPACITY,220))replay("日常流量 180/s",220,const(180))replay("大促开闸 400/s 持续60s",220,burst(0,60,400,180))replay("无锁DDL 瞬发6000事件/10s",220,burst(0,10,600,180))replay("双11零点 500/s 持续120s",220,burst(0,120,500,180))运行输出:
从库回放上限 220 事件/秒, 初始积压 220 事件 日常流量 180/s 峰值延迟 1.0s | 积压峰值 220 事件 | 回基线耗时 31s 大促开闸 400/s 持续60s 峰值延迟 50.1s | 积压峰值 11020 事件 | 回基线耗时 窗口内未回 无锁DDL 瞬发6000事件/10s 峰值延迟 18.3s | 积压峰值 4020 事件 | 回基线耗时 104s 双11零点 500/s 持续120s 峰值延迟 153.7s | 积压峰值 33820 事件 | 回基线耗时 窗口内未回模型给出的治理清单非常具体:持续性超容量(后两行)不是延迟治理问题,是拓扑问题——写洪峰速率超过单机回放上限时,唯一解是把读流量从这套主从中剥离(扩容从库只分流读、不解决回放;要解决复制本身,得上多分片写或级联复制);脉冲型积压(DDL 行)则是调度问题——6000 事件的瞬时突刺带来 18 秒峰值、104 秒回血,所以"错峰执行 DDL/大事务拆小批(每批几千行 + 批间 sleep)"不是 DBA 的洁癖,是在给所有依赖 lag 上界的策略(包括上面的位点等待)发保底工资。还有一个隐藏结论:粘主窗口、位点等待超时 cap 这些参数,都应引用 lag 的实测峰值分位动态调整,而不是写死——t=156.5 那次破防,根源就是把动态量当常数。
业务兜底:路由治不了的,产品来治
最后划清边界:位点等待保的是"读己之写",而"作者 A 发文、粉丝 B 立刻搜到"属于跨会话新鲜度,任何异步复制都给不了硬保证,只能业务侧兜底:发布成功后内容双写进搜索/信息流通道(消息驱动,绕过复制);个人主页读自己的作品列表强制走主;给粉丝展示"作者大大刚刚在编辑"的软提示替代空白。这类设计的共同点是:把"一致性承诺"从存储层上移到交互层,用信息透明换技术豁免。
路由与延迟的账算完了,但所有参数都建立在"我们知道系统容量在哪"的前提上——这个前提平时是信仰,只有压测能把它变成数字。下一篇《高并发流量治理实战(9):全链路压测方法:容量评估、影子表与压测标透传》讲怎么不打扰真实用户地把整条链路的底摸清。
参考来源
- MySQL 8.0 Reference Manual: Replication(主从复制与并行复制): https://dev.mysql.com/doc/refman/8.0/en/replication.html
- MySQL 8.0 Reference Manual: WAIT_FOR_EXECUTED_GTID_SET(): https://dev.mysql.com/doc/refman/8.0/en/gtid-functions.html
- MySQL 8.0 Reference Manual: Multi-Threaded Replication: https://dev.mysql.com/doc/refman/8.0/en/replication-multi-threaded-appliers.html
- Wikipedia: Read–write splitting: https://en.wikipedia.org/wiki/Read%E2%80%93write_splitting
tags: 读写分离, 主从延迟, MySQL, 复制
本系列已结集为免费专栏《高并发流量治理实战:从限流到全链路压测》 https://blog.csdn.net/weixin_67153745/category_13213830.html ;系统性进阶推荐付费专栏《提示词工程实战:从入门到生产级 Prompt 设计》限时 ¥19.9,首篇免费试读:https://blog.csdn.net/weixin_67153745/category_13213600.html