读写分离与主从延迟:DB 性能优化的基石 — vibrant cover for technical blog article

读写分离与主从延迟:DB 性能优化的基石

主从复制解放读压力,但延迟带来的一致性问题,需要更精细的读写策略。

2025年8月10日·返回文章列表
文章大纲

绝大多数业务都是读多写少,读写比例经常到 10:1 甚至更高。单库扛不住读压力时,最直接有效的办法是加只读副本分担,主库负责写,从库负责读。这就是读写分离,DB 性能优化里最基础也最普遍的一步。

但读写分离不是免费的午餐。主从复制通常是异步的,从库的数据滞后于主库,这就带来一个绕不开的问题,用户刚写完数据立即读,可能读到从库上还没同步过来的旧值。这种"写后读不一致"是读写分离必须正视的代价。用好读写分离的关键,不在于怎么搭主从,而在于怎么精细地处理这个延迟窗口。

主从复制是怎么工作的

主库执行写操作后生成 binlog,从库拉取主库的 binlog 在本地重放,从而保持数据一致。这个过程的关键特征是异步,主库写完 binlog 就返回给客户端,不等从库确认。所以从库的数据天然滞后,滞后的窗口取决于网络延迟、从库重放速度、主库写入量。

复制有三种模式,区别在于主库什么时候算"写成功"。异步复制,主库写完本地立即返回,从库在后面慢慢追,性能最好但主库宕机可能丢失还没同步的数据。半同步复制,主库至少等一个从库确认收到 binlog 才返回,是性能和可靠性的平衡,也是生产环境的默认选择。全同步复制,主库等所有从库都确认,强一致但性能差,几乎没人用。

选哪种取决于业务对数据丢失的容忍度。绝大多数业务用半同步就够了,它保证主库宕机时至少有一个从库有完整数据。对数据零容忍的核心交易系统才考虑全同步,但要接受性能代价。

PlantUML
正在加载图表…
PlantUML diagram

图:三种复制模式的本质区别在于主库何时算"写成功"——半同步是性能与可靠性的平衡点,是生产默认选择

写后读不一致,最常见的体验问题

主从延迟最直接的后果是写后读不一致。用户改了昵称,提交成功,立即刷新页面,结果看到的是旧昵称。因为写进了主库,但读走了从库,从库还没同步过来。用户体验上这就是个 bug,明明提示修改成功了,刷新却没变化。

这类问题在 C 端产品里尤其恼火。用户改头像、改资料、下单后查订单,都属于"刚写完马上要看到结果"的场景,一旦读到旧值,用户的第一反应是系统出问题了,反复刷新甚至重新操作,制造更多无效请求。

延迟的根因有几类。大事务,一个事务涉及大量数据,从库重放慢,延迟累积。大表 DDL,ALTER 大表在从库重放极慢,可能卡住几小时。从库性能差,硬件不如主库跟不上重放速度。网络问题,主从间网络抖动导致 binlog 传输延迟。治理上,避免大事务拆分执行,DDL 用 pt-online-schema-change 这类在线工具,从库硬件和主库对齐,监控主从间网络。

应对延迟的几种策略

处理写后读不一致,按业务对一致性的需求分类施策,没有一种策略通吃所有场景。

强制读主是最简单粗暴的。对写后必须立即可见的查询,直接读主库不走从库,彻底绕开延迟。代价是增加主库压力,把读流量又压回主库,抵消读写分离的收益。所以只能对真正强一致的关键路径用,不能滥用。

写后窗口读主是更精细的做法。写操作后的一段时间内(比如 5 秒)读主,过了这个窗口再读从。实现上用一个标记记录"这个用户刚写过",最常见的是写 Redis,key 是用户 ID,TTL 是窗口长度。查询时先看标记在不在,在就走主库,不在走从库。窗口长度根据实际主从延迟调整,通常几秒到十几秒。

客户端缓存是另一种思路。写成功后,客户端自己把新值缓存在本地,刷新时先用本地值,等从库同步上来了再切回正常读从。这种方案对客户端改造多,适用场景有限。

PlantUML
正在加载图表…
PlantUML diagram

图:写后窗口读主——写操作后在 Redis 打一个 TTL 标记,窗口内读主库,窗口过期后恢复读从库,兼顾一致性与读扩展

// 写后窗口读主
public void updateProfile(User user) {
userMapper.update(user);
// 标记该用户 5 秒内的查询走主库
redis.setex("force_master:" + user.getId(), 5, "1");
}
public User getProfile(Long userId) {
if (redis.exists("force_master:" + userId)) {
return userMapper.selectByPrimaryKey(userId); // 走主库
}
return userReadMapper.selectByPrimaryKey(userId); // 走从库
}

读写分离的路由

读写分离要落地,需要数据访问层根据请求类型自动路由。写操作和强一致读走主库,普通读按负载均衡走从库。ShardingSphere、MyCat 这类中间件都支持,业务代码通常无感知,通过注解或配置声明哪些查询是强一致需求即可。

PlantUML
正在加载图表…
PlantUML diagram

图:读写分离路由架构——数据访问层按请求类型分流:写与强一致读走主库,普通读按权重与最少连接分发到从库

从库的负载均衡有几种策略。轮询最简单,请求均匀分配。权重按从库性能分配,强机器多承担。最少连接优先把请求分给当前负载低的从库。一致性哈希把相同查询固定路由到某个从库,利于利用该从库自己的查询缓存。生产里权重加最少连接的组合比较常见。

读写分离的落地还要处理一个细节,事务内的读怎么办。一个事务里既有写又有读,如果读走了从库,事务内刚写的数据可能读不到,破坏事务的隔离性。成熟的数据访问层会保证同一事务内的所有操作都走主库,事务提交后才恢复读写分离的默认路由。这个细节不注意,会出现"事务里写完立刻查不到"的诡异 bug。

从库的硬件规格最好和主库对齐。有些团队图省钱给从库配低配机器,结果从库重放 binlog 跟不上主库写入,延迟越积越大,最后摘除的从库越来越多,读扩展的效果大打折扣。从库的写入重放是单线程的(MySQL 的并行复制虽有改善但仍有限),重放能力天然弱于主库的多线程写入,硬件再低配就更跟不上。从库至少要和主库同等规格,CPU 和 IO 更强一些才稳。

延迟监控与从库摘除

读写分离必须配套延迟监控。核心指标是 Seconds_Behind_Master,它反映从库落后主库多少秒。给这个指标设阈值,超过 3 秒告警,超过 30 秒就要考虑摘除该从库,停止往它路由读请求,避免读到严重滞后的数据。

摘除机制很重要。某个从库因为大事务或性能问题严重滞后,如果不摘除,路由过去的请求会读到很久以前的旧数据,业务出错。监控到延迟超阈值,自动把这个从库从路由池里剔除,等它追上来再加回去。这套机制保证读到的数据即使有延迟也在可接受范围内。

延迟监控还要关注延迟的趋势。正常情况下从库延迟是毫秒级且稳定,如果延迟持续增长,说明从库重放速度跟不上主库写入速度,可能是从库性能瓶颈或主库写入量异常飙升,要及时介入。

写后读不一致是用户可感知的事故

别把主从延迟当成纯技术指标,对用户来说它就是个 bug。改了资料刷新没变、下了单查不到订单,用户会反复刷新甚至重复下单,制造更多问题。关键业务路径(写后立即读、资金查询、订单状态)必须强制读主,别为了省主库那点压力牺牲用户体验。

按一致性需求分类施策

读写分离能不能用好,全看有没有把查询按一致性需求分类。强一致的(写后立即读、资金、订单)走主库,容忍延迟的(列表、统计、历史、推荐)走从库。不分类盲目全走从库,必然踩写后读不一致的坑;全走主库,读写分离形同虚设。这个分类是设计阶段就要想清楚的,不是出问题再补。

Continue Reading

关联文档推荐

查看全部
多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog article容量评估:从 QPS 推导到资源规划的全流程 — editorial cover photo全链路压测:从影子库到流量染色的实战 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。