我见过很多团队在数据库上栽跟头的方式:不是CPU突然打满,而是连接数先爆,几百个请求全部卡在“获取连接”上,一查慢日志,绝大部分就是几条简单的SELECT。这种场景下,很多人第一反应是加缓存、加索引,但真正该做的往往是先搭一套MySQL主从复制,把读流量从主库上剥离开。
这也就是“MySQL主从复制与读写分离”这套组合存在的意义:主库专心处理写请求,从库分担读请求,再配合复制延迟监控、故障切换预案,整个系统扛读的能力能上一个量级,而且架构成本并不高。这篇内容我会从复制原理讲起,用Docker给你搭一套可复现的MySQL 8.0主从环境,再讲应用层怎么写路由才能让读写真正分离,最后把主从延迟、数据一致性、故障切换这些生产里绕不开的问题一次说透。
1. 单库扛不住读的时候,为什么要引入主从复制?
1.1 先想清楚“读写分离”到底解决什么问题
绝大多数业务系统是典型的读多写少。用户在浏览商品、刷文章、查订单,真正产生写入的只有下单、评论、支付回调这些动作。如果这些读SQL全部压在同一台MySQL上,即使每一条都走了索引,连接数也会先成为瓶颈,因为一条查询无论多快,都要占用一个连接、一份内存、一部分CPU时间片。
主从复制的第一个价值,就是让读流量可以从主库“溢出”到从库。主库继续负责写,从库接受大部分读请求,主库的连接压力和IO负载立刻降下来。这个收益不是靠优化SQL能换来的,因为SQL优化解决的是单条查询的效率,而读写分离解决的是“查询总量超过单实例承载能力”的问题。
第二个价值容易被忽略:它把“备份”和“分析”这类重活从主库上摘出去了。很多团队会在凌晨用主库直接跑报表或做全量备份,主库的IO和CPU瞬间飙高,白天业务峰值时的性能其实已经被这种夜间任务影响了。有了从库,备份、离线分析、数据抽取这些操作可以全部打到从库上,不影响主库的在线业务。
第三,它才构成高可用的基础。没有副本的主库宕机,数据库恢复时间取决于磁盘和备份策略;有了从库,就有了“把流量切到副本”的可能性,虽然这需要配套的切换工具,但至少架构上不再是单点。
1.2 这套方案不是万能的,边界要提前划清楚
我经常看到团队把主从复制当成“万能药”,遇到性能问题就加从库。实际上有好几类问题主从复制解决不了。
写多读少的业务,瓶颈在主库写入能力,加从库不会让主库写得更快,反而会让主库多一份binlog推送的开销。大事务慢查询、全表扫描导致主库IO打满,从库同样会遭殃,因为从库要回放同一条SQL。需要强一致读的业务,比如用户刚下单就立刻在订单列表看到这条记录,如果读从库正好赶上复制延迟,就会出现“明明写成功了却查不到”的诡异体验,这种场景需要特殊处理。
还有一个常见误区:认为配了主从就等于高可用。实际上主库宕机后,从库不会自动顶上,必须有人或工具触发切换,应用层的连接也得跟着改。这是另一套工程。所以这篇文章后面我专门留了一节讲高可用,就是提醒大家“复制”和“高可用”之间还隔着一层切换机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主从复制的底层逻辑:binlog、relay log与三个线程的接力
2.1 从一条写SQL提交到从库回放,中间发生了什么
理解主从复制,核心是理解三个线程的接力:主库上的Binlog Dump线程,从库上的I/O线程和SQL线程。
当应用在主库执行一条INSERT并提交成功后,这条事务会按配置写入主库的binlog文件。此时从库的I/O线程会主动连上主库,请求从某个binlog位置开始的数据。主库的Binlog Dump线程负责把binlog里新产生的事件推给从库。从库I/O线程收到后,并不直接执行,而是先写入从库本地的relay log(中继日志),相当于先落一个中转文件。从库的SQL线程再异步读取relay log,把里面的每一条事务在从库上按顺序回放一遍。
这里可以用一个比喻帮助理解:主库的binlog是仓库的出货台账,Binlog Dump线程是负责把台账页发给分店的调度员;从库的relay log是分店的临时收货区,I/O线程是收货员,只负责把货搬进收货区;SQL线程则是上架员,按顺序把货一件件摆上货架。收货速度可以很快,但上架速度取决于货架是否拥挤、有没有人挡路。
明白了这个链路,很多现象就能解释了。比如I/O线程正常但SQL线程卡住,说明网络传输没毛病,问题出在从库自身执行层面。再比如主库刚提交的事务,从库要经历“写入binlog->网络传输->写入relay log->SQL线程回放”四个步骤,必然存在一个时间差,这个时间差就是复制延迟,后面第五部分会详细展开。
2.2 binlog格式怎么选:STATEMENT、ROW还是MIXED
binlog是复制的基础,但binlog里记录什么、以什么形式记录,直接决定了复制的正确性和体积。MySQL支持三种格式,很多人配置时随意选,这里面的坑值得说清楚。
| 格式 | 记录内容 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | SQL语句本身 | binlog体积小,可读性强 | NOW()、RAND()等不确定函数会导致主从数据不一致 |
| ROW | 每一行的实际变更前/后镜像 | 复制结果最准确,任何变更都能精确回放 | binlog体积大,尤其大事务会产生海量日志 |
| MIXED | 自动切换 | 普通语句按STATEMENT记录,不安全的自动转ROW | 行为有一定不可预测性,排查问题时要兼顾两种格式 |
MySQL 5.7.7及以后版本默认是ROW格式,MySQL 8.0延续了这个默认值。我在实际生产环境中几乎只使用ROW格式,原因是它从根本上杜绝了“主库执行结果和从库回放结果不一致”这类问题。比如一条UPDATE ... WHERE命中一万行,如果按STATEMENT记录,从库必须执行完全相同的一条SQL,一旦从库的数据和主库有细微差异,这一万行就可能更新出不同结果;而ROW格式记录的是每一行的主键和更新前后值,从库按主键精确修改,几乎不存在歧义。
ROW格式也有代价,一个大事务删除一百万行会产生上GB的binlog,对磁盘和网络都是压力。但这属于可控成本,一致性比性能更重要。
2.3 GTID复制:为什么比传统位点复制更适合现代架构
传统主从配置里,从库要明确告诉主库“我要从哪个binlog文件、第几个字节开始拉数据”,也就是File和Position两个坐标。这套机制本身没问题,但一旦发生主从切换,事情就很麻烦:原来从库可能已经在跑一些新主缺失的事务,新主也要重新梳理该给从库推什么,人工介入容易出错。
GTID(Global Transaction Identifier)把每个事务分配一个全局唯一ID,格式是server_uuid:transaction_id。主从复制不再需要追文件坐标,从库只要开启SOURCE_AUTO_POSITION=1,主库就能根据主从双方各自已执行的GTID集合,自动算出该给从库推送哪些缺失事务。主从切换后,新从库连上新主,同样只要GTID集合能对上就能继续复制,运维门槛低很多。
MySQL 8.0默认已经开启了GTID模式,所以现在新搭集群没有必要再去用传统位点方式。我曾经接手过一个老项目,从库配置还是十年前的MASTER_LOG_FILE和MASTER_LOG_POS写死的,每次主库binlog轮转或故障恢复都要手工校正,后来把整个集群切到GTID模式,类似问题基本绝迹。后面的Docker实操我会直接用GTID配置。
3. Docker Compose一步到位搭建MySQL 8.0主从复制环境
3.1 为什么用Docker来做演示
用Docker搭主从的最大好处是可复现、不污染宿主机。很多人本机已经装了MySQL,如果再装一个实例,端口、socket、配置文件全是坑。而Docker Compose可以把主库和从库做成两个独立容器,服务名直接当主机名互相访问,清理的时候一条docker compose down就全部带走。我这里用MySQL 8.0镜像,8.0的复制语法和参数相对现代,适合作为新项目的参考基线。
3.2 目录结构和主从配置文件
先创建如下目录结构:
code复制mysql-cluster/
├── docker-compose.yml
├── master/
│ └── conf/
│ └── my.cnf
└── slave/
└── conf/
└── my.cnf
主库配置文件master/conf/my.cnf:
ini复制[mysqld]
server-id=1
log-bin=mysql-bin
binlog_format=ROW
gtid_mode=ON
enforce_gtid_consistency=ON
从库配置文件slave/conf/my.cnf:
ini复制[mysqld]
server-id=2
log-bin=mysql-bin
relay-log=relay-bin
binlog_format=ROW
gtid_mode=ON
enforce_gtid_consistency=ON
read_only=ON
配置里最关键的是server-id,主从必须不一样,这是MySQL区分实例身份的标识。如果两台实例server-id相同,复制会直接报错,这是新手最容易踩的坑。另一个是gtid_mode=ON和enforce_gtid_consistency=ON,两者必须同时开启,否则MySQL不允许执行某些可能破坏GTID一致性的语句。从库额外加了read_only=ON,防止业务误连到从库后写入数据。注意read_only拦不住具备SUPER权限的用户,所以生产环境最好再加一层账号权限管控,尽量不让应用账号拥有SUPER权限。
docker-compose.yml内容如下:
yaml复制services:
master:
image: mysql:8.0
container_name: mysql-master
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root_123
TZ: Asia/Shanghai
ports:
- "13306:3306"
volumes:
- ./master/conf:/etc/mysql/conf.d
- master_data:/var/lib/mysql
slave:
image: mysql:8.0
container_name: mysql-slave
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root_123
TZ: Asia/Shanghai
ports:
- "13307:3306"
volumes:
- ./slave/conf:/etc/mysql/conf.d
- slave_data:/var/lib/mysql
volumes:
master_data:
slave_data:
我把宿主机端口映射成了13306和13307,避免和本机已有的3306端口冲突。容器内部从库访问主库时,直接用Compose网络里的服务名master加3306端口,不需要经过宿主机端口映射。
3.3 启动服务并创建复制账号
在mysql-cluster目录下执行:
bash复制docker compose up -d
容器启动后,先确认两个MySQL实例都处于健康状态:
bash复制docker ps
然后进入主库容器,创建复制专用账号。这里有个关键点:MySQL 8.0已经不再支持GRANT ... IDENTIFIED BY这种老写法,必须先CREATE USER再GRANT:
bash复制docker exec -it mysql-master mysql -uroot -proot_123
在主库的MySQL命令行里执行:
sql复制CREATE USER 'repl'@'%' IDENTIFIED BY 'repl_pass_123';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
复制账号只需要REPLICATION SLAVE这一个权限,不需要其他任何业务权限,这是最小权限原则。密码强度自己把握,演示环境我用了简单密码,生产环境务必使用强密码并限制来源主机,而不是用'%'放开所有来源。
查看主库当前的binlog状态:
sql复制SHOW BINARY LOG STATUS;
如果你的MySQL版本比较老,可以用SHOW MASTER STATUS;,两者返回的信息类似。在GTID模式下,这个命令主要用来看当前binlog文件,方便后面和从库状态做对比。
3.4 配置从库复制源并启动复制
进入从库容器:
bash复制docker exec -it mysql-slave mysql -uroot -proot_123
执行配置复制源。MySQL 8.0.23以后推荐用CHANGE REPLICATION SOURCE TO,老版本用CHANGE MASTER TO。这里给出GTID自动定位的写法:
sql复制CHANGE REPLICATION SOURCE TO
SOURCE_HOST='master',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='repl_pass_123',
SOURCE_AUTO_POSITION=1;
START REPLICA;
如果执行`CHANGE
