1. 从一次查询超时说起:为什么单库迟早会扛不住
很多团队在业务起步阶段,数据库往往只有一台机器,所有读写请求都打到同一个实例上。刚开始用户量小、并发低,这套架构跑得挺顺,甚至会觉得“数据库优化”是个遥远的话题。但只要业务进入增长期,问题就会以非常具体的形式冒出来:某个下午,运营同学反馈后台列表页转圈十几秒打不开;再过几天,用户端开始零星报错,日志里清一色是查询超时。
我印象很深的一次,是某段时间订单查询接口的响应时间从平均 80 毫秒一路涨到 2 秒以上,监控面板上数据库的 CPU 使用率长期贴着 90% 不下来。当时第一反应是加索引,查了慢查询日志,确实揪出几条没走索引的 SQL,优化之后好了一阵。但没过两周,同样的症状又回来了。这时候才意识到,问题不在某一条 SQL,而在于所有读请求和写请求都挤在同一个数据库实例上,单机的连接数、CPU、磁盘 IO 都已经接近物理上限。
这就是主从分离要解决的核心矛盾。它的思路其实很朴素:把“写”和“读”拆到不同的机器上。写操作(INSERT、UPDATE、DELETE)只走主库,读操作(SELECT)分散到一个或多个从库上。主库负责保证数据的一致性和写入的权威性,从库通过复制机制持续同步主库的数据,专门承接查询流量。这样一来,原本压在一台机器上的压力被分摊到多台机器,读多写少的业务场景(而绝大多数互联网业务都是读多写少)能获得非常明显的吞吐提升。
这篇文章我会把主从分离这件事从头到尾讲透:它背后的复制原理是什么、怎么搭建、代码层面怎么改造、延迟问题怎么处理、以及那些只有真正上线跑过才会遇到的坑。适合已经有一定数据库基础、正在为高并发发愁的后端同学,也适合想系统理解读写分离设计思路的架构方向读者。我不会只给你一堆配置命令,而是把每一步“为什么这么做”讲清楚,这样你换一个数据库、换一个业务场景,也能自己推导出方案。
2. 主从复制的底层逻辑:数据是怎么从主库流到从库的
要理解主从分离,先得理解主从复制。很多人会配置主从,但说不清楚数据到底是怎么过去的,一旦出现延迟或者数据不一致就无从下手。所以这一节我们把复制的链路拆开看。
2.1 三个关键线程与两类日志
以最常见的 MySQL 为例,主从复制的核心可以概括为“三个线程、两类日志”。
主库上有一个binlog dump 线程。当从库连上来的时候,主库会为每个从库启动一个 dump 线程,它的工作就是读取主库本地的 binlog(二进制日志),然后发送给从库。binlog 记录的是主库上所有发生变更的操作,可以理解成一本“操作流水账”。
从库上有两个线程。一个是IO 线程,它负责连接主库,接收 dump 线程发来的 binlog 内容,然后写入从库本地的 relay log(中继日志)。另一个是SQL 线程,它读取 relay log,把里面记录的操作在从库上重新执行一遍,从而让从库的数据和主库保持一致。
所以整条链路是:主库写 binlog → dump 线程发送 → 从库 IO 线程接收并写入 relay log → 从库 SQL 线程重放。理解这条链路非常重要,因为后面讲的“主从延迟”,本质上就是这条链路上某一环慢了。
2.2 三种复制模式,选错会踩大坑
binlog 有三种格式,直接决定了复制的行为和风险,这里必须讲清楚。
| 复制格式 | 记录内容 | 优点 | 缺点与风险 |
|---|---|---|---|
| STATEMENT | 记录原始 SQL 语句 | 日志量小,节省带宽和存储 | 某些函数(如 NOW()、RAND())在主从执行结果可能不一致,导致数据错乱 |
| ROW | 记录每一行数据的变化 | 数据一致性最强,不依赖 SQL 执行环境 | 日志量大,批量操作时可能膨胀几十倍 |
| MIXED | 混合模式,一般语句用 STATEMENT,不确定的用 ROW | 兼顾两者 | 行为不够直观,排查问题时需要额外判断 |
我的建议很明确:生产环境优先用 ROW。虽然日志量大一些,但数据一致性是底线。我见过用 STATEMENT 模式因为一条带随机函数的 SQL 导致主从数据对不上的案例,排查起来极其痛苦,最后只能重建从库。磁盘和带宽的成本,远比数据不一致带来的损失小。
2.3 复制是异步的,这是延迟的根源
默认情况下,MySQL 主从复制是异步的:主库写完 binlog 就立刻向客户端返回成功,根本不管从库有没有收到、有没有重放。这个设计换来了主库的低延迟和高吞吐,但代价就是从库的数据天然可能落后于主库。
除了异步复制,还有半同步复制和全同步复制。半同步是指主库至少要等一个从库确认收到 binlog 之后才返回成功,能在一定程度上减少数据丢失风险,但会增加写入延迟。全同步则要等所有从库都重放完成,性能代价太大,生产环境基本不用。
提示:选择复制模式时,先问自己一个问题——业务能接受“刚写入的数据,立刻去从库查可能查不到”吗?如果完全不能接受,那要么关键读走主库,要么上更强的同步机制,但要做好性能下降的心理准备。
3. 搭建一套可用的主从环境:配置项背后的取舍
理解了原理,我们来看怎么落地。这一节我以 MySQL 为例,把关键配置和它们背后的考量讲清楚。不同数据库细节不同,但思路是相通的。
3.1 主库配置:server-id 和 binlog 是重点
主库的配置文件里,几个参数必须设置。
[mysqld] server-id = 1 log-bin = mysql-bin binlog_format = ROW sync_binlog = 1 innodb_flush_log_at_trx_commit = 1server-id是集群内每台机器的唯一标识,主从不能重复,否则复制会出问题。log-bin开启二进制日志,这是复制的前提。binlog_format设成 ROW,理由前面讲过。
sync_binlog = 1表示每次事务提交都把 binlog 刷到磁盘,配合innodb_flush_log_at_trx_commit = 1,能保证主库崩溃时不丢数据。这两个参数会带来一定的磁盘 IO 开销,但对于主库这种“数据权威源”,我认为这个代价是值得的。如果业务对性能极度敏感、能容忍极端情况下丢少量数据,可以适当放宽,但一定要清楚自己在放弃什么。
3.2 从库配置:read_only 与 relay log
从库的配置里,除了同样要设置唯一的server-id,还有两个关键点。
[mysqld] server-id = 2 relay-log = mysql-relay-bin read_only = 1read_only = 1让从库只接受读操作,普通账号无法写入。这一步非常重要,它能防止应用代码因为配置错误把写请求打到从库上,造成主从数据不一致。注意read_only对拥有 SUPER 权限的账号无效,所以复制用的账号权限要单独管理。
relay-log指定中继日志的位置,一般用默认即可,但显式配置便于排查问题。
3.3 建立复制关系:从库如何找到主库的“进度”
配置改完重启后,需要在主库上创建一个专门用于复制的账号,并记录当前的 binlog 位置。
-- 主库执行 CREATE USER 'repl'@'%' IDENTIFIED BY 'strong_password'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES; SHOW MASTER STATUS;SHOW MASTER STATUS会返回当前的 binlog 文件名和位置(Position),这两个值就是从库开始复制的起点。然后在从库上执行:
CHANGE MASTER TO MASTER_HOST = '主库IP', MASTER_USER = 'repl', MASTER_PASSWORD = 'strong_password', MASTER_LOG_FILE = 'mysql-bin.000001', MASTER_LOG_POS = 154; START SLAVE;之后用SHOW SLAVE STATUS\G检查,重点看两个字段:Slave_IO_Running和Slave_SQL_Running,都应该是Yes。如果有一个是No,说明复制链路断了,需要看Last_Error排查。
注意:如果主库已经有大量数据,直接这样建立复制会导致从库缺数据。正确做法是先做主库全量备份,在从库恢复,再基于备份对应的 binlog 位置建立复制。这个顺序不能颠倒。
4. 应用层怎么改:读写分离不是加个注解就完事
主从环境搭好只是第一步,真正决定成败的是应用层怎么用。很多团队以为配个中间件、加个注解就搞定了,结果上线后各种诡异问题。这一节讲清楚应用层改造的核心逻辑。
4.1 路由规则:哪些读走从库,哪些必须走主库
最基础的路由原则是:写走主库,读走从库。但现实远比这复杂。有几类读操作必须走主库,否则会出问题。
第一类是写后立即读。比如用户下单后立刻跳转到订单详情页,如果这个查询走了从库,而主从有延迟,用户就会看到“订单不存在”,体验极差。这类场景必须强制走主库。
第二类是对一致性要求极高的读,比如支付前的余额校验、库存扣减前的查询。这些读如果读到旧数据,可能直接导致业务逻辑错误。
第三类是事务内的读。在一个事务里,如果先写主库再读,读也必须走主库,否则事务的隔离性就被破坏了。
我的做法是:默认读走从库,但提供一个显式走主库的入口。代码里用注解或者封装好的方法标记,让开发者能明确指定。关键是默认值要安全,宁可多走主库,也不要因为默认走从库而出现数据不一致。
4.2 中间件方案与代码方案怎么选
读写分离的实现方式主要有两类:中间件方案和代码层方案。
中间件方案(如各类数据库代理)的好处是对应用透明,应用连中间件就像连一个普通数据库,路由逻辑由中间件处理。缺点是引入了一个额外的组件,增加了运维复杂度和故障点,而且中间件本身也可能成为瓶颈。
代码层方案是在应用内部封装数据源路由,通过 AOP 或者手动切换来决定用哪个数据源。好处是灵活、可控,能针对具体业务做精细化的路由决策。缺点是需要改代码,对开发者有要求。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 中间件 | 应用透明,接入成本低 | 多一个故障点,运维复杂 | 多语言栈、不想改代码 |
| 代码层 | 灵活可控,路由精细 | 需要改代码,有学习成本 | 单一技术栈、需要精细控制 |
我的经验是:中小团队优先考虑代码层方案。因为主从分离的难点从来不是“怎么路由”,而是“什么时候不该路由到从库”,这需要业务语义的判断,中间件很难替你做这个决定。代码层方案虽然要改代码,但把控制权留在自己手里,出问题时排查也更容易。
4.3 一个容易忽略的细节:连接池要分开
无论用哪种方案,主库和从库的连接池一定要分开配置。我见过有团队共用一个连接池,结果从库慢查询把连接占满,导致主库的写操作也拿不到连接,整个系统雪崩。
主库连接池要保证写操作的优先级,从库连接池可以配置更短的超时时间,一旦从库响应慢就快速失败,避免拖垮整个应用。从库连接池的最大连接数也要根据从库的实际承载能力来设,不要盲目设大。
5. 主从延迟:那个让无数人半夜爬起来的问题
主从延迟是读写分离绕不开的坎。只要用了异步复制,延迟就客观存在,区别只是大小。这一节讲清楚延迟从哪来、怎么监控、怎么缓解。
5.1 延迟的四个主要来源
延迟可能出现在复制链路的任何一个环节,主要有四类。
主库写入压力大。主库 binlog 产生速度超过从库消费速度,从库就会越追越远。这种情况通常伴随主库高并发写入。
从库 SQL 线程重放慢。从库要把 relay log 里的操作重新执行,如果从库硬件比主库差,或者从库上还有大量查询在跑,重放速度就会受影响。特别是 ROW 模式下的大事务,一个事务可能包含几十万行变更,SQL 线程要一行行重放,期间其他事务都得排队。
大事务和长事务。这是延迟的头号杀手。一个执行几分钟的大事务,在从库上也要花几分钟重放,这期间延迟会持续累积。
网络抖动。主从之间的网络如果出现波动,binlog 传输就会卡顿,IO 线程跟不上,延迟自然就上来了。
5.2 监控延迟:别只看 Seconds_Behind_Master
SHOW SLAVE STATUS里的Seconds_Behind_Master是最常用的延迟指标,但它有个陷阱:它计算的是从库 SQL 线程当前重放的事件时间戳与主库当前时间戳的差值。如果复制链路完全断了,这个值可能显示为 NULL 或者 0,反而看起来“正常”。
更可靠的监控方式是心跳表。在主库上定期更新一张表的时间戳,从库上读取这个时间戳和当前时间的差值,就是真实的延迟。这种方式能真实反映“主库刚写的数据,从库多久能查到”。
-- 主库定时执行 UPDATE heartbeat SET ts = NOW() WHERE id = 1; -- 从库查询延迟 SELECT TIMESTAMPDIFF(SECOND, ts, NOW()) AS delay_seconds FROM heartbeat WHERE id = 1;5.3 缓解延迟的实战手段
延迟无法完全消除,但可以缓解。几个我实际用过有效的手段:
拆分大事务。把一次更新几十万行的操作拆成多个小批次,每批几千行,中间适当停顿。这样从库重放时不会长时间阻塞。
从库并行复制。MySQL 5.7 之后支持基于组提交的并行复制,可以让 SQL 线程多线程重放,显著提升重放速度。开启方式是在从库配置slave_parallel_workers和slave_parallel_type。
关键读走主库。这是最直接的手段。对于写后立即读、强一致性读,直接路由到主库,绕开延迟问题。虽然增加了主库压力,但保证了正确性。
从库硬件不要缩水。有些团队主库用高配机器,从库用低配,结果从库重放跟不上。从库的 CPU 和磁盘 IO 能力至少要能匹配主库的写入速度。
提示:延迟监控一定要设告警阈值。我一般设两个档:超过 1 秒警告,超过 5 秒严重告警。因为延迟一旦开始累积,往往会越来越严重,早发现早处理。
6. 上线之后才会遇到的坑:几个真实场景复盘
理论和配置讲完了,但真正让人头疼的往往是上线后那些意料之外的情况。这一节我复盘几个实际踩过的坑,希望能帮你少走弯路。
6.1 从库读到了“不存在”的数据
有一次做活动,用户领取优惠券后立刻跳转到“我的优惠券”页面,结果部分用户看到的是空的。排查后发现,领券是写主库,查询走从库,而当时主从延迟有几百毫秒,用户跳转太快,从库还没同步到。
这个问题的本质是业务对延迟的容忍度判断失误。解决方案是给这类“写后立即读”的场景加一个短期的强制主库标记。具体做法是:写操作完成后,在缓存或 ThreadLocal 里记录一个标记,接下来几秒内该用户的读请求都走主库。这样既保证了正确性,又不会让所有读都压到主库。
6.2 从库被慢查询拖垮,延迟飙升
从库承接读流量后,难免会遇到慢查询。有一次一个运营后台的统计查询没走索引,在从库上跑了十几秒,把从库的 CPU 打满,SQL 线程重放被严重拖慢,延迟从几十毫秒涨到几十秒。
这个坑的教训是:从库也要做慢查询治理。不能因为从库“只是读”就放松要求。从库上的查询同样要过慢查询日志,同样要加索引。而且从库最好和主库用同样的硬件规格,否则重放能力不足。
6.3 主从切换时的数据丢失
有一次主库所在机器故障,运维紧急把从库提升为主库。但因为是异步复制,从库上还差最后一部分 binlog 没重放,导致这部分数据丢失。虽然量不大,但涉及几笔订单,处理起来很麻烦。
这个问题的根源是异步复制的固有风险。缓解手段有两个:一是关键业务用半同步复制,至少保证一个从库收到 binlog;二是做好 binlog 的备份和补数据预案,切换后能通过 binlog 把丢失的数据补回来。
6.4 连接池配置不当导致的雪崩
前面提过连接池要分开,这里讲一个具体的翻车案例。有个团队主从共用一个连接池,某天从库因为一个慢查询导致连接被占满,应用拿不到连接,连主库的写操作也失败了,整个系统不可用。
正确的做法是主库和从库各自独立连接池,并且从库连接池设置合理的获取超时时间。一旦从库响应慢,快速失败并降级,而不是无限等待。同时可以配置从库不可用时自动降级到主库读,虽然会增加主库压力,但至少保证系统可用。
7. 一些让主从分离更稳的工程习惯
最后分享几个我在实践中总结的习惯,它们不复杂,但能显著降低主从分离的运维风险。
第一,给从库读加超时和熔断。从库查询设置比主库更短的超时时间,一旦超时或错误率升高,自动熔断,把流量切回主库。这样能防止从库故障拖垮整个应用。
第二,定期校验主从数据一致性。可以用工具定期对比主从的 checksum,发现不一致及时处理。不要等到业务出问题才发现数据对不上。
第三,binlog 保留时间要足够长。如果从库故障需要重建,或者需要基于 binlog 补数据,binlog 保留时间太短就会很被动。我一般建议至少保留 7 天,重要业务保留更久。
第四,主从切换要有演练。不要等到真出故障才第一次做切换。定期演练能暴露很多配置和流程上的问题,真出事时才能从容应对。
第五,文档化路由规则。哪些读走主库、哪些走从库,要有明确的文档和代码规范。新同学加入时能快速理解,避免因为不了解规则而写出有问题的代码。
主从分离不是一个“配好就一劳永逸”的方案,它是一套需要持续维护和调优的机制。延迟、一致性、故障切换,每一个点都需要结合具体业务去权衡。但只要把原理吃透、把监控做好、把关键路径的读走主库,它就能成为支撑高并发读流量的可靠基石。我在多个项目里用这套思路扛住了远超单库能力的查询量,希望这些经验对你有用。