1. 从一次线上告警说起为什么单库迟早会扛不住凌晨两点监控大屏上那条QPS曲线突然拉成了一条直线紧接着是应用层大面积超时告警。登录数据库一看连接数打满慢查询日志里堆了几十条执行时间超过3秒的SQL主库CPU直接飙到95%以上。这不是什么极端场景而是一个日活刚过十万的业务在晚高峰时段的真实写照。很多团队在业务早期都做过同一个选择所有读写请求全部压在一个数据库实例上。这个选择在当时完全合理——架构简单、事务一致性好、运维成本低。但问题在于单实例的承载能力是有物理上限的。一台配置不错的机器撑住几千的QPS已经算不错了一旦写入和读取混在一起互相争抢资源性能衰减会来得比想象中更快。数据库主从分离就是在这个节点上最常被拿出来讨论的方案。它的核心思路非常朴素把写操作集中到主库把读操作分散到一个或多个从库上通过数据复制机制保证从库和主库的数据最终一致。听起来简单但真正落地时会遇到一堆细节问题——复制延迟怎么处理、读写路由怎么做、主库挂了怎么办、从库数量多少合适。这些问题不解决主从分离不但不能救火反而会引入新的故障点。这篇文章适合两类人看一类是正在被数据库性能问题折磨、考虑引入主从架构的后端开发者另一类是把主从分离当成“标配”但没深究过其中坑点的工程师。我会从整体设计思路讲到具体落地步骤把参数计算、路由策略、延迟处理、故障切换这些环节拆开揉碎尽量让每个环节都能直接抄作业。2. 主从分离的整体设计思路与方案选型2.1 为什么是主从分离而不是分库分表或换更强的机器面对数据库瓶颈摆在面前的路其实有好几条垂直扩容换更强的机器、分库分表、引入缓存、读写分离。这四条路各有适用场景选错了方向比不优化还糟糕。垂直扩容是最省事的加内存、换SSD、升CPU短期内确实能顶一阵。但这条路有两个硬伤一是成本增长不是线性的性能翻倍往往意味着价格翻好几倍二是单机始终有天花板再强的机器也扛不住持续增长的流量。它适合作为应急手段不适合作为长期方案。分库分表解决的是数据量和写入压力的问题但它带来的复杂度是数量级的提升——分布式事务、跨库join、全局ID、扩容迁移每一个都是深坑。如果读多写少的特征明显分库分表就是杀鸡用牛刀。缓存能挡住大量重复读请求但它解决不了复杂查询和缓存穿透后的数据库压力而且缓存和数据库的一致性维护本身就是个难题。主从分离的定位很清晰它专门解决“读多写少”场景下的读压力问题。大多数互联网业务的读写比在5:1到20:1之间有的甚至更高。把读流量分摊到多个从库主库只负责写整体吞吐能力可以接近线性提升。它的复杂度适中不需要改动业务的数据分片逻辑是性价比最高的一个选择。提示判断要不要上主从分离先看读写比。如果读写比低于3:1说明写压力才是主要矛盾这时候应该优先考虑写入优化或分库分表而不是主从分离。2.2 主从复制的底层原理binlog是怎么把数据搬过去的要理解主从分离必须先搞懂数据是怎么从主库同步到从库的。这套机制的核心是binlog二进制日志。主库上每一次数据变更INSERT、UPDATE、DELETE以及DDL都会按顺序写入binlog。从库启动一个IO线程连接到主库请求读取binlog主库的dump线程把binlog内容推送给从库。从库的IO线程收到后写入本地的relay log中继日志然后由SQL线程读取relay log并在从库上重放这些操作最终让从库的数据和主库保持一致。这里有个关键点binlog有三种格式选择哪种直接影响复制的行为。格式记录内容优点缺点STATEMENT记录SQL语句原文日志量小某些函数如NOW()、RAND()在主从上执行结果可能不一致ROW记录每行数据的变化数据一致性最强日志量大批量操作时膨胀明显MIXED混合模式自动选择兼顾两者行为不够可预测生产环境我一般推荐ROW格式。虽然日志量大一些但数据一致性有保障不会出现主从数据对不上的诡异问题。日志量的问题可以通过参数调优来缓解。复制模式上还有异步复制、半同步复制和全同步复制的区别。异步复制是默认模式主库写完binlog就返回不关心从库有没有收到性能最好但主库宕机时可能丢数据。半同步复制要求至少一个从库确认收到binlog后主库才返回在数据安全和性能之间取了个平衡。对于大多数业务半同步复制是更稳妥的选择尤其是在主从分离之后从库承担了大量读请求数据不一致的代价会更高。2.3 读写路由的三种实现层次主从分离落地时最核心的工程问题是一条SQL到底该发往主库还是从库。这个决策可以在三个层次做。第一种是应用层硬编码。在代码里手动区分写操作走主库数据源读操作走从库数据源。这种方式最直接但侵入性强业务代码里到处是数据源切换逻辑后期维护很痛苦。第二种是中间件代理。在应用和数据库之间加一层代理比如常见的数据库中间件应用只连代理由代理根据SQL类型自动路由。这种方式对业务透明但多了一层网络跳转延迟会略微增加而且代理本身可能成为瓶颈。第三种是框架层封装。利用Spring的AbstractRoutingDataSource这类机制通过注解或AOP在方法级别切换数据源。这种方式兼顾了透明性和性能是目前Java生态里最主流的做法。我个人的经验是中小规模团队优先选框架层封装改造成本低可控性强。规模再大、需要跨语言支持时再考虑引入中间件代理。3. 核心细节解析与实操要点3.1 主从复制的关键参数配置配置主从复制时有几个参数直接决定了复制的稳定性和性能必须认真对待。主库侧最重要的参数是binlog_format和sync_binlog。binlog_format设为ROW前面已经说过。sync_binlog控制binlog写入磁盘的频率设为1表示每次事务提交都刷盘最安全但性能有损耗设为0表示交给操作系统决定性能好但宕机可能丢日志。金融类业务建议设为1普通业务可以设为100或1000在安全和性能之间取平衡。从库侧的关键参数是relay_log_recovery和slave_parallel_workers。relay_log_recovery设为ON后从库重启时会自动丢弃未执行的relay log并重新从主库拉取避免数据不一致。slave_parallel_workers开启并行复制让多个SQL线程同时重放relay log能显著降低复制延迟。在MySQL 5.7及以上版本配合slave_parallel_typeLOGICAL_CLOCK并行复制效果很好。还有一个容易被忽略的参数是read_only。从库应该设为ON防止误写入。但要注意这个参数对具有SUPER权限的账号无效所以从库的账号权限也要控制好。3.2 读写路由的判定规则与边界情况读写路由看起来简单——写走主库、读走从库——但实际业务里有大量边界情况需要处理。事务内的读必须走主库。这是最容易踩的坑。如果一个事务里先写后读读请求被路由到从库由于复制延迟很可能读不到刚写的数据导致业务逻辑出错。解决办法是在事务开始时就把整个事务绑定到主库数据源。写后立即读的场景也要走主库。比如用户提交订单后立刻跳转到订单详情页这个读请求如果走从库可能查不到刚创建的订单。常见的处理方式是在写操作后的一段时间内比如1秒强制走主库或者用缓存来兜底。强制走主库的注解是必备的。框架层封装时要提供一个显式指定数据源的注解让开发者在明确知道需要读主库时能手动指定。比如DataSource(master)这样的注解。下面是一个基于Spring的读写路由核心配置示例public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } } // 通过AOP在方法执行前设置数据源类型 Aspect Component public class DataSourceAspect { Before(annotation(readOnly) || within(readOnly)) public void setReadDataSource(ReadOnly readOnly) { DataSourceContextHolder.setDataSourceType(slave); } Before(annotation(writeOnly) || within(writeOnly)) public void setWriteDataSource(WriteOnly writeOnly) { DataSourceContextHolder.setDataSourceType(master); } After(annotation(readOnly) || within(readOnly) || annotation(writeOnly) || within(writeOnly)) public void clearDataSource() { DataSourceContextHolder.clearDataSourceType(); } }注意数据源切换必须在事务开启之前生效。如果事务已经开启再切换数据源切换不会生效因为事务已经绑定到了某个连接上。这是AOP切面顺序容易出问题的地方切面的Order值要设置得比事务切面更小。3.3 复制延迟的监控与应对策略复制延迟是主从分离绕不开的问题。延迟的产生原因主要有几个从库单线程重放跟不上主库的写入速度、大事务导致relay log堆积、从库上有慢查询拖慢了SQL线程、网络抖动导致binlog传输中断。监控延迟最直接的方式是看SHOW SLAVE STATUS里的Seconds_Behind_Master字段。但这个字段并不完全可靠——它只反映SQL线程和IO线程的时间差如果IO线程已经断了这个值可能是NULL或者0看起来正常实际已经出问题了。更可靠的监控方式是在主库定期写入心跳时间戳从库读取这个时间戳和当前时间对比这样能真实反映端到端的延迟。应对延迟的策略分几个层次。预防层面开启并行复制、避免大事务把大批量操作拆成小批次、从库配置不低于主库。兜底层面对于延迟敏感的业务提供强制走主库的开关对于能容忍短暂不一致的业务用缓存或者前端降级来过渡。极端情况如果延迟持续超过阈值可以考虑临时把读流量切回主库虽然会加重主库负担但至少保证数据正确。延迟程度表现应对措施 1秒正常范围无需处理1-5秒轻微延迟监控观察检查从库负载5-30秒明显延迟排查慢查询、大事务考虑并行复制调优 30秒严重延迟切读流量回主库紧急排查4. 实操过程与核心环节实现4.1 环境准备与主从搭建的完整步骤假设我们有两台服务器一台作为主库一台作为从库都是MySQL 5.7版本。下面是完整的搭建流程。主库配置。修改my.cnf文件加入以下配置[mysqld] server-id 1 log-bin mysql-bin binlog_format ROW sync_binlog 100 innodb_flush_log_at_trx_commit 1 binlog_do_db your_databaseserver-id必须全局唯一主从不能相同。log-bin指定binlog文件名前缀。binlog_do_db指定要复制的数据库不指定则复制所有库。重启主库后创建一个专门用于复制的账号CREATE USER repl% IDENTIFIED BY strong_password; GRANT REPLICATION SLAVE ON *.* TO repl%; FLUSH PRIVILEGES;然后锁定主库记录当前的binlog位置FLUSH TABLES WITH READ LOCK; SHOW MASTER STATUS;记下File和Position两个值这就是从库复制的起点。此时不要关闭这个会话否则锁会释放。如果需要导出数据在另一个会话执行mysqldump完成后用UNLOCK TABLES释放锁。从库配置。修改my.cnf[mysqld] server-id 2 relay_log mysql-relay-bin read_only ON relay_log_recovery ON slave_parallel_workers 4 slave_parallel_type LOGICAL_CLOCK重启从库后执行复制配置CHANGE MASTER TO MASTER_HOST 主库IP, MASTER_PORT 3306, MASTER_USER repl, MASTER_PASSWORD strong_password, MASTER_LOG_FILE 记录的File值, MASTER_LOG_POS 记录的Position值; START SLAVE;用SHOW SLAVE STATUS\G检查Slave_IO_Running和Slave_SQL_Running都为YesSeconds_Behind_Master为0说明复制已经正常运行。4.2 数据源配置与动态路由的落地在Spring Boot项目里配置多数据源需要几个步骤。首先是application.yml里的数据源定义spring: datasource: master: url: jdbc:mysql://master-host:3306/your_db?useSSLfalse username: app_user password: password driver-class-name: com.mysql.jdbc.Driver slave: url: jdbc:mysql://slave-host:3306/your_db?useSSLfalse username: app_user password: password driver-class-name: com.mysql.jdbc.Driver然后是数据源配置类把两个数据源注册到动态路由里Configuration public class DataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean public DataSource dynamicDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave, slaveDataSource()); DynamicDataSource dataSource new DynamicDataSource(); dataSource.setTargetDataSources(targetDataSources); dataSource.setDefaultTargetDataSource(masterDataSource()); return dataSource; } }这里有个细节默认数据源要设为主库。因为写操作必须走主库如果路由逻辑出问题默认走主库至少能保证数据正确性不会出现写丢失。4.3 强制走主库的注解实现与事务绑定前面提到事务内的读必须走主库这个逻辑需要在AOP切面里处理。核心思路是在事务开启之前就确定数据源并且事务内的所有操作都使用同一个数据源。Around(annotation(transactional)) public Object aroundTransaction(ProceedingJoinPoint point, Transactional transactional) throws Throwable { // 事务方法默认走主库 DataSourceContextHolder.setDataSourceType(master); try { return point.proceed(); } finally { DataSourceContextHolder.clearDataSourceType(); } }这个切面的Order值要小于事务切面的Order值确保它在事务开启之前执行。Spring的事务切面默认Order是Ordered.LOWEST_PRECEDENCE所以我们的切面设成Ordered.HIGHEST_PRECEDENCE就能保证顺序。对于写后立即读的场景提供一个ForceMaster注解标注在需要强制走主库的方法上Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface ForceMaster { }切面里检测到这个注解就设置数据源为主库。这样开发者可以在业务代码里显式控制比隐式的时间窗口策略更可靠。4.4 主从切换的应急流程主库宕机是必须提前准备预案的场景。完整的切换流程包括故障检测、从库提升、应用切换、原主库恢复四个阶段。故障检测可以通过监控系统实现检测指标包括主库连接是否可达、QPS是否骤降、复制线程状态等。检测到故障后需要人工确认避免误判。从库提升选择延迟最小的从库执行以下操作STOP SLAVE; RESET SLAVE ALL; -- 如果原主库可能恢复记录当前binlog位置以便后续找回丢失的数据 SHOW MASTER STATUS;然后把这个从库的read_only设为OFF应用层的写数据源切换到这台新主库。应用切换如果用的是配置中心可以动态推送新的数据源配置避免重启应用。如果配置写死在文件里就需要重启或者用热加载机制。原主库恢复后不能直接重新加入集群因为它可能落后于新主库。正确的做法是把原主库作为新主库的从库重新建立复制关系等数据追平后再决定是否切回。提示主从切换是有损操作切换期间写入会中断。对于写入连续性要求高的业务需要考虑更高级的方案比如双主架构或者基于一致性协议的自动故障转移。但双主架构本身有一致性风险需要额外处理冲突。5. 常见问题与排查技巧实录5.1 复制中断的典型原因与修复方法复制中断是运维中最常遇到的问题表现是Slave_SQL_Running变成No。常见原因和修复方法如下错误类型典型报错修复方法主键冲突Duplicate entry确认数据后跳过该事务或手动修复数据数据不存在Cant find record同上通常是主从不一致导致表结构不一致Table doesnt exist在从库补建表结构权限不足Access denied检查复制账号权限跳过错误事务的命令是SET GLOBAL SQL_SLAVE_SKIP_COUNTER 1; START SLAVE;但这只是应急手段跳过之后必须人工核对数据一致性否则主从数据会越差越多。5.2 从库读到的数据比主库旧怎么办这是复制延迟导致的经典问题。除了前面说的强制走主库和缓存兜底还有一个技巧是在从库上记录复制延迟应用层根据延迟决定是否降级。具体做法是应用在读取从库前先查一下当前延迟如果超过阈值就改走主库。这个查询本身有开销可以缓存几秒钟。另一个思路是业务层面的补偿。比如订单创建后不是立即查数据库而是把订单信息写入缓存详情页优先读缓存。这样即使从库有延迟用户也能看到最新数据。5.3 从库数量是不是越多越好不是。从库数量增加会带来几个问题主库的dump线程要为每个从库服务binlog传输占用带宽主库的复制压力增大。一般来说单主库挂3到5个从库是比较合理的范围。如果需要更多读能力可以考虑级联复制从库再挂从库但级联层数不宜超过两层否则延迟会累积。从库的配置也有讲究。如果从库只承担读请求可以把innodb_buffer_pool_size设大一些因为读操作对内存缓存的需求更高。而主库因为要处理写操作需要平衡内存和磁盘IO。5.4 大事务导致的延迟尖刺怎么处理大事务是复制延迟的头号杀手。一个执行10分钟的大事务在从库上也要重放10分钟这期间所有后续的binlog都堆积着。处理办法是从源头拆分大事务批量删除改成DELETE ... LIMIT 1000循环执行大批量更新拆成多个小批次每批之间sleep一下避免在业务高峰期执行DDLDDL在MySQL 5.7之前会阻塞复制如果已经产生了大事务导致的延迟可以临时增加slave_parallel_workers但根本解决还是要从业务代码入手。5.5 主从数据一致性怎么校验定期校验主从数据一致性是必要的。常用的工具是pt-table-checksum它通过在主库上计算表的校验和然后在从库上对比来发现不一致。使用时有几个注意点要在业务低峰期执行因为校验过程会加锁要指定复制账号工具需要连接从库发现不一致后用pt-table-sync修复但修复前要确认业务能接受短暂的数据波动。对于核心表建议每周校验一次。非核心表可以降低频率。校验结果要存档方便追溯数据变化趋势。6. 我在实际项目中的几点体会主从分离这套方案我前后在几个项目里落地过踩过的坑不算少。最大的体会是技术方案本身不难难的是边界情况的处理。读写路由的代码可能半天就能写完但事务绑定、延迟兜底、故障切换这些细节往往要花几倍的时间去打磨。另一个体会是监控比功能更重要。主从复制是否正常、延迟多少、从库负载如何这些指标必须实时可见。我见过太多团队搭好了主从却没人看监控等到业务出问题才发现复制早就断了。监控告警的阈值要设得合理延迟超过5秒就该有人关注超过30秒就该触发应急流程。最后分享一个小技巧在从库上开启慢查询日志但阈值设得比主库低。因为从库承担了大量读请求慢查询对用户体验的影响更直接。通过分析从库的慢查询往往能发现一些在主库上被写操作掩盖的性能问题。这个习惯帮我定位过好几个索引缺失的问题效果比单纯看主库的慢查询日志好得多。
阅读完成 · 觉得有帮助?