先来一个让人血压升高的现场:你刚在系统里把用户头像改成了猫,页面刷新,头像还是那张旧图。你怀疑是缓存没删干净。于是去查日志、查 Redis、查代码,折腾半天——最后发现缓存是好的,Redis 里确实是新的,是数据库本身返回了旧数据。
因为读走的是从库。而那个从库,还在慢慢地、安静地、让你毫无察觉地追赶主库。
这就是主从复制延迟。这玩意儿不是"偶尔抽风",它是必然存在的物理事实:主库写入的一刻,从库收到并应用是另一刻。你把读写一拆,这份"另一刻"就变成了你代码里的定时炸弹。
这篇不写"如何搭建主从复制",那是运维手册。我要讲的是延迟是怎么产生的、监控指标为什么会骗你、以及在代码层到底有几种应对姿势。
一、先说一个必须刻在脑子里的结论
读写分离的代价不是"偶尔读到旧数据",而是"你必须承认系统里存在两个状态"。主库状态是新的,从库状态是旧的。这不是 bug,是分布式系统的入场券。Kafka 有 lag,Redis 有异步复制,MySQL 也有。你以为分离了数据库就消除了并发问题?恭喜,你只是把并发问题换了个更难的形式。
凡是"读己之写"(read-your-writes)能被用户看见的场景,读写分离就已经是个 bug 了,只是 bug 的 owner 是数据库,不是你。
二、Seconds_Behind_Master:MySQL 里最著名的假指标
几乎每个 DBA 都会用这个字段,它也几乎总是骗人。MySQL 内部的计算逻辑大致是:
Seconds_Behind_Master = NOW() - 副本线程读到并"处理完"的 binlog 事件对应的时间戳
坑在"处理完"这三个字上。旧版本里它取的是 IO 线程读到的事件时间戳,而 IO 线程只负责把 binlog 拉过来、写入 relay log。写进 relay log 就算"处理完",后面的 SQL 线程(应用日志、重放事务)根本没开始跑,延迟就已经被显示成 0 了。
所以你会看到这种诡异现象:
SHOW SLAVE STATUS\G
Seconds_Behind_Master: 0
Relay_log_space: 4 GB # relay log 堆了一大堆,SQL 线程还没消化完
指标说"我一点都不落后",可从库的数据其实落后了整整一个事务链。
MySQL 5.7 之后做了一部分修正:并行复制下会参考 performance_schema 里 worker 线程的进度。但结论依然是——这个字段只能当"是否彻底卡死"的粗粒度告警,不能当业务可依赖的延迟度量。想要真实延迟,得自己测。
三、延迟到底从哪儿产生:两段流水线
把复制拆开看,延迟 = 传输延迟 + 应用延迟。
第一段(传输):主库 binlog → 网络 → 从库 IO 线程 → relay log。这一段通常很快,网络抖动、大事务、大批量 DDL 才会让它变长。经典的"大事务把从库拖死"就是在这里发生的:一个 2000 万行的 UPDATE,binlog 有几个 GB,从库 IO 线程吭哧吭哧拉,主库早就返回成功,业务毫不知情。
第二段(应用):从库 SQL 线程读 relay log → 改数据。这一段才是日常延迟的主战场,因为它受限于并行度。
- MySQL 5.5 及以前:单线程应用。哪怕你的从库有 64 核,也只有一个线程在干活。更坑的是并行度还受单线程写行锁竞争限制——两个事务改同一行,就必须排队。
- 5.6 起:支持"按库并行",但如果你的业务全在一个库里(当代几乎必然),并行度恒等于 1,等于没开。
- 5.7.6 起:引入"按事务组并行"(
LOG_CLOCK模式),可以真正同时应用多个互相不冲突的事务,这是从库延迟能被压下来的关键。 - 8.0.27+:加入 WRITESET 并行,基于行级冲突检测决定并行还是排队,理论上更聪明。
但这里有个结构性的天花板:单主复制下,主库的 binlog 写入本身是串行的。从库并行度再高,也只是"并行地追赶一条串行的日志流"。当从库并行度追上之后,它就退化成"主库写多快,从库就得跟多快",主库一个事务 5ms,从库就得在 5ms 内应用完——中间任何一次行锁等待、网络缓冲、IO 抖动,都会让延迟瞬间涨到几百毫秒。
这就是为什么单主复制在写密集场景下迟早会崩。想要真正的并行复制能力,绕不开共享存储或组复制 / InnoDB Cluster这类方案——本质上是用"多写节点 + 冲突检测"替代"单写节点 + 日志追赶"。
四、代码层到底怎么兜
别一上来就想着"关掉读写分离"(那是把可用性也一起关了)。按业务容忍度分层处理:
方案一:写后读强制走主库。最朴素也最可靠。做法是给读请求打一个标记,比如在请求上下文里带 read_consistency=strong,由连接池路由层决定走主库。缺点是主库读压力大,而且要区分"哪些读必须强一致"——通常是"用户刚改完马上要看的那一笔"。
// 伪代码:写完把"需要强一致的读"标记出来,TTL 别太长
func (c *OrderService) Update(ctx context.Context, id int64, f OrderPatch) error {
if err := c.repo.Update(ctx, id, f); err != nil {
return err
}
// 记住这个实体刚刚被写过,随后的读请求带这个 token
token := c.freshness.Mark(ctx, "order:"+strconv.FormatInt(id, 10))
ctx = context.WithValue(ctx, ctxKeyFreshToken{}, token)
return nil
}
func (c *OrderService) Get(ctx context.Context, id int64) (*Order, error) {
// 携带 token 且未过期 -> 强制路由到主库
if c.freshness.HasFresh(ctx, "order:"+strconv.FormatInt(id, 10)) {
ctx = c.router.WithPrimary(ctx)
}
return c.repo.Get(ctx, id)
}
方案二:用 GTID 等待。MySQL 提供 sql_wait_for_executed_gtid_set(),让客户端拿着自己刚写成功的事务 GTID,去目标从库上等一下:等到了再读。这样即使读走了从库,也不会读到比这次写入更旧的状态。实现上通常在 DAO 层做一次"等 GTID"的轻量往返。
-- 拿到本事务的 GTID 后,在从库上等待
SELECT WAIT_FOR_EXECUTED_GTID_SET('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:1-42', 1);
-- 返回 0 = 已经应用完;返回 1 = 超时还没追上
这方案的优雅之处在于:它把"一致性"从业务代码里剥出来变成了一个数据库层的等待语义。代价是每次读都多一次网络往返,以及超时阈值的设定——设短了变成尾延迟毛刺,设长了等于没做。
方案三:业务层自己兜(最容易被忽略,但往往最该用)。很多业务根本不需要读到数据库里的最新值:改完头像后立刻 GET 一次,返回的是"资源版本号"或"你自己提交的内容",而不是数据库行。这叫把读己之写变成客户端本地状态。它不需要任何数据库特性,延迟最低,而且天然避免了一整类问题。
顺带说一句,跟这个主题强相关的缓存细节:很多人在从库上执行"删缓存"操作来保证一致性。这是错的——如果主库的写还没传到从库,你删的从库缓存(或者共享 Redis 里的键)和你读到的旧数据是一伙的。删了也会被旧数据回填。缓存失效的时机应该锚定在主库写入成功的那一刻,而不是从库。
五、别用指标骗自己:自己测一遍
真实延迟测法很简单,难的是有人真的去做:
-- 主库上,每秒写一行心跳
CREATE TABLE heartbeat (id INT PRIMARY KEY, ts TIMESTAMP(3) NOT NULL) ENGINE=InnoDB;
-- 从库上,读同样的 id,比较时间戳差
SELECT ts, TIMESTAMPDIFF(MICROSECOND, ts, NOW(3)) AS lag_us
FROM heartbeat WHERE id = 1;
把这个查询挂到监控里,看 P50/P99 而不是平均值。平均延迟是给汇报用的,P99 才是给用户用的——用户抱怨慢,永远是那 1% 的时候慢。
六、最后
读写分离不是免费的性能优化,它是用一致性的复杂度换吞吐的带宽。这笔账你得算清楚:哪些接口能接受读到 200ms 前的数据?哪些绝对不能?如果分不清,说明还没到该拆的时候。
而当延迟真的开始伤人时,我更推荐先做那个"听起来最没技术含量"的方案:让关键路径回到主库,让非关键查询去从库。工程上大部分人缺的从来不是 clever 的方案,而是把一致性等级按业务分类这件事做完。
至于那些把 Seconds_Behind_Master 挂在 Grafana 上然后宣布"我们没有延迟问题"的团队——祝你们下次遇到问题时,能想起这篇。