很多人对“MySQL主从复制”的第一印象是:这是DBA的活儿,开发不用管。可真当自己负责一个系统、一台服务器甚至一个课程项目时,数据库挂了没人帮你切,读流量大了也没人帮你分担,这时候才意识到主从集群的基础知识绕不过去。这篇我决定把MySQL主从复制的原理和一套基于Docker的实战部署放在一起讲,先搞懂日志是怎么流转的,再动手把容器环境搭起来,最后给出我实际踩过的几个坑和排查思路。无论你是刚学MySQL没多久的技术新人,还是正打算给团队搭一套测试环境的后端开发,这篇文章都适合你照着操作一遍。
先说结论:主从复制没有想象中那么神秘,核心就是一台主库把变更记录写进binlog,从库把这段日志拉过来重新执行一遍。但真正动手部署时,细节远比原理多——容器网络怎么规划、配置漂到哪里、位点怎么对齐、报错怎么排查,每一步都可能卡住一两个小时。我会把整套过程拆成可复现的步骤,并且把我遇到过的故障现场直接复盘给你。
1. 从单库到主从:先想清楚为什么要做这件事
1.1 单库的三个天花板
我见过不少团队,一开始都觉得自己“用不上集群”,理由是数据量没那么大、并发没那么高。但实际上,单库模型会先后撞上三堵墙。
第一堵墙是读写并发。业务一忙,所有读和写都挤在一台实例上,连接数先被占满,接着慢查询和锁等待交替出现,系统响应时间直线上升。第二堵墙是单点可用性。这台机器一旦宕机、磁盘满、内存耗尽或者被误操作清空,整个依赖它的服务就全部瘫痪,而且恢复时间完全看运气。第三堵墙是数据备份的低效。对单库做物理备份或逻辑备份,要么独占资源,要么备份过程本身就会影响线上查询。
主从复制对这三堵墙给出了一套朴素但实用的解法:主库负责写,从库分担读;主库意外挂掉后可以手动把从库提升为主库;备份操作也能切到从库执行,不干扰主业务。这也是为什么即便现在各种分布式数据库层出不穷,MySQL主从仍然是最普遍、最经济的一种架构选择。
1.2 主从不是万能药,先搞清楚它的边界
说句实在话,不少人对主从复制寄予了过高的期望,以为搭完之后数据库就自动“高可用”了。实际上MySQL原生主从复制并不会自动故障转移——主库挂了以后,从库不会自己站出来接客。你需要借助MHA、Orchestrator这类额外工具,或者自己写好脚本去做提升和切换。
主从复制也解决不了“写的水平扩展”问题。所有的写入仍然要走主库,从库只是把主库的日志拿来重新跑一遍,本质上是增加读能力而不是改写能力。如果你期望的是分库分表、自动扩容,那主从复制只是其中最底层的基座,还得再叠加中间件层面的方案。
另外还有一个经常被忽略的问题:异步复制本身意味着从库数据“最终一致”,极端情况下主库突然宕机,有些日志还没来得及发给从库,这部分数据就会丢失。生产环境想降低风险,还得考虑半同步复制或无损复制方案。我把这些边界写在前头,是不希望你们看完文章后把它直接搬到生产环境,却发现和想象中不太一样。
2. 主从复制的工作机制:binlog日志的流转逻辑
2.1 三个线程把日志串起来
主从复制说白了是一个日志传递和回放的过程,三条日志环节参与其中:主库的binlog、从库的relay log(中继日志)、以及从库上执行的SQL线程。你可以把主从复制想象成一份报纸从编辑部送到读者手里的过程:编辑部负责写稿(主库写入binlog),快递员把报纸从编辑部运到站点(I/O线程拉取binlog到relay log),读者再从站点取报阅读(SQL线程回放relay log)。三个环节只要有一个断了,整个复制链路就会出问题。
具体来说,主库会运行一个专门的binlog dump线程,每当有事务提交,就把产生的日志记录写入binlog文件。从库上则有两个常驻线程:I/O线程负责连接主库,读取主库binlog中的新事件,写到本地relay log;SQL线程负责读取relay log并顺序执行这些事件,使从库的数据变更和主库保持一致。
这里有个很常见的误区:从库并不是直接下载主库的binlog然后自己解析执行,而是先完整落到relay log再回放。所以你在容器里查看从库数据目录时,可能会看到类似relay-log.000002这样的文件,它们是复制的中间产物,没有它们,复制链路照样转不起来。
2.2 binlog格式怎么选
binlog有三种格式:STATEMENT、ROW、MIXED。
STATEMENT格式记录的是SQL语句本身,优点是日志量小,缺点是某些语句在不同机器上执行结果不一定一致,比如使用了UUID()、NOW()这类非确定性函数,或者对数据依赖顺序的操作,在主库和从库执行结果可能不一样。
ROW格式记录的是实际变更前后的行数据,日志量相对更大,但复制结果最可靠,任何一条数据改动都能精确地在从库重放,也是官方推荐和8.0版本默认使用的格式。MIXED格式则让MySQL根据语句类型自行判断,有时记录语句,有时记录行。
我的建议是:没有特殊理由,直接用ROW格式。虽然日志文件会变胖,但换来了可预期的一致性,遇到麻烦时定位问题也容易得多。排查复制报错时,ROW格式的报错信息通常更直观,能直接告诉你是哪一行数据出了问题。
2.3 位点模式与GTID模式:两种“物流单号”的区别
传统的主从复制靠“文件名+文件偏移量”来定位日志位置,也就是常说的binlog文件名加POS。配置时通过MASTER_LOG_FILE和MASTER_LOG_POS指定从主库哪个位置开始拉取日志。
这种方式最大的问题是:位点信息是写在配置文件或命令里的,一旦后续要重置复制关系、增加从库、切换主库,很容易因为位点填写错误导致复制起点不对,排查起来非常痛苦。GTID(Global Transaction Identifier)就是为了解决这个问题而引入的全局事务标识。
你可以把GTID理解成一个“快递单号”,每个事务在全局都有唯一编号。从库不再需要记住某个文件的具体字节偏移,只需要告诉主库“我已经执行过哪些事务编号”,主库就知道该把剩下哪些事务发给它。配置上更简单,逻辑上也更清晰,跨库迁移或故障切换时会方便很多。
我实操时的建议是:如果是全新搭建的环境,直接上GTID模式;如果是已有老环境要改造,需要先确认所有实例版本都支持GTID,并按照官方步骤逐步切换。这篇文章里的部署会同时给出两种方式,你可以自己选。
3. Docker化部署前的三个关键决定
3.1 镜像版本:8.0还是5.7
很多人还停留在MySQL 5.7的习惯里,但这里我建议新项目直接使用8.0。8.0已经发布多年,功能、性能和稳定性都成熟了,参数默认值也更合理,比如默认字符集是utf8mb4,binlog默认就是ROW格式。8.0还在并行复制、安全认证等方面做了不少增强。
如果你是因为老项目而不得不用5.7,也要注意它已经停止官方维护,尽量不要再用它搭新环境。镜像选官方仓库的mysql即可,注意拉取时明确指定标签,比如mysql:8.0,不要随手敲个latest,避免后面版本变化带来不必要的影响。
还有个小提醒:MySQL 8.0的默认认证插件是caching_sha2_password,如果你使用的客户端工具版本较旧,会出现连不上的情况,需要在创建用户时显式指定mysql_native_password,或者在客户端升级适配。这个坑在创建复制账号时也会遇到,后面我会细说。
3.2 网络模式:容器主从必须互通
Docker部署主从有一个容易想当然的环节:网络。两个容器各起各的,端口也都映射到宿主机了,但容器内部的IP并不属于同一张网卡,互相之间可能根本ping不通。
最稳妥的做法是创建一个自定义bridge网络,让主库和从库都连接到这个网络里,并使用容器名作为互相访问的地址。这样做的好处是容器重建后IP变了也不影响通信,因为MySQL连接时使用的是容器名对应的内部DNS解析。
我见过有人图省事把主从连接地址写成宿主机IP加映射端口,这在部分场景下也能跑通,但当宿主机IP变化、防火墙调整、多套环境并存时,问题会成倍增加。自定义网络并不麻烦,强烈建议一开始就按规范来做。Windows和Mac上用Docker Desktop也一样,先创建网络再启动容器,这套流程是通用的。
3.3 配置与数据目录:第一次挂载最容易翻车
Docker容器本身是“临时”的,容器一删,数据就没了。所以部署有状态服务时,必须把配置目录、数据目录、日志目录通过-v参数挂载到宿主机上。
主库和从库的配置需要分别写到各自的宿主机目录中,例如/opt/mysql-master/my.cnf和/opt/mysql-slave/my.cnf,再挂载到容器内MySQL会读取的目录。官方镜像通常会自动加载/etc/mysql/conf.d下的配置片段,所以你只需要把自定义配置写到这个路径下就行。
我遇到的翻车现场,是有人把数据目录挂载到了宿主机上相同的位置,导致主库从库互相覆盖数据;还有人启动容器时忘了加-v,初始化完才发现容器一重启配置全没了。所以先规划好目录结构,再拿起始命令,这一步别急。
4. 从零部署MySQL主从环境:核心命令与完整操作
4.1 构建主库容器
我先创建一条自定义网络,名字起得清楚一点,后面两个容器都用它:
docker network create mysql-cluster-net然后准备主库的配置文件,存到宿主机/opt/mysql-master/my.cnf:
[mysqld] server-id = 1 log-bin = mysql-bin binlog_format = row gtid-mode = ON enforce-gtid-consistency = ON log-slave-updates = ON这里逐个解释一下。server-id在主从复制里必须唯一,它是实例身份的标识;log-bin开启二进制日志,没有它主从复制无从谈起;binlog_format=row是前面说过推荐的复制格式;gtid-mode和enforce-gtid-consistency用于启用GTID模式;log-slave-updates让从库在回放主库日志时也记录自己的binlog,这样从库以后还能继续做下一级主库,这个参数建议直接开着,避免以后要扩展时又要重启实例。
启动主库容器:
docker run -d \ --name mysql-master \ --network mysql-cluster-net \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=Root@123456 \ -v /opt/mysql-master/my.cnf:/etc/mysql/conf.d/my.cnf \ -v /opt/mysql-master/data:/var/lib/mysql \ mysql:8.0端口映射这里有个细节:我把主库的3306端口映射到宿主机3306,从库到时候映射到3307。如果本机其他端口被占用,你可以按自己的情况调整,但后面连接时端口别搞混。
容器启动后,等待十几秒让数据库完成初始化,再确认它正常运行:
docker ps | grep mysql-master docker exec -it mysql-master mysql -uroot -p输入密码进入MySQL控制台后,跑一下SHOW VARIABLES LIKE 'server_id';,确认读到的是配置里的值。如果读到默认的1,也可能配置没生效;如果读到自定义值,说明配置文件加载正常。
4.2 创建复制账号并锁定主库位点
在主库上执行下面的SQL,创建一个专门用于复制的账号,注意账号的host是通配符%,允许从库以任意来源IP连接:
CREATE USER 'repl'@'%' IDENTIFIED BY 'Repl@123456'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;如果你用了MySQL 8.0,这里要特别留意认证插件的问题。默认创建的账号使用caching_sha2_password,一些版本的MySQL从库连接时会报错“Authentication plugin 'caching_sha2_password' cannot be loaded”。最简单的兼容做法是创建时直接指定旧版认证插件:
CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123456'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;随后查看主库当前的日志文件和位点:
4.3 构建从库容器并建立复制通道
从库的配置放在/opt/mysql-slave/my.cnf:
[mysqld] server-id = 2 log-bin = mysql-bin gtid-mode = ON enforce-gtid-consistency = ON log-slave-updates = ON注意server-id不能和主库一样。从库其实也可以不开binlog,但既然我们用的是GTID模式,并且考虑到以后可能做级联复制或一主多从,建议同样开启。
启动从库容器:
docker run -d \ --name mysql-slave \ --network mysql-cluster-net \ -p 3307:3306 \ -e MYSQL_ROOT_PASSWORD=Root@123456 \ -v /opt/mysql-slave/my.cnf:/etc/mysql/conf.d/my.cnf \ -v /opt/mysql-slave/data:/var/lib/mysql \ mysql:8.0这里端口映射成3307,是因为同一台宿主机上3306已经被主库占用了。如果你在其它机器上部署,端口可以保持一致。
容器起来后,进入从库执行CHANGE MASTER语句。这里分两种模式说明。
使用GTID方式时,从库侧执行:
CHANGE MASTER TO MASTER_HOST = 'mysql-master', MASTER_PORT = 3306, MASTER_USER = 'repl', MASTER_PASSWORD = 'Repl@123456', MASTER_AUTO_POSITION = 1, GET_MASTER_PUBLIC_KEY = 1;MASTER_AUTO_POSITION=1表示使用GTID自动定位,GET_MASTER_PUBLIC_KEY=1用于解决MySQL 8.0首次连接时获取公钥的问题。你可能会问,为什么需要这个公钥参数?因为8.0的账号默认使用caching_sha2_password,首次握手建立加密连接时需要获取服务器的RSA公钥,客户端默认不会自动向服务器请求,所以提前加这个参数能少踩一个坑。
如果你希望用传统位点方式,那就不开GTID定位,改用:
CHANGE MASTER TO MASTER_HOST = 'mysql-master', MASTER_PORT = 3306, MASTER_USER = 'repl', MASTER_PASSWORD = 'Repl@123456', MASTER_LOG_FILE = 'mysql-bin.000001', MASTER_LOG_POS = 157, GET_MASTER_PUBLIC_KEY = 1;这里的MASTER_LOG_FILE和MASTER_LOG_POS必须是从主库SHOW MASTER STATUS中读取到的最新值。新手最容易在这里填错,比如填了0,或者填了一个曾经看到过的旧位点,结果复制的起始位置对不上,后续必然报错。所以我更推荐GTID方式,省去人工对准位点的麻烦。在文章后面的排错章节,我会专门讲位点填错会引发什么现象。
4.4 启动复制并检查关键状态
执行完CHANGE MASTER后,在从库启动复制线程:
START SLAVE;然后查看复制状态:
SHOW SLAVE STATUS\G重点关注三列:
Slave_IO_Running: 应该为Yes,代表I/O线程连接主库正常并正在拉取日志。Slave_SQL_Running: 应该为Yes,代表SQL线程正在回放日志。Seconds_Behind_Master: 从库落后主库的秒数,新环境里通常是0或很小的值。
如果看到的是Connecting或者No,说明复制链路有问题。不用急,我在下一节把最常见的情况都列出来。
另外,如果你用了GTID模式,可以顺便检查GTID是否已在主从间同步:
SHOW MASTER STATUS;在主库和从库分别查看Executed_Gtid_Set,正常情况下从库会逐渐追平主库的GTID集合,这就表示复制正沿着正确轨道运行。
5. 复制状态验证与四个高频故障排查链路
5.1 一张表验证同步是否真正生效
复制搭建完成后,一定要做一个简单但关键的验证:在从库避免写入的前提下,主库建库建表插数据,然后回从库查看结果。这能确认整个链路是通的。
在主库执行:
CREATE DATABASE demo; USE demo; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT INTO user (name) VALUES ('张三'), ('李四');然后到从库执行:
SHOW DATABASES; USE demo; SELECT * FROM user;如果看到刚才插入的两行数据,恭喜你,主从链路已经跑通。对这个验证过程,我有两个建议。第一,从库上不要执行任何写操作,哪怕手滑建了一张表都可能破坏复制的一致性。第二,验证完之后再故意破坏一次,用来观察故障现象——很多人不敢这么试,但其实在测试环境里演练故障,比生产出现问题再慌慌张张地查要好得多。
5.2 故障一:Slave_IO_Running一直Connecting
这是IO线程连不上主库导致的。排查思路从网络、账号、参数三个层面依次推进。
首先确认两个容器在同一条自定义网络里,且能互通:
docker exec -it mysql-slave ping mysql-master如果ping不通,检查网络名是否一致,容器是否都运行在mysql-cluster-net上。Docker网络这块看起来简单,但实际部署中我发现相当多的人栽在拼写错误上,比如网络名多打了一个字符,或者容器启动时忘了加--network参数。
然后检查复制账号能否正常连接。可以在从库容器内手动尝试连接主库:
docker exec -it mysql-slave mysql -h mysql-master -P 3306 -u repl -p如果提示密码错误、账号不存在或host不匹配,就去主库重新授权。记住:你创建用户时写的是'repl'@'%',如果写成'repl'@'localhost',从库容器自然连不进来。
还有一种情况是主库的bind-address默认监听所有地址,一般不会卡在这里;但如果你在配置里手动限定了bind-address=127.0.0.1,那即使从库网络通畅,也连不进来。遇到Slave_IO_Running=Connecting先别急着怀疑密码,把网络连通性、账号权限、bind-address排查一遍,大部分问题都能定位。
5.3 故障二:Slave_SQL_Running=No与1062主键冲突
SQL线程停止要比IO线程停止复杂一点,因为它代表从库在回放日志时遇到了数据冲突。最常见的报错是一串数字,比如1062,对应“Duplicate entry”。原因通常是:主库插入了某条记录,从库对应表也已经存在相同主键的数据,回放到这条时自然就报重复了。
复现这个场景很简单:主库插入一行id=1的记录,从库同步后有这条数据,然后你在从库手动再插入一条id=1的记录(虽然我说过不要写从库,但故障演练时可以这么做),接着回主库修改或插入数据触发同步,SQL线程就会卡在1062上。
遇到这种报错,先看具体错误信息:
SHOW SLAVE STATUS\G关注Last_SQL_Error字段,它会明确告诉你出错时间、错误码、具体是哪条SQL执行失败。处理思路分两步:先解决从库的数据冲突,再跳过或修复出错的事务。
如果只是测试环境,数据也不重要,可以手动从从库删除产生冲突的那条记录,然后重启SQL线程:
STOP SLAVE; SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1; START SLAVE;SQL_SLAVE_SKIP_COUNTER=1的意思是让SQL线程跳过下一条出错事务,适用于单条错误不严重、能够忽略的情况。但如果是生产环境,我会严肃地提醒你:跳过错误不是根本解决之道,你应该对比主从两边的数据,找出不一致的根源,必要时用工具做一次数据校正。跳过机制只是给了你一个“先让业务跑起来”的窗口。
5.4 故障三:auto.cnf里藏着的UUID冲突
这个坑多半出现在你使用了一个已经初始化过的数据目录去复用容器,比如你把/opt/mysql-slave/data直接从备份目录或其他容器里拷过来。
MySQL实例在数据目录下有一个auto.cnf文件,里面保存了该实例的server UUID。如果主库和从库的auto.cnf内容恰好一致,虽然主从复制配置正确,但是复制启动时会被判定为同一台机器,报错信息里会有Fatal error: The slave I/O thread stops because master and slave have equal MySQL server UUIDs。
解决办法很简单:先停止从库容器,删掉从库数据目录里的auto.cnf,再重新启动容器,MySQL会自动生成一个新的UUID。如果主从已经互相建立了复制关系,删除前记得先确认状态,必要时STOP SLAVE,重建UUID后重新START SLAVE即可。
这类问题最坑的地方是它不按常理出牌,配置看起来一切正常,网络也通,但复制就是起不来。我的经验是:凡是复用了数据目录、或者克隆过容器的场景,第一时间先检查两边的auto.cnf。
5.5 故障四:起始位点错误导致复制错位
传统位点方式的部署中,另一个高频故障是把MASTER_LOG_POS填错了。比如你执行CHANGE MASTER时填入了旧位点,从库会尝试从那个位置开始拉取日志,但主库的binlog并不会无限往后保存,要么日志文件已经被清理,要么起始位置的内容和从库当前状态对不上,表现为复制通道建立失败,或者复制出去了数据错乱。
排查这个问题的思路是:重新到主库执行SHOW MASTER STATUS,把当前最新的日志文件和位点记录下来,然后回到从库STOP SLAVE,重新执行一次CHANGE MASTER,填入最新位点后再START SLAVE。
如果你确实需要从某个历史时间点开始复制,那就要先确定该时间点对应的binlog和位点,而不是想当然地填0或填1。这也是我一再强调GTID模式更省心的原因——它把定位的工作交给了MySQL自己。
说到位点,我再补充一个容易忽视的环节:主库的binlog是有保留期和清理策略的。如果从库落后主库太久,主库的binlog已经被轮转清理,从库就无法追平,只能重建整个从库实例。所以监控里Seconds_Behind_Master一变大,要尽快处理,别拖到日志被清掉。
6. 主从架构的下一步:从入门到可用的过渡
6.1 一主多从、级联复制、双主
跑通一主一从之后,你自然会发现这套链路可以横向扩展。读压力继续增长时,可以再拉两台从库,把只读流量分散到多个实例上。扩展时不需要重新创建主库,按照前面的步骤再添加一台从库,让它从主库拉取日志即可。
如果从库数量很多,或者主从跨地域,也可以考虑级联复制,也就是让一台从库继续作为下一级从库的主库。这正好解释了为什么我在从库配置中把log-slave-updates打开——如果不开,从库回放主库日志时不会写自己的binlog,那它就没有资格再做下一级的主库。
还有双主架构,两个实例互为主从。这种方案能应对单点写入失败,但写入冲突需要业务层处理,复杂度会上一个台阶。我建议新手先把一主一从和一主多从搞熟练,再去碰双主或MMM方案。
6.2 安全加固与半同步
如果你的主从链路是跨机房部署,或者经过不完全可信的网络,我建议至少开启MySQL SSL复制。8.0默认支持SSL,配置上需要生成证书或使用自签名证书,然后在CHANGE MASTER语句里追加MASTER_SSL=1。
另外,前面提到异步复制存在主库宕机丢数据的风险,生产环境通常会用半同步复制来缓解。MySQL 8.0原生支持半同步插件,启用后主库在等待从库确认日志接收后才会提交事务,能够在性能和可靠性之间取得一个折中点。如果对这块感兴趣,可以单独拿一套环境去测,重点观察rpl_semi_sync_master_status和rpl_semi_sync_slave_status的状态值。
6.3 切换演练和我的实用建议
最后说一个我亲身实践过的建议:主从搭建完之后,一定要做至少一次切换演练。把主库容器人为停掉,在从库上执行:
STOP SLAVE; RESET SLAVE ALL;此时从库就变成了独立主库。让业务临时连到这个库上先跑着,验证应用配置和新端口、新账号是否匹配。演练完之后再把原主库恢复,重新建立复制关系。整个过程走一遍,你才能真正理解主从切换不是一句“把从库改成主库”那么简单。
我平时最常被人问的问题是:主从复制已经搭好了,怎么保证从库数据和主库完全一致?实际操作中,直接用percona-toolkit里的pt-table-checksum和pt-table-sync工具可以校对和修复一致性,但它依赖从库临时开放写权限,生产环境操作前一定要先评估。这个工具不在本文范围内,但如果你遇到了两边账目对不上的情况,可以往这个方向查一查。
就我个人体会而言,学主从复制的最佳方式就是亲手搭一遍,然后故意把环境弄坏,再亲手把它修好。光看不练,你永远理解不了“位点”“relay log”“SQL线程停止”这些词到底意味着什么。等到你在测试环境里把Connecting、1062、UUID冲突都亲身处理过一遍,再面对生产环境时心里就有底了。