最近总有人问我,说公司网站一到活动高峰就卡顿,数据库CPU直接飙到90%以上,各种慢查询把主库拖得喘不过气。我给出的第一个建议往往不是加缓存、分库分表,而是先把MySQL主从复制和读写分离落地。这套组合拳不是新东西,却是性价比最高、最不容易出错的数据库扩展方案。这篇文章我不讲空泛概念,直接按照我自己从零搭建并维护线上主从环境的完整经历来写,包括为什么这么做、每一步怎么操作、哪些细节会坑得你半夜爬起来看日志,以及如何用Docker快速模拟一套真实环境,把你从“听说过”变成“能落地、能排查、能优化”。
先说清楚它能解决什么问题,适合什么场景。主从复制解决的是数据的高可用与容灾备份,读写分离解决的是主库的读压力分流。但如果你只有一台机器、一个业务,访问量一天几千次,老老实实单库就行。只有当你发现主库SQL线程持续飙高、慢查询日志越来越长、报表类查询和线上事务互相抢占InnoDB资源时,才真正需要这套架构。话虽如此,提前在测试环境完整搭一遍,理解它的同步链路、故障切换机制,对任何搞后端和数据库的人都是必修课。
接下来我会沿着一条比较贴近实战的路径展开:先拆解为什么要读写分离以及主从复制的底层原理,再给你一份在Docker环境下完整搭建MySQL 8.0主从环境的保姆级步骤,然后重点处理新旧数据不一致的问题,再讲业务层如何接入读写分离,紧接着是监控、告警与故障排查思路,最后补充我踩过的一些不常见但影响巨大的坑。全文基于我在多个项目中的真实配置,用的都是常规的Docker与MySQL官方镜像实践,仅供参考,实际生产环境需要根据自己的云平台和容器方案做适配。
1. 主从复制与读写分离背后的真实动机:不是跟风,是流量和教育成本倒逼
很多教程一上来就让你敲配置,根本不说清主从复制到底帮你扛住了什么。先花点篇幅把这个动机讲透,后边你配置起来才会有意识地做取舍。
1.1 单库时期的痛点:所有请求都打在同一个事务引擎上
很多业务从MVP阶段做起,初期用户量小,库表也简单。但业务过了冷启动阶段,你会发现一个规律:读请求的增速远大于写请求。一个内容类系统,用户浏览列表、查看详情、搜索聚合,读写比达到10:1甚至20:1很正常。这些读操作如果全部落在主库上,每一个SELECT都会和INSERT、UPDATE抢InnoDB的行锁、缓冲池和IO能力,互相拖累。
更麻烦的是,一旦某条统计类查询写得不够严谨,比如没带索引的深分页、临时文件排序、大范围扫表,它一执行就把CPU打满,直接拖垮同一库上的核心写入接口,用户下单都开始超时。读操作导致写业务雪崩,这是单库架构下最憋屈的故障之一。我的处理思路从来都是优先劝老板接受分等级流量治理,但落到数据访问层,读写分离就是最基础的分级手段。
1.2 主从复制解决的三大核心问题
- 容灾与备份:主库如果发生硬件故障或容器被误删,只要从库数据是完整的,可以在分钟级完成实例切换。
- 分担读压力:从库可以水平扩展,不同从库服务不同业务线。例如一个从库服务线上查询,一个从库服务后台报表。
- 降低锁竞争:把大查询和线上事务分离后,主库上的长事务明显减少,死锁和锁等待的发生概率随之下降。
但我必须提前泼盆冷水:主从复制对写入吞吐量的提升几乎为零。你写入还是单点主库,它解决的是读扩展。如果业务模型是典型的高写入、低读取,主从架构的意义更多在于容灾,而不是拆分性能瓶颈。
1.3 当主库不再是单点:架构变化引入的依赖
引入主从复制之后,逻辑拓扑就变了。应用层写操作只走主库,读操作可以轮询或哈希到从库。这个变化又牵出好几件之前不存在的注意事项,包括:主从数据天然存在毫秒到秒级的延迟,刚写入的数据立刻去从库读可能读不到;如果只有一台从库,它挂了你得能自动感知并摘除;不同从库如果机器配置不一致,同步延迟会被放大。这些问题不能等到上线之后再解决,架构设计阶段就必须把路由规则和一致性要求一起定下来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主从同步机制拆解:binlog如何变成从库的中继日志
配置主从复制前,如果不理解底层数据是怎么流动的,出了问题你连排查方向都没有。这里我把MySQL 8.0的同步链路简化为四个角色。
2.1 四个关键角色:主库、Binlog,从库IO线程、从库SQL线程
复制链路中,主库只要有数据变更(事务提交),都会把变更记录追加写入二进制日志文件,也就是binlog。从库启动后,会开启两个核心线程。IO线程负责连接主库,请求指定binlog文件名和偏移量,把拿到的日志片段写到本地文件——中继日志relay log。SQL线程负责读取relay log中记录的事件,在本地数据库逐个重放,从而完成数据变更。整个过程读起来有点像主库演戏,从库照着重演。
- 主库:产生业务变更,负责记录binlog。
- 从库IO线程:拉取主库binlog,落地为中继日志。
- 从库SQL线程:消费relay log,产出本地数据变更。
- 心跳机制:默认配置下,主库会定期与从库IO线程保持连接探活,避免长时间空闲断开。
2.2 为什么推荐基于GTID的复制,而不是传统基于日志位点
MySQL 5.6之后引入了GTID,全称是Global Transaction Identifier,即全局事务标识符。每一个在主库上提交的事务都会分配一个唯一的GTID,并记录在binlog中。从库执行时也会记录自己已经执行到哪个GTID集合。相比传统模式需要手动指定MASTER_LOG_FILE和MASTER_LOG_POS,GTID模式最大的好处是从库可以自动感知主库当前位点。如果你要新增一台从库,只需要让它从主库或已有从库做一次全量备份恢复,再启动复制进程即可。对于运维来说,这省掉了容易搞混的文件名和偏移量定位环节。我强烈建议新搭环境都用GTID。
2.3 一条更新语句的主从完整旅程
假设主库执行 UPDATE user SET balance = balance + 10 WHERE id = 7。它要经历:事务在InnoDB层完成修改,写入undo和redo;提交成功时同时把这条变更以二进制事件形式写入binlog;从库IO线程通过MySQL协议,基于半同步或异步模式拉取该binlog事件;从库将事件追加到本地的relay log;从库SQL线程解析relay log,拿到对应的schema和表,在从库的InnoDB引擎中执行同样的行变更。整个过程如果一切正常,从库会把自己执行到的GTID记录到系统表中。这样当你执行SHOW REPLICA STATUS时,能看到类似Retrieved_Gtid_Set和Executed_Gtid_Set的值来判断进度。
2.4 binlog格式为什么建议用ROW
MySQL的binlog格式有三种:STATEMENT、ROW、MIXED。STATEMENT格式记录的是SQL语句,日志量小,但有些函数在不同机器上执行结果未必一致,例如UUID、NOW这类不稳定场景。ROW格式记录的是每行数据的前后镜像,精度最高,虽然日志量偏大,但在MySQL 8.0的默认行格式下,配合压缩选项其实完全可控。主从复制追求数据一致性,我建议使用ROW格式,尤其是在涉及大事务更新的场景,它能避免很多诡异的不一致问题。同时要注意,如果binlog_row_image设置为FULL,最好也确认一下表没有超大的TEXT/BLOB字段且频繁更新,否则binlog会显得比较胖。
3. Docker环境主从搭建全实录:从镜像启动到GTID复制验证
纸上谈兵对工程没有意义,这里我以Docker Compose方式跑一套MySQL 8.0一主一从架构。这套方式适合本地学习、测试环境快速验证,也接近云原生容器化部署的形态。整个过程中,我会把每一步的意图和容易出错的点一起写清楚。
3.1 容器规划与目录说明
我习惯创建独立的目录工程,例如在用户目录下建一个mysql-master-slave文件夹,里面放master/conf、master/data、slave/conf、slave/data。数据目录挂载出来,删容器不丢数据。另外,我会提前创建自定义网络,而不是使用默认bridge网络,这样两个容器之间可以通过服务名互相访问。
| 角色 | 容器名 | 端口映射 | 挂载目录 |
|---|---|---|---|
| 主库 | mysql-master | 3306 | ./master/data:/var/lib/mysql |
| 从库 | mysql-slave | 3307 | ./slave/data:/var/lib/mysql |
注意端口不能冲突,如果你本地3306被占,就换33061/33062之类的高位端口。我尽量保留3306映射,以便Navicat等客户端直接连接。
3.2 编写主库配置文件:server-id、log-bin、GTID必须成组出现
主库的my.cnf是整个复制链路的起点。一个比较严谨的最小配置如下:
ini复制[mysqld]
server-id=1
log-bin=mysql-bin-1
binlog_format=ROW
gtid_mode=ON
enforce-gtid-consistency=ON
binlog_row_image=FULL
default-authentication-plugin=mysql_native_password
简单解释几个关键参数:
server-id:整个主从复制拓扑内每个节点必须是唯一的整数,这是节点标识。log-bin:开启binlog,并给日志文件一个基础名称,方便后续识别。gtid_mode=ON和enforce-gtid-consistency=ON:这两个必须同时开,前者打开GTID,后者强制事务在执行时满足GTID的复制安全性,例如禁止在事务里创建临时表这类操作。binlog_row_image=FULL:8.0默认是FULL,每个binlog事件都记录整行的前镜像和后镜像。如果你的行字段非常大,可以评估改成MINIMAL,只记录被修改字段。
在Docker中,假设Dockerfile基于mysql:8.0官方镜像,你可以将配置放在宿主机master/conf/my.cnf,然后通过容器启动参数-v $PWD/master/conf/my.cnf:/etc/mysql/conf.d/my.cnf挂载进去。我个人的建议是手动创建好目录,先挂载配置再启动容器。
3.3 启动主库容器并初始化复制账号
配置文件就位后,执行以下命令启动主库:
bash复制docker run -d \
--name mysql-master \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=Root@123456 \
-v $PWD/master/conf/my.cnf:/etc/mysql/conf.d/my.cnf \
-v $PWD/master/data:/var/lib/mysql \
mysql:8.0
启动之后,最好先确认主库状态。进入容器创建一个专门用于复制的账号,不要直接用root:
sql复制CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123456';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
REPLICATION SLAVE权限只是让从库有权限拉取binlog,并不授予业务读写权限。账号不需要什么SUPER、ALL PRIVILEGES,权限最小化是基本的安全意识。
3.4 从库配置与首次启动:先别急着配置复制源
从库的my.cnf同样要设置独一无二的server-id。例如:
ini复制[mysqld]
server-id=2
log-bin=mysql-bin-2
binlog_format=ROW
gtid_mode=ON
enforce-gtid-consistency=ON
read_only=ON
从库上建议先把read_only=ON打开。这个参数会导致普通账号只能执行写操作,但拥有SUPER权限的账号不受影响。这样设计是为了防止应用因为误配置直接把数据写到从库,那是复制一致性的大敌。启动从库容器时,把宿主机3307映射到容器3306:
bash复制docker run -d \
--name mysql-slave \
-p 3307:3306 \
-e MYSQL_ROOT_PASSWORD=Root@123456 \
-v $PWD/slave/conf/my.cnf:/etc/mysql/conf.d/my.cnf \
-v $PWD/slave/data:/var/lib/mysql \
mysql:8.0
首次启动时不要马上执行CHANGE MASTER,因为主库可能还没有完全准备好,更关键的是如果后续要做全量数据补齐,需要先确定从库的初始数据来源。
3.5 CHANGE MASTER TO与START REPLICA:GTID模式下的最小命令集合
在主库确认已经打开GTID且插入一点基础数据后,进入从库容器,执行:
sql复制CHANGE MASTER TO
MASTER_HOST='mysql-master',
MASTER_PORT=3306,
MASTER_USER='repl',
MASTER_PASSWORD='Repl@123456',
MASTER_AUTO_POSITION=1;
START REPLICA;
在MySQL 8.0里,旧版的START SLAVE虽然还能用,但官方已经推荐使用START REPLICA。MASTER_AUTO_POSITION=1告诉从库不要自己去猜log file和pos,直接基于GTID自动协商同步位点。执行完START REPLICA后,立刻检查:
sql复制SHOW REPLICA STATUS\G
你需要在输出里找几个关键字段:
Replica_IO_Running: YesReplica_SQL_Running: YesSeconds_Behind_Source: 0Last_IO_Errno: 0Last_SQL_Errno: 0
如果全是这样的值,复制链路已经建立。很多人到这里就收工了,但我会顺手做一个主从数据验证。
3.6 实测数据同步:主库建库建表插入,从库立即看到
在主库执行:
sql复制CREATE DATABASE demo;
USE demo;
CREATE TABLE user (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO user(name) VALUES ('zhangsan'), ('lisi'), ('wangwu');
然后到从库执行SELECT COUNT(*) FROM demo.user;,正常情况下能看到3条记录。如果你发现从库看不到,先别慌,基本判断思路是这样的:在从库执行SHOW REPLICA STATUS\G看IO和SQL线程是否都在Yes状态;如果IO线程No,检查Last_IO_Error,多数是账号权限或网络不通;如果SQL线程No,检查Last_SQL_Error,常见是主从执行冲突。这里我提供一个快速验证GTID同步进度的SQL:
sql复制SELECT @@GLOBAL.gtid_executed;
在主从两边分别执行,主库会显示比如3e11fa4a-8a4e-11ee-8a6a-0242ac110002:1-5,从库一般会显示到相同的数字。如果从库的GTID集合落后,说明还有中继日志在执行中;如果长期不追平,就要检查从库是否有大查询阻塞了SQL线程。
4. 数据不一致的破局:从已有业务的完整备份到追平GTID
上面演示的是“空库开始同步”,很顺利。但现实业务不是空库,你通常是在已经有业务跑着的中途决定做主从复制。这个时候主库里可能已经有了几十GB、甚至TB级的数据,并且不断有新增写入。如果直接配置复制,从库会从主库的某个历史位点重新拉binlog,而那个位点非常早,意味着主库的binlog可能早已被purge,从库永远追不上。这个场景我在线上经历的次数不少,操作流程值得详细记录。
4.1 基于mysqldump的全量初始化:--source-data与--set-gtid-purged的区别
最正统的办法是在主库做一次全量备份,将这个备份恢复到从库上,然后从库从备份时刻的GTID位点开始增量追平。使用mysqldump时,有两个参数非常关键。
--single-transaction:对InnoDB表做一致性快照备份,不锁表。--set-gtid-purged=ON:在导出文件中生成SET @@GLOBAL.gtid_purged语句,从库恢复时可以进行相应设置。- 传统复制模式下会用到
--master-data=2,在GTID模式下不那么依赖,但加上也无妨。
导出命令:
bash复制mysqldump -uroot -p'Root@123456' \
--single-transaction \
--set-gtid-purged=ON \
--databases demo > demo_full.sql
注意,如果是只导业务数据,不导出mysql系统库。重点在于,备份文件开头会看到类似:
sql复制SET @@GLOBAL.GTID_PURGED='3e11fa4a-8a4e-11ee-8a6a-0242ac110002:1-100';
这行内容的作用就是告诉新的从库:在开始处理后续binlog之前,这100个事务已经被包含在备份里了,直接从101开始同步。这意味着你不用关心主库当前的log_file和log_pos,GTID已经帮你完成了对齐。
4.2 将备份导入从库并启动复制
假设你新起了一台干净从库,里面除系统库外什么都没有。先导入备份:
bash复制mysql -uroot -p'Root@123456' < demo_full.sql
再执行CHANGE MASTER和START REPLICA。因为GTID是被自动协商的,从库会拿着自己数据里体现的gtid_executed去主库请求后续binlog。这比传统模式记录文件名加pos要可靠得多,不用怕主库日志切换导致文件名变化。
4.3 主从数据校验:手工抽查与工具对比
同步完成后不要盲目信状态,我自己会在几张核心表上用MAX(id)与COUNT(*)抽查,或者用percona-toolkit中的pt-table-checksum做数据一致性比对。如果差异比较大,大概率是某张表复制过滤规则配置出错,或者主库修改了表结构没有同步到从库。因为MySQL的DDL操作也会写binlog,ALTER TABLE如果从库执行失败,SQL线程会停下,这时如果没有监控,很容易出现长期主从数据偏移。
5. Spring Boot业务层接入读写分离:不是简单写两个数据源就完事
搭建完MySQL主从复制,只有从代码层面完成读写路由,读写分离才真正生效。下面是我的实践配置,基于Spring Boot 2.7/3.x都可以落地,核心思路是借助AbstractRoutingDataSource实现动态数据源路由。
5.1 引入依赖与基本项目结构
仍然使用大家最熟悉的Spring Boot,加上MyBatis-Plus或Spring Data JPA都可以。如果只想验证读写分离,我建议先搭建一个最小Spring Boot项目,引入:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
如果你用MyBatis,再加mybatis-spring-boot-starter即可。
5.2 动态数据源路由的核心原理
Spring的AbstractRoutingDataSource维护了一个targetDataSources的Map,每次执行数据库操作时通过determineCurrentLookupKey()决定实际使用哪个数据源。我们利用这个机制,在方法或事务开始时,根据当前操作的类型,把“写入走主库、读取走从库”的key绑定到ThreadLocal。整个设计分为三部分:
- 自定义注解,例如
@ReadOnly标记只读方法。 - AOP切面或手动拦截器,当识别到
@ReadOnly时设置当前数据源为从库,否则默认主库。 - 扩展
AbstractRoutingDataSource,在determineCurrentLookupKey()中读取ThreadLocal的key。
简单数据源配置类大概长这样:
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceHolder.getDataSourceKey();
}
}
而DynamicDataSourceHolder:
java复制public class DynamicDataSourceHolder {
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public static void setMaster() { CONTEXT_HOLDER.set("master"); }
public static void setSlave() { CONTEXT_HOLDER.set("slave"); }
public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); }
public static void clear() { CONTEXT_HOLDER.remove(); }
}
5.3 基于注解完成读写切换的落地代码
我偏好用AOP配合@ReadOnly注解,可读性好。如果你怕AOP生效范围不好控制,也可以在Service方法里手动调用DynamicDataSourceHolder.setSlave()和clear(),但那样容易遗漏finally清理。
自定义注解:
java复制@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
}
AOP切面:
java复制@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(readOnly)")
public Object switchDataSource(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable {
DynamicDataSourceHolder.setSlave();
try {
return joinPoint.proceed();
} finally {
DynamicDataSourceHolder.clear();
}
}
}
这里有个关键点非常容易被忽略:如果某个查询方法内已经有事务注解@Transactional(readOnly = true),事务管理器会默认使用主数据源,因为事务管理器是在启动时绑定某个DataSource的。也就是说,你要么把事务管理器也改造成动态路由的,要么尽量让@ReadOnly注解的方法不开启Spring事务。早期我在一个查询方法上同时用了@Transactional(readOnly = true)和@ReadOnly,结果SQL一路打到主库,排查半天才意识到是DataSourceTransactionManager把连接固定在主数据源上。所以我的建议是:读写分离场景下,写方法用@Transactional,读方法不要依赖@Transactional(readOnly=true)做路由,而是通过@ReadOnly + AOP控制数据源。
5.4 YAML中的多数据源配置
在配置文件里定义两个DataSource,主库写,从库读:
yaml复制spring:
datasource:
master:
jdbc-url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: Root@123456
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
jdbc-url: jdbc:mysql://localhost:3307/demo?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: Root@123456
driver-class-name: com.mysql.cj.jdbc.Driver
注意,从库端口3307是我们在宿主机上暴露的端口。如果应用也跑在同一台宿主机,直接连localhost没问题。如果应用在另一个容器,需要让数据库容器与应用容器在同一Docker网络里,使用服务名访问。
DataSource配置类:
java复制@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() { return DataSourceBuilder.create().build(); }
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); }
@Bean
public DynamicDataSource dynamicDataSource() {
DynamicDataSource dataSource = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave", slaveDataSource());
dataSource.setTargetDataSources(targetDataSources);
dataSource.setDefaultTargetDataSource(masterDataSource());
return dataSource;
}
}
关键点是默认数据源必须设置为主库。因为所有的写操作、事务操作以及系统内部的元数据操作,在没有显式说明时都应该落到主库。如果默认设置成从库,很容易发生“写进去竟然成功了但我没看见数据”这种诡异错误。
5.5 路由策略的取舍与常见误区
读写分离落地的难点并不在于配置本身,而在于如何防止误读。主库写成功之后,应用要立即查询这条记录,如果查询被路由到从库,而延迟还没有追上,就会出现“数据消失”或“旧数据”现象。针对这个场景,常规的做法包括:
- 刚写入过的数据强制走主库。可以在保存方法返回实体时,手动设置
DynamicDataSourceHolder.setMaster()去查询。 - 关键页面,例如订单支付成功后的详情页,直接走主库;列表页走从库。
- 全链路追踪中打印当前数据源key,提前暴露路由错误。
很多公司并不强求从库读绝对实时,因为报表、搜索、详情列表对几毫秒的延迟容忍度高。但用户中心这种场景,用户改完昵称马上看到旧名,体验就很糟。所以千万别指望一个@ReadOnly注解解决所有问题,每个业务接口都需要根据读取一致性要求做路由设计。
6. 监控、告警与从库延迟问题:别等主从分裂了才发现
运维层面最怕的还是主从断了没人知道。从库IO线程或SQL线程如果有一个停止,从库数据就会一直停滞。等你发现时,从库与主库的差距可能已经达到几小时甚至几天。
6.1 一张SQL学会计算主从复制延迟
如果要写监控脚本,核心就是不断采样主从的位点:
sql复制SELECT
MASTER_POS_WAIT('mysql-bin-1.000001', 0) AS current_pos_wait,
NOW();
但实际生产上我更多依赖SHOW REPLICA STATUS里的字段。
我常用的一个延迟计算思路是:“从库当前正在执行的binlog事件时间戳与主库当前最新事件时间戳的差”。在GTID模式下,可以从性能表performance_schema.replication_applier_status_by_worker读取正在执行的事务时间,然后用主库当前事务时间减它。这只是大概的估计,但如果延迟在10秒以上,业务上已经能明显感知到了。
6.2 延迟高时的经典排查路径
如果你发现Seconds_Behind_Source不为0,或者业务查询明显读到旧数据,按下面顺序排查:
- 先看从库机器负载,是CPU还是IO被打满。
- 检查从库是否有大查询Long Query占用了线程资源。
- 查看主库的binlog写入量,确认是大事务还是大量小事务。
- 如果是大事务,主库一次性产生几GB binlog,从库需要顺序执行,延迟必然上升。
- 查看从中继日志到执行的耗时,SQL线程是否被某个DDL卡住。
一旦主从延迟出现了,最先能做的减灾措施就是路由摘除延迟大的从库,让流量暂时全部打主库,避免读旧数据。
6.3 告警项应该如何设置
不要只监控线程状态,还要监控延迟和主从切换事件。我最少会在以下指标上做告警:
- Replica_IO_Running是否是Yes。
- Replica_SQL_Running是否是Yes。
- 延迟超过预设阈值,例如10秒,持续超过1分钟。
- 主从两端GTID集合差异超过预设的事务数。
告警工具可以简单用Shell脚本加crontab,也可以接入Prometheus的mysqld_exporter。这部分依公司基础设施而定。
7. 生产环境踩坑实录:那些让主从复制突然失效的隐藏手雷
把一套可用的主从环境跑起来并不难,难点在于跑起来之后它能稳定运行不给你显眼。我把自己早年间踩过的几个坑拿出来讲讲,有些配置问题单看官方文档根本不会注意到。
7.1 binlog过期时间与数据补齐的矛盾
MySQL 8.0里,默认的binlog_expire_logs_seconds可能是2592000秒,也就是30天。这在测试环境没什么问题,但生产环境如果你的binlog保存时间过长,会占用大量磁盘。有不少DBA为了省磁盘,把binlog过期时间设成3600秒甚至更短,觉得只保留一小时够了。
当主库出现瞬时大流量写入,或者你新加一台从库时需要基于历史binlog做数据补齐,如果binlog已经被清理,从库的IO线程会报错The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION=1, but the master has purged binary logs containing GTIDs that the slave requires。这基本上意味着你只能重新全量初始化这台从库,非常被动。建议确认从库离线或扩容时间窗口,备份一份binlog归档再清理。
7.2 max_allowed_packet太小导致从库SQL线程中断
有一段时间我在主库执行了一批批量UPDATE,单条SQL影响的行数比较多。从库的SQL线程跑着跑着就挂了,报错信息显示log event entry exceeded max_allowed_packet。原因是binlog中的一个事件超过了从库max_allowed_packet的限制。主库能执行完写入,不代表从库能接收和处理。解决方案是在主从两边的配置里都把max_allowed_packet设成足够大,例如64M或128M。比较稳妥的做法是两边设置一致,避免出现这类边界不一致问题。
7.3 不同版本之间复制导致认证插件不兼容
MySQL 5.7的默认认证插件是mysql_native_password,MySQL 8.0的默认是caching_sha2_password。如果主库是5.7,从库是8.0,你用老密码插件创建复制账号,也许能连上;反之,如果主库8.0、从库5.7,创建账号时没指定mysql_native_password,从库IO线程会一直报认证失败。解决办法是在主库创建复制账号时明确指定插件:
sql复制CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123456';
而且主从大版本最好保持一致,8.0环境内部互通是最省心的。
7.4 使用SSL加密复制连接时的证书信任问题
如果你的主库开启了require_secure_transport,或者你在CHANGE MASTER语句里特意指定了MASTER_SSL=1,但没有正确配置CA证书,从库的IO线程会反复报SSL连接错误。虽然复制链路走的是内网,但为了安全开启SSL是合理的。不过配置起来要格外注意证书路径和权限。如果只是测试环境,可以先不启用SSL,避免排查范围扩大。
7.5 SQL线程重复执行导致的从库主键冲突
如果在测试环境下,你手动在从库上插入了某条业务数据,那么主库如果再插入相同主键的数据,从库重放时就会报主键冲突,SQL线程停止。这个时候可以通过跳过特定事务的方式恢复,但要准确识别跳过哪一个事务非常麻烦。如果冲突数量多,最干净的办法仍然是重建从库。所以生产环境一定要给从库开read_only=ON,凡是应用账号都不给写权限,让所有写入都老老实实走主库。
8. 从单点到一主多从:扩展与故障切换的下一步思考
当你在一主一从上跑通之后,自然会想:如果从库也需要扩容呢?如果主库挂了怎么办?这一节作为从入门到进阶的方向梳理。
8.1 新增一台从库时,怎么避免长时间锁表
沿用前面讲的mysqldump逻辑即可。新增从库时先做全量备份,恢复到新从库后,用GTID自动对齐到最新位点。整个过程不影响主库线上服务,但要注意备份期间产生的binlog量。如果数据量非常大,mysqldump会成为一个IO密集型的任务,建议在业务低峰期执行,或者使用物理备份工具如Percona XtraBackup来减少锁和IO压力。
8.2 链路拓扑:级联复制与双主复制
多从库可以挂在同一个主库下,也可以设计成级联复制:主库A复制到从库B,B再复制到C。减少主库对多个从库的binlog分发压力。双主复制则是两个节点互为主从,用于实现高可用切换。MySQL 8.0的Group Replication和InnoDB Cluster提供的是更自动化的高可用方案,但底层的原理仍然是基于binlog和GTID的事件复制。
8.3 主库故障时的应用层切换思路
如果主库宕机,所有写流量都会失败。怎么快速将某个从库提升为主库?流程可以大致整理为:如果还有存活从库,先确定哪台延迟最小;在升主之前,先停掉该从库的复制线程;执行STOP REPLICA; RESET SLAVE ALL;清掉复制信息;在其余从库上重新设置复制源指向新主库;在业务配置或注册中心里把写数据源切换到新主库的地址。如果用了数据库中间件或代理层,很多步骤会被自动管理。但如果你只有裸的Spring Boot多数据源,切换过程往往需要人工介入。这也是为什么生产环境建议引入MHA、Orchestrator或MySQL Router这些工具的原因。
9. 这套方案跑起来后,我对主从复制更深一层的体会
文章写到这里,最后再说点实际体会。如果你刚把主从复制跑通,发现从库查询速度并没有比原来快多少,一方面可能是索引没有建好,另一方面可能是写入压力本来就不是你系统的主要瓶颈。数据链路从“一次查询”变成“主写从读”,本质上增加了系统的复杂度,你需要付出额外的精力来管理路由规则、监控延迟、应对故障。只有当读写比例、数据一致性要求、团队运维能力都匹配时,这个复杂度才是值得的。
我在做读写分离路由时有一个很深的感受,技术架构上没有万能的银弹。主从复制解决的是读扩展和容灾问题,但它也会带来查询不到最新数据的麻烦、额外的一台机器成本、需要定期巡检的数据一致性隐患。对一个刚起步的业务来说,如果单库还能撑住,就别过早为了“架构先进”引入主从。等到数据量涨到一定程度,再来做这套方案也为时不晚,因为整个迁移路径已经很成熟。
另外一个小建议是,至少要在沙箱环境完整演练一遍主库挂掉和从库提升为新的主库的过程。这样当生产环境的故障真的发生时,团队才有足够的肌肉记忆去响应。如果这期间你能把每一步的检查清单都记录在案,那是很有价值的团队资产。我自己每次搭建完一套主从环境时,都会强制自己在半小时内完成一次主从切换演练,确保后续在事故发生时不会因为少配了一个权限或漏检查一个GTID集合而手足无措。
